MediaWiki 1.41.2
HTMLFormFieldCloner.php
Go to the documentation of this file.
1<?php
2
6
44 private static $counter = 0;
45
51 protected $uniqueId;
52
53 /* @var HTMLFormField[] */
54 protected $mFields = [];
55
60 public function __construct( $params ) {
61 $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
62 parent::__construct( $params );
63
64 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
65 throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
66 }
67
68 // Make sure the delete button, if explicitly specified, is sensible
69 if ( isset( $this->mParams['fields']['delete'] ) ) {
70 $class = 'mw-htmlform-cloner-delete-button';
71 $info = $this->mParams['fields']['delete'] + [
72 'formnovalidate' => true,
73 'cssclass' => $class
74 ];
75 unset( $info['name'], $info['class'] );
76
77 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
78 throw new InvalidArgumentException(
79 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
80 );
81 }
82
83 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
84 $info['cssclass'] .= " $class";
85 }
86
87 $this->mParams['fields']['delete'] = $info;
88 }
89 }
90
95 protected function getFieldsForKey( $key ) {
96 if ( !isset( $this->mFields[$key] ) ) {
97 $this->mFields[$key] = $this->createFieldsForKey( $key );
98 }
99 return $this->mFields[$key];
100 }
101
109 protected function createFieldsForKey( $key ) {
110 $fields = [];
111 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
112 $name = "{$this->mName}[$key][$fieldname]";
113 if ( isset( $info['name'] ) ) {
114 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
115 } else {
116 $info['name'] = $name;
117 }
118 if ( isset( $info['id'] ) ) {
119 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
120 } else {
121 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
122 }
123 // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
124 // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
125 if ( $this->mCondState ) {
126 foreach ( [ 'hide', 'disable' ] as $type ) {
127 if ( !isset( $this->mCondState[$type] ) ) {
128 continue;
129 }
130 $param = $type . '-if';
131 if ( isset( $info[$param] ) ) {
132 // Hide or disable child field if either its rules say so, or parent's rules say so.
133 $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
134 } else {
135 // Hide or disable child field if parent's rules say so.
136 $info[$param] = $this->mCondState[$type];
137 }
138 }
139 }
140 $cloner = $this;
141 $info['cloner'] = &$cloner;
142 $info['cloner-key'] = $key;
143 $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
144 $fields[$fieldname] = $field;
145 }
146 return $fields;
147 }
148
157 protected function rekeyValuesArray( $key, $values ) {
158 $data = [];
159 foreach ( $values as $fieldname => $value ) {
160 $name = "{$this->mName}[$key][$fieldname]";
161 $data[$name] = $value;
162 }
163 return $data;
164 }
165
170 protected function parseFieldPath( $name ) {
171 $fieldKeys = [];
172 while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
173 array_unshift( $fieldKeys, $m[2] );
174 $name = $m[1];
175 }
176 array_unshift( $fieldKeys, $name );
177 return $fieldKeys;
178 }
179
188 public function findNearestField( $field, $find ) {
189 $findPath = $this->parseFieldPath( $find );
190 // Access to fields as child or in other group is not allowed.
191 // Further support for a more complicated path may conduct here.
192 if ( count( $findPath ) > 1 ) {
193 return null;
194 }
195 if ( !isset( $this->mParams['fields'][$find] ) ) {
196 if ( isset( $this->mParams['cloner'] ) ) {
197 return $this->mParams['cloner']->findNearestField( $this, $find );
198 }
199 return null;
200 }
201 $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
202 return $fields[$find];
203 }
204
209 protected function getFieldPath( $field ) {
210 $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
211 if ( isset( $this->mParams['cloner'] ) ) {
212 $path = array_merge( $this->mParams['cloner']->getFieldPath( $this ), $path );
213 }
214 return $path;
215 }
216
224 public function extractFieldData( $field, $alldata ) {
225 foreach ( $this->getFieldPath( $field ) as $key ) {
226 $alldata = $alldata[$key];
227 }
228 return $alldata[$field->mParams['fieldname']];
229 }
230
231 protected function needsLabel() {
232 return false;
233 }
234
235 public function loadDataFromRequest( $request ) {
236 // It's possible that this might be posted with no fields. Detect that
237 // by looking for an edit token.
238 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
239 return $this->getDefault();
240 }
241
242 $values = $request->getArray( $this->mName ) ?? [];
243
244 $ret = [];
245 foreach ( $values as $key => $value ) {
246 if ( $key === 'create' || isset( $value['delete'] ) ) {
247 $ret['nonjs'] = 1;
248 continue;
249 }
250
251 // Add back in $request->getValues() so things that look for e.g.
252 // wpEditToken don't fail.
253 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
254
255 $fields = $this->getFieldsForKey( $key );
256 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
257 $row = [];
258 foreach ( $fields as $fieldname => $field ) {
259 if ( $field->skipLoadData( $subrequest ) ) {
260 continue;
261 }
262 if ( !empty( $field->mParams['disabled'] ) ) {
263 $row[$fieldname] = $field->getDefault();
264 } else {
265 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
266 }
267 }
268 $ret[] = $row;
269 }
270
271 if ( isset( $values['create'] ) ) {
272 // Non-JS client clicked the "create" button.
273 $fields = $this->getFieldsForKey( $this->uniqueId );
274 $row = [];
275 foreach ( $fields as $fieldname => $field ) {
276 if ( !empty( $field->mParams['nodata'] ) ) {
277 continue;
278 }
279 $row[$fieldname] = $field->getDefault();
280 }
281 $ret[] = $row;
282 }
283
284 return $ret;
285 }
286
287 public function getDefault() {
288 $ret = parent::getDefault();
289
290 // The default is one entry with all subfields at their defaults.
291 if ( $ret === null ) {
292 $fields = $this->getFieldsForKey( $this->uniqueId );
293 $row = [];
294 foreach ( $fields as $fieldname => $field ) {
295 if ( !empty( $field->mParams['nodata'] ) ) {
296 continue;
297 }
298 $row[$fieldname] = $field->getDefault();
299 }
300 $ret = [ $row ];
301 }
302
303 return $ret;
304 }
305
310 public function cancelSubmit( $values, $alldata ) {
311 if ( isset( $values['nonjs'] ) ) {
312 return true;
313 }
314
315 foreach ( $values as $key => $value ) {
316 $fields = $this->getFieldsForKey( $key );
317 foreach ( $fields as $fieldname => $field ) {
318 if ( !array_key_exists( $fieldname, $value ) ) {
319 continue;
320 }
321 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
322 return true;
323 }
324 }
325 }
326
327 return parent::cancelSubmit( $values, $alldata );
328 }
329
334 public function validate( $values, $alldata ) {
335 if ( isset( $this->mParams['required'] )
336 && $this->mParams['required'] !== false
337 && !$values
338 ) {
339 return $this->msg( 'htmlform-cloner-required' );
340 }
341
342 if ( isset( $values['nonjs'] ) ) {
343 // The submission was a non-JS create/delete click, so fail
344 // validation in case cancelSubmit() somehow didn't already handle
345 // it.
346 return false;
347 }
348
349 foreach ( $values as $key => $value ) {
350 $fields = $this->getFieldsForKey( $key );
351 foreach ( $fields as $fieldname => $field ) {
352 if ( !array_key_exists( $fieldname, $value ) || $field->isHidden( $alldata ) ) {
353 continue;
354 }
355 $ok = $field->validate( $value[$fieldname], $alldata );
356 if ( $ok !== true ) {
357 return false;
358 }
359 }
360 }
361
362 return parent::validate( $values, $alldata );
363 }
364
372 protected function getInputHTMLForKey( $key, array $values ) {
373 $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
374
375 // Conveniently, PHP method names are case-insensitive.
376 $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
377
378 $html = '';
379 $hidden = '';
380 $hasLabel = false;
381
382 $fields = $this->getFieldsForKey( $key );
383 foreach ( $fields as $fieldname => $field ) {
384 $v = array_key_exists( $fieldname, $values )
385 ? $values[$fieldname]
386 : $field->getDefault();
387
388 if ( $field instanceof HTMLHiddenField ) {
389 // HTMLHiddenField doesn't generate its own HTML
390 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
391 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
392 } else {
393 $html .= $field->$getFieldHtmlMethod( $v );
394
395 $labelValue = trim( $field->getLabel() );
396 if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
397 $hasLabel = true;
398 }
399 }
400 }
401
402 if ( !isset( $fields['delete'] ) ) {
403 $field = $this->getDeleteButtonHtml( $key );
404
405 if ( $displayFormat === 'table' ) {
406 $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
407 } else {
408 $html .= $field->getInputHTML( $field->getDefault() );
409 }
410 }
411
412 if ( $displayFormat !== 'raw' ) {
413 $classes = [ 'mw-htmlform-cloner-row' ];
414
415 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
416 $classes[] = 'mw-htmlform-nolabel';
417 }
418
419 $attribs = [ 'class' => $classes ];
420
421 if ( $displayFormat === 'table' ) {
422 $html = Html::rawElement( 'table',
423 $attribs,
424 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
425 } else {
426 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
427 }
428 }
429
430 $html .= $hidden;
431
432 if ( !empty( $this->mParams['row-legend'] ) ) {
433 $legend = $this->msg( $this->mParams['row-legend'] )->text();
434 $html = Xml::fieldset( $legend, $html );
435 }
436
437 return $html;
438 }
439
444 protected function getDeleteButtonHtml( $key ): HTMLFormField {
445 $name = "{$this->mName}[$key][delete]";
446 $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
447 $field = HTMLForm::loadInputFromParameters( $name, [
448 'type' => 'submit',
449 'formnovalidate' => true,
450 'name' => $name,
451 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
452 'cssclass' => 'mw-htmlform-cloner-delete-button',
453 'default' => $this->getMessage( $label )->text(),
454 'disabled' => $this->mParams['disabled'] ?? false,
455 ], $this->mParent );
456 return $field;
457 }
458
459 protected function getCreateButtonHtml(): HTMLFormField {
460 $name = "{$this->mName}[create]";
461 $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
462 return HTMLForm::loadInputFromParameters( $name, [
463 'type' => 'submit',
464 'formnovalidate' => true,
465 'name' => $name,
466 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
467 'cssclass' => 'mw-htmlform-cloner-create-button',
468 'default' => $this->getMessage( $label )->text(),
469 'disabled' => $this->mParams['disabled'] ?? false,
470 ], $this->mParent );
471 }
472
473 public function getInputHTML( $values ) {
474 $html = '';
475
476 foreach ( (array)$values as $key => $value ) {
477 if ( $key === 'nonjs' ) {
478 continue;
479 }
480 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
481 $this->getInputHTMLForKey( $key, $value )
482 );
483 }
484
485 $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
486 $html = Html::rawElement( 'ul', [
487 'id' => "mw-htmlform-cloner-list-{$this->mID}",
488 'class' => 'mw-htmlform-cloner-ul',
489 'data-template' => $template,
490 'data-unique-id' => $this->uniqueId,
491 ], $html );
492
493 $field = $this->getCreateButtonHtml();
494 $html .= $field->getInputHTML( $field->getDefault() );
495
496 return $html;
497 }
498
506 protected function getInputOOUIForKey( $key, array $values ) {
507 $html = '';
508 $hidden = '';
509
510 $fields = $this->getFieldsForKey( $key );
511 foreach ( $fields as $fieldname => $field ) {
512 $v = array_key_exists( $fieldname, $values )
513 ? $values[$fieldname]
514 : $field->getDefault();
515
516 if ( $field instanceof HTMLHiddenField ) {
517 // HTMLHiddenField doesn't generate its own HTML
518 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
519 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
520 } else {
521 $html .= $field->getOOUI( $v );
522 }
523 }
524
525 if ( !isset( $fields['delete'] ) ) {
526 $field = $this->getDeleteButtonHtml( $key );
527 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
528 $fieldHtml->setInfusable( true );
529
530 $html .= $fieldHtml;
531 }
532
533 $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
534
535 $html .= $hidden;
536
537 if ( !empty( $this->mParams['row-legend'] ) ) {
538 $legend = $this->msg( $this->mParams['row-legend'] )->text();
539 $html = Xml::fieldset( $legend, $html );
540 }
541
542 return $html;
543 }
544
545 public function getInputOOUI( $values ) {
546 $html = '';
547
548 foreach ( (array)$values as $key => $value ) {
549 if ( $key === 'nonjs' ) {
550 continue;
551 }
552 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
553 $this->getInputOOUIForKey( $key, $value )
554 );
555 }
556
557 $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
558 $html = Html::rawElement( 'ul', [
559 'id' => "mw-htmlform-cloner-list-{$this->mID}",
560 'class' => 'mw-htmlform-cloner-ul',
561 'data-template' => $template,
562 'data-unique-id' => $this->uniqueId,
563 ], $html );
564
565 $field = $this->getCreateButtonHtml();
566 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
567 $fieldHtml->setInfusable( true );
568
569 $html .= $fieldHtml;
570
571 return $html;
572 }
573}
A container for HTMLFormFields that allows for multiple copies of the set of fields to be displayed t...
rekeyValuesArray( $key, $values)
Re-key the specified values array to match the names applied by createFieldsForKey().
extractFieldData( $field, $alldata)
Extract field data for a given field that belongs to this cloner.
needsLabel()
Should this field have a label, or is there no input element with the appropriate id for the label to...
findNearestField( $field, $find)
Find the nearest field to a field in this cloner matched the given name, walk through the chain of cl...
validate( $values, $alldata)
Override this function to add specific validation checks on the field input.Don't forget to call pare...
createFieldsForKey( $key)
Create the HTMLFormFields that go inside this element, using the specified key.
getInputOOUI( $values)
Same as getInputHTML, but returns an OOUI object.
string $uniqueId
String uniquely identifying this cloner instance and unlikely to exist otherwise in the generated HTM...
getInputOOUIForKey( $key, array $values)
Get the input OOUI HTML for the specified key.
getInputHTMLForKey( $key, array $values)
Get the input HTML for the specified key.
loadDataFromRequest( $request)
Get the value that this input has been set to from a posted form, or the input's default value if it ...
getInputHTML( $values)
This function must be implemented to return the HTML to generate the input object itself.
cancelSubmit( $values, $alldata)
Override this function if the control can somehow trigger a form submission that shouldn't actually s...
The parent class to generate form fields.
getMessage( $value)
Turns a *-message parameter (which could be a MessageSpecifier, or a message name,...
getClassName()
Gets the non namespaced class name.
msg( $key,... $params)
Get a translated interface message.
This class is a collection of static functions that serve two purposes:
Definition Html.php:57
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:46
Similar to MediaWiki\Request\FauxRequest, but only fakes URL parameters and method (POST or GET) and ...