MediaWiki master
HTMLFormFieldCloner.php
Go to the documentation of this file.
1<?php
2
4
5use InvalidArgumentException;
11
50 private static $counter = 0;
51
57 protected $uniqueId;
58
60 protected $mFields = [];
61
62 private bool $nonJsUpdate = false;
63
68 public function __construct( $params ) {
69 $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
70 parent::__construct( $params );
71
72 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
73 throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
74 }
75
76 // Make sure the delete button, if explicitly specified, is sensible
77 if ( isset( $this->mParams['fields']['delete'] ) ) {
78 $class = 'mw-htmlform-cloner-delete-button';
79 $info = $this->mParams['fields']['delete'] + [
80 'formnovalidate' => true,
81 'cssclass' => $class
82 ];
83 unset( $info['name'], $info['class'] );
84
85 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
86 throw new InvalidArgumentException(
87 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
88 );
89 }
90
91 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
92 $info['cssclass'] .= " $class";
93 }
94
95 $this->mParams['fields']['delete'] = $info;
96 }
97 }
98
103 protected function getFieldsForKey( $key ) {
104 if ( !isset( $this->mFields[$key] ) ) {
105 $this->mFields[$key] = $this->createFieldsForKey( $key );
106 }
107 return $this->mFields[$key];
108 }
109
117 protected function createFieldsForKey( $key ) {
118 $fields = [];
119 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
120 $name = "{$this->mName}[$key][$fieldname]";
121 if ( isset( $info['name'] ) ) {
122 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
123 } else {
124 $info['name'] = $name;
125 }
126 if ( isset( $info['id'] ) ) {
127 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
128 } else {
129 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
130 }
131 // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
132 // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
133 if ( $this->mCondState ) {
134 foreach ( [ 'hide', 'disable' ] as $type ) {
135 if ( !isset( $this->mCondState[$type] ) ) {
136 continue;
137 }
138 $param = $type . '-if';
139 if ( isset( $info[$param] ) ) {
140 // Hide or disable child field if either its rules say so, or parent's rules say so.
141 $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
142 } else {
143 // Hide or disable child field if parent's rules say so.
144 $info[$param] = $this->mCondState[$type];
145 }
146 }
147 }
148 $cloner = $this;
149 $info['cloner'] = &$cloner;
150 $info['cloner-key'] = $key;
151 $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
152 $fields[$fieldname] = $field;
153 }
154 return $fields;
155 }
156
165 protected function rekeyValuesArray( $key, $values ) {
166 $data = [];
167 foreach ( $values as $fieldname => $value ) {
168 $name = "{$this->mName}[$key][$fieldname]";
169 $data[$name] = $value;
170 }
171 return $data;
172 }
173
178 protected function parseFieldPath( $name ) {
179 $fieldKeys = [];
180 while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
181 array_unshift( $fieldKeys, $m[2] );
182 $name = $m[1];
183 }
184 array_unshift( $fieldKeys, $name );
185 return $fieldKeys;
186 }
187
196 public function findNearestField( $field, $find ) {
197 $findPath = $this->parseFieldPath( $find );
198 // Access to fields as child or in other group is not allowed.
199 // Further support for a more complicated path may conduct here.
200 if ( count( $findPath ) > 1 ) {
201 return null;
202 }
203 if ( !isset( $this->mParams['fields'][$find] ) ) {
204 $cloner = $this->mParams['cloner'] ?? null;
205 if ( $cloner instanceof self ) {
206 return $cloner->findNearestField( $this, $find );
207 }
208 return null;
209 }
210 $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
211 return $fields[$find];
212 }
213
218 protected function getFieldPath( $field ) {
219 $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
220 $cloner = $this->mParams['cloner'] ?? null;
221 if ( $cloner instanceof self ) {
222 $path = array_merge( $cloner->getFieldPath( $this ), $path );
223 }
224 return $path;
225 }
226
234 public function extractFieldData( $field, $alldata ) {
235 if (
236 // Is an empty array when first rendering a form with a formIdentifier.
237 count( $alldata ) === 0 ||
238 // This field is either part of the cloner template or is newly created
239 // for non-JS users, which is not tracked in the form's field data (T391882).
240 $field->mParams['cloner-key'] === $this->uniqueId
241 ) {
242 return $field->getDefault();
243 }
244
245 foreach ( $this->getFieldPath( $field ) as $key ) {
246 $alldata = $alldata[$key];
247 }
248 return $alldata[$field->mParams['fieldname']];
249 }
250
252 protected function needsLabel() {
253 return false;
254 }
255
257 public function loadDataFromRequest( $request ) {
258 // It's possible that this might be posted with no fields. Detect that
259 // by looking for an edit token.
260 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
261 return $this->getDefault();
262 }
263
264 $values = $request->getArray( $this->mName ) ?? [];
265
266 $ret = [];
267 foreach ( $values as $key => $value ) {
268 if ( $key === 'create' || isset( $value['delete'] ) ) {
269 $this->nonJsUpdate = true;
270 continue;
271 }
272
273 // Add back in $request->getValues() so things that look for e.g.
274 // wpEditToken don't fail.
275 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
276
277 $fields = $this->getFieldsForKey( $key );
278 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
279 $row = [];
280 foreach ( $fields as $fieldname => $field ) {
281 if ( $field->skipLoadData( $subrequest ) ) {
282 continue;
283 }
284 if ( !empty( $field->mParams['disabled'] ) ) {
285 $row[$fieldname] = $field->getDefault();
286 } else {
287 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
288 }
289 }
290 $ret[] = $row;
291 }
292
293 if ( isset( $values['create'] ) ) {
294 // Non-JS client clicked the "create" button.
295 $fields = $this->getFieldsForKey( $this->uniqueId );
296 $row = [];
297 foreach ( $fields as $fieldname => $field ) {
298 if ( !empty( $field->mParams['nodata'] ) ) {
299 continue;
300 }
301 $row[$fieldname] = $field->getDefault();
302 }
303 $ret[] = $row;
304 }
305
306 return $ret;
307 }
308
310 public function filter( $values, $alldata ) {
311 // Mimic the later stage of HTMLForm::loadFieldData() as if for normal fields.
312 foreach ( $values as $key => &$fieldsValue ) {
313 $fields = $this->getFieldsForKey( $key );
314 foreach ( $fieldsValue as $fieldname => &$value ) {
315 // Reset to default for fields that are supposed to be disabled.
316 if ( $fields[$fieldname]->isDisabled( $alldata ) ) {
317 $value = $fields[$fieldname]->getDefault();
318 }
319
320 // Apply field-specific filters.
321 $value = $fields[$fieldname]->filter( $value, $alldata );
322 }
323 }
324
325 // Apply the filter defined by the 'filter-callback' option at the end.
326 return parent::filter( $values, $alldata );
327 }
328
330 public function getDefault() {
331 $ret = parent::getDefault();
332
333 // Some existing use cases in SecurePoll use an empty string as the default value.
334 // TODO: Throw an exception in future versions.
335 if ( $ret !== null && !is_array( $ret ) ) {
336 $type = gettype( $ret );
337 wfDeprecated( __CLASS__ . " with non-array default ($type given)", '1.45' );
338 }
339
340 // The default is one entry with all subfields at their defaults.
341 if ( $ret === null || !is_array( $ret ) ) {
342 $fields = $this->getFieldsForKey( $this->uniqueId );
343 $row = [];
344 foreach ( $fields as $fieldname => $field ) {
345 if ( !empty( $field->mParams['nodata'] ) ) {
346 continue;
347 }
348 $row[$fieldname] = $field->getDefault();
349 }
350 $ret = [ $row ];
351 }
352
353 return $ret;
354 }
355
360 public function cancelSubmit( $values, $alldata ) {
361 if ( $this->nonJsUpdate ) {
362 return true;
363 }
364
365 foreach ( $values as $key => $value ) {
366 $fields = $this->getFieldsForKey( $key );
367 foreach ( $fields as $fieldname => $field ) {
368 if ( !array_key_exists( $fieldname, $value ) ) {
369 continue;
370 }
371 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
372 return true;
373 }
374 }
375 }
376
377 return parent::cancelSubmit( $values, $alldata );
378 }
379
384 public function validate( $values, $alldata ) {
385 if ( isset( $this->mParams['required'] )
386 && $this->mParams['required'] !== false
387 && !$values
388 ) {
389 return $this->msg( 'htmlform-cloner-required' );
390 }
391
392 if ( $this->nonJsUpdate ) {
393 // The submission was a non-JS create/delete click, so fail
394 // validation in case cancelSubmit() somehow didn't already handle
395 // it.
396 return false;
397 }
398
399 foreach ( $values as $key => $value ) {
400 $fields = $this->getFieldsForKey( $key );
401 foreach ( $fields as $fieldname => $field ) {
402 if ( !array_key_exists( $fieldname, $value ) || $field->isHidden( $alldata ) ) {
403 continue;
404 }
405 $ok = $field->validate( $value[$fieldname], $alldata );
406 if ( $ok !== true ) {
407 return false;
408 }
409 }
410 }
411
412 return parent::validate( $values, $alldata );
413 }
414
422 protected function getInputHTMLForKey( $key, array $values ) {
423 $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
424
425 // Conveniently, PHP method names are case-insensitive.
426 $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
427
428 $html = '';
429 $hidden = '';
430 $hasLabel = false;
431
432 $fields = $this->getFieldsForKey( $key );
433 foreach ( $fields as $fieldname => $field ) {
434 $v = array_key_exists( $fieldname, $values )
435 ? $values[$fieldname]
436 : $field->getDefault();
437
438 if ( $field instanceof HTMLHiddenField ) {
439 // HTMLHiddenField doesn't generate its own HTML
440 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
441 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
442 } else {
443 $html .= $field->$getFieldHtmlMethod( $v );
444
445 $labelValue = trim( $field->getLabel() );
446 if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
447 $hasLabel = true;
448 }
449 }
450 }
451
452 if ( !isset( $fields['delete'] ) ) {
453 $field = $this->getDeleteButtonHtml( $key );
454
455 if ( $displayFormat === 'table' ) {
456 $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
457 } else {
458 $html .= $field->getInputHTML( $field->getDefault() );
459 }
460 }
461
462 if ( $displayFormat !== 'raw' ) {
463 $classes = [ 'mw-htmlform-cloner-row' ];
464
465 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
466 $classes[] = 'mw-htmlform-nolabel';
467 }
468
469 $attribs = [ 'class' => $classes ];
470
471 if ( $displayFormat === 'table' ) {
472 $html = Html::rawElement( 'table',
473 $attribs,
474 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
475 } else {
476 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
477 }
478 }
479
480 $html .= $hidden;
481
482 if ( !empty( $this->mParams['row-legend'] ) ) {
483 $legend = $this->msg( $this->mParams['row-legend'] )->text();
484 $legend = $legend ? Html::element( 'legend', [], $legend ) : '';
485 $html = Html::rawElement(
486 'fieldset',
487 [],
488 $legend . $html
489 );
490 }
491
492 return $html;
493 }
494
499 protected function getDeleteButtonHtml( $key ): HTMLFormField {
500 $name = "{$this->mName}[$key][delete]";
501 $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
502 $field = HTMLForm::loadInputFromParameters( $name, [
503 'type' => 'submit',
504 'formnovalidate' => true,
505 'name' => $name,
506 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
507 'cssclass' => 'mw-htmlform-cloner-delete-button',
508 'default' => $this->getMessage( $label )->text(),
509 'disabled' => $this->mParams['disabled'] ?? false,
510 ], $this->mParent );
511 return $field;
512 }
513
514 protected function getCreateButtonHtml(): HTMLFormField {
515 $name = "{$this->mName}[create]";
516 $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
517 return HTMLForm::loadInputFromParameters( $name, [
518 'type' => 'submit',
519 'formnovalidate' => true,
520 'name' => $name,
521 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
522 'cssclass' => 'mw-htmlform-cloner-create-button',
523 'default' => $this->getMessage( $label )->text(),
524 'disabled' => $this->mParams['disabled'] ?? false,
525 ], $this->mParent );
526 }
527
529 public function getInputHTML( $values ) {
530 $html = '';
531
532 foreach ( (array)$values as $key => $value ) {
533 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
534 $this->getInputHTMLForKey( $key, $value )
535 );
536 }
537
538 $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
539 $html = Html::rawElement( 'ul', [
540 'id' => "mw-htmlform-cloner-list-{$this->mID}",
541 'class' => 'mw-htmlform-cloner-ul',
542 'data-template' => $template,
543 'data-unique-id' => $this->uniqueId,
544 ], $html );
545
546 $field = $this->getCreateButtonHtml();
547 $html .= $field->getInputHTML( $field->getDefault() );
548
549 return $html;
550 }
551
559 protected function getInputOOUIForKey( $key, array $values ) {
560 $html = '';
561 $hidden = '';
562
563 $fields = $this->getFieldsForKey( $key );
564 foreach ( $fields as $fieldname => $field ) {
565 $v = array_key_exists( $fieldname, $values )
566 ? $values[$fieldname]
567 : $field->getDefault();
568
569 if ( $field instanceof HTMLHiddenField ) {
570 // HTMLHiddenField doesn't generate its own HTML
571 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
572 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
573 } else {
574 $html .= $field->getOOUI( $v );
575 }
576 }
577
578 if ( !isset( $fields['delete'] ) ) {
579 $field = $this->getDeleteButtonHtml( $key );
580 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
581 $fieldHtml->setInfusable( true );
582
583 $html .= $fieldHtml;
584 }
585
586 $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
587
588 $html .= $hidden;
589
590 if ( !empty( $this->mParams['row-legend'] ) ) {
591 $legend = $this->msg( $this->mParams['row-legend'] )->text();
592 $legend = $legend ? Html::element( 'legend', [], $legend ) : '';
593 $html = Html::rawElement(
594 'fieldset',
595 [],
596 $legend . $html
597 );
598 }
599
600 return $html;
601 }
602
604 public function getInputOOUI( $values ) {
605 $html = '';
606
607 foreach ( (array)$values as $key => $value ) {
608 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
609 $this->getInputOOUIForKey( $key, $value )
610 );
611 }
612
613 $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
614 $html = Html::rawElement( 'ul', [
615 'id' => "mw-htmlform-cloner-list-{$this->mID}",
616 'class' => 'mw-htmlform-cloner-ul',
617 'data-template' => $template,
618 'data-unique-id' => $this->uniqueId,
619 ], $html );
620
621 $field = $this->getCreateButtonHtml();
622 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
623 $fieldHtml->setInfusable( true );
624
625 $html .= $fieldHtml;
626
627 return $html;
628 }
629}
630
632class_alias( HTMLFormFieldCloner::class, 'HTMLFormFieldCloner' );
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
A container for HTMLFormFields that allows for multiple copies of the set of fields to be displayed t...
filter( $values, $alldata)
to overridemixed
needsLabel()
Should this field have a label, or is there no input element with the appropriate id for the label to...
extractFieldData( $field, $alldata)
Extract field data for a given field that belongs to this cloner.
getInputHTML( $values)
This function must be implemented to return the HTML to generate the input object itself....
if(empty( $this->mParams['fields'])||!is_array( $this->mParams['fields'])) if(isset($this->mParams[ 'fields'][ 'delete'])) getFieldsForKey( $key)
cancelSubmit( $values, $alldata)
Override this function if the control can somehow trigger a form submission that shouldn't actually s...
getInputOOUIForKey( $key, array $values)
Get the input OOUI HTML for the specified key.
getInputOOUI( $values)
Same as getInputHTML, but returns an OOUI object.Defaults to false, which getOOUI will interpret as "...
getInputHTMLForKey( $key, array $values)
Get the input HTML for the specified key.
rekeyValuesArray( $key, $values)
Re-key the specified values array to match the names applied by createFieldsForKey().
findNearestField( $field, $find)
Find the nearest field to a field in this cloner matched the given name, walk through the chain of cl...
array< string, $mFields=[];private bool $nonJsUpdate=false;public function __construct($params) { $this-> uniqueId
HTMLFormField[]>
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.
loadDataFromRequest( $request)
Get the value that this input has been set to from a posted form, or the input's default value if it ...
string $uniqueId
String uniquely identifying this cloner instance and unlikely to exist otherwise in the generated HTM...
The parent class to generate form fields.
getMessage( $value)
Turns a *-message parameter (which could be a MessageSpecifier, or a message name,...
__construct( $params)
Initialise the object.
getClassName()
Gets the non namespaced class name.
msg( $key,... $params)
Get a translated interface message.
isDisabled( $alldata)
Test whether this field is supposed to be disabled, based on the values of the other form fields.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:195
static loadInputFromParameters( $fieldname, $descriptor, ?HTMLForm $parent=null)
Initialise a new Object for the field.
Definition HTMLForm.php:622
This class is a collection of static functions that serve two purposes:
Definition Html.php:43
HTML sanitizer for MediaWiki.
Definition Sanitizer.php:32
Similar to MediaWiki\Request\FauxRequest, but only fakes URL parameters and method (POST or GET) and ...
element(SerializerNode $parent, SerializerNode $node, $contents)