MediaWiki REL1_39
HTMLFormFieldCloner.php
Go to the documentation of this file.
1<?php
2
40 private static $counter = 0;
41
47 protected $uniqueId;
48
49 /* @var HTMLFormField[] */
50 protected $mFields = [];
51
56 public function __construct( $params ) {
57 $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
58 parent::__construct( $params );
59
60 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
61 throw new MWException( 'HTMLFormFieldCloner called without any fields' );
62 }
63
64 // Make sure the delete button, if explicitly specified, is sensible
65 if ( isset( $this->mParams['fields']['delete'] ) ) {
66 $class = 'mw-htmlform-cloner-delete-button';
67 $info = $this->mParams['fields']['delete'] + [
68 'formnovalidate' => true,
69 'cssclass' => $class
70 ];
71 unset( $info['name'], $info['class'] );
72
73 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
74 throw new MWException(
75 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
76 );
77 }
78
79 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
80 $info['cssclass'] .= " $class";
81 }
82
83 $this->mParams['fields']['delete'] = $info;
84 }
85 }
86
91 protected function getFieldsForKey( $key ) {
92 if ( !isset( $this->mFields[$key] ) ) {
93 $this->mFields[$key] = $this->createFieldsForKey( $key );
94 }
95 return $this->mFields[$key];
96 }
97
105 protected function createFieldsForKey( $key ) {
106 $fields = [];
107 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
108 $name = "{$this->mName}[$key][$fieldname]";
109 if ( isset( $info['name'] ) ) {
110 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
111 } else {
112 $info['name'] = $name;
113 }
114 if ( isset( $info['id'] ) ) {
115 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
116 } else {
117 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
118 }
119 // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
120 // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
121 if ( $this->mCondState ) {
122 foreach ( [ 'hide', 'disable' ] as $type ) {
123 if ( !isset( $this->mCondState[$type] ) ) {
124 continue;
125 }
126 $param = $type . '-if';
127 if ( isset( $info[$param] ) ) {
128 // Hide or disable child field if either its rules say so, or parent's rules say so.
129 $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
130 } else {
131 // Hide or disable child field if parent's rules say so.
132 $info[$param] = $this->mCondState[$type];
133 }
134 }
135 }
136 $cloner = $this;
137 $info['cloner'] = &$cloner;
138 $info['cloner-key'] = $key;
139 $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
140 $fields[$fieldname] = $field;
141 }
142 return $fields;
143 }
144
153 protected function rekeyValuesArray( $key, $values ) {
154 $data = [];
155 foreach ( $values as $fieldname => $value ) {
156 $name = "{$this->mName}[$key][$fieldname]";
157 $data[$name] = $value;
158 }
159 return $data;
160 }
161
166 protected function parseFieldPath( $name ) {
167 $fieldKeys = [];
168 while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
169 array_unshift( $fieldKeys, $m[2] );
170 $name = $m[1];
171 }
172 array_unshift( $fieldKeys, $name );
173 return $fieldKeys;
174 }
175
184 public function findNearestField( $field, $find ) {
185 $findPath = $this->parseFieldPath( $find );
186 // Access to fields as child or in other group is not allowed.
187 // Further support for a more complicated path may conduct here.
188 if ( count( $findPath ) > 1 ) {
189 return null;
190 }
191 if ( !isset( $this->mParams['fields'][$find] ) ) {
192 if ( isset( $this->mParams['cloner'] ) ) {
193 return $this->mParams['cloner']->findNearestField( $this, $find );
194 }
195 return null;
196 }
197 $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
198 return $fields[$find];
199 }
200
205 protected function getFieldPath( $field ) {
206 $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
207 if ( isset( $this->mParams['cloner'] ) ) {
208 $path = array_merge( $this->mParams['cloner']->getFieldPath( $this ), $path );
209 }
210 return $path;
211 }
212
220 public function extractFieldData( $field, $alldata ) {
221 foreach ( $this->getFieldPath( $field ) as $key ) {
222 $alldata = $alldata[$key];
223 }
224 return $alldata[$field->mParams['fieldname']];
225 }
226
227 protected function needsLabel() {
228 return false;
229 }
230
231 public function loadDataFromRequest( $request ) {
232 // It's possible that this might be posted with no fields. Detect that
233 // by looking for an edit token.
234 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
235 return $this->getDefault();
236 }
237
238 $values = $request->getArray( $this->mName );
239 if ( $values === null ) {
240 $values = [];
241 }
242
243 $ret = [];
244 foreach ( $values as $key => $value ) {
245 if ( $key === 'create' || isset( $value['delete'] ) ) {
246 $ret['nonjs'] = 1;
247 continue;
248 }
249
250 // Add back in $request->getValues() so things that look for e.g.
251 // wpEditToken don't fail.
252 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
253
254 $fields = $this->getFieldsForKey( $key );
255 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
256 $row = [];
257 foreach ( $fields as $fieldname => $field ) {
258 if ( $field->skipLoadData( $subrequest ) ) {
259 continue;
260 }
261 if ( !empty( $field->mParams['disabled'] ) ) {
262 $row[$fieldname] = $field->getDefault();
263 } else {
264 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
265 }
266 }
267 $ret[] = $row;
268 }
269
270 if ( isset( $values['create'] ) ) {
271 // Non-JS client clicked the "create" button.
272 $fields = $this->getFieldsForKey( $this->uniqueId );
273 $row = [];
274 foreach ( $fields as $fieldname => $field ) {
275 if ( !empty( $field->mParams['nodata'] ) ) {
276 continue;
277 }
278 $row[$fieldname] = $field->getDefault();
279 }
280 $ret[] = $row;
281 }
282
283 return $ret;
284 }
285
286 public function getDefault() {
287 $ret = parent::getDefault();
288
289 // The default is one entry with all subfields at their defaults.
290 if ( $ret === null ) {
291 $fields = $this->getFieldsForKey( $this->uniqueId );
292 $row = [];
293 foreach ( $fields as $fieldname => $field ) {
294 if ( !empty( $field->mParams['nodata'] ) ) {
295 continue;
296 }
297 $row[$fieldname] = $field->getDefault();
298 }
299 $ret = [ $row ];
300 }
301
302 return $ret;
303 }
304
309 public function cancelSubmit( $values, $alldata ) {
310 if ( isset( $values['nonjs'] ) ) {
311 return true;
312 }
313
314 foreach ( $values as $key => $value ) {
315 $fields = $this->getFieldsForKey( $key );
316 foreach ( $fields as $fieldname => $field ) {
317 if ( !array_key_exists( $fieldname, $value ) ) {
318 continue;
319 }
320 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
321 return true;
322 }
323 }
324 }
325
326 return parent::cancelSubmit( $values, $alldata );
327 }
328
333 public function validate( $values, $alldata ) {
334 if ( isset( $this->mParams['required'] )
335 && $this->mParams['required'] !== false
336 && !$values
337 ) {
338 return $this->msg( 'htmlform-cloner-required' );
339 }
340
341 if ( isset( $values['nonjs'] ) ) {
342 // The submission was a non-JS create/delete click, so fail
343 // validation in case cancelSubmit() somehow didn't already handle
344 // it.
345 return false;
346 }
347
348 foreach ( $values as $key => $value ) {
349 $fields = $this->getFieldsForKey( $key );
350 foreach ( $fields as $fieldname => $field ) {
351 if ( !array_key_exists( $fieldname, $value ) ) {
352 continue;
353 }
354 if ( $field->isHidden( $alldata ) ) {
355 continue;
356 }
357 $ok = $field->validate( $value[$fieldname], $alldata );
358 if ( $ok !== true ) {
359 return false;
360 }
361 }
362 }
363
364 return parent::validate( $values, $alldata );
365 }
366
374 protected function getInputHTMLForKey( $key, array $values ) {
375 $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
376
377 // Conveniently, PHP method names are case-insensitive.
378 $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
379
380 $html = '';
381 $hidden = '';
382 $hasLabel = false;
383
384 $fields = $this->getFieldsForKey( $key );
385 foreach ( $fields as $fieldname => $field ) {
386 $v = array_key_exists( $fieldname, $values )
387 ? $values[$fieldname]
388 : $field->getDefault();
389
390 if ( $field instanceof HTMLHiddenField ) {
391 // HTMLHiddenField doesn't generate its own HTML
392 list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
393 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
394 } else {
395 $html .= $field->$getFieldHtmlMethod( $v );
396
397 $labelValue = trim( $field->getLabel() );
398 if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
399 $hasLabel = true;
400 }
401 }
402 }
403
404 if ( !isset( $fields['delete'] ) ) {
405 $field = $this->getDeleteButtonHtml( $key );
406
407 if ( $displayFormat === 'table' ) {
408 $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
409 } else {
410 $html .= $field->getInputHTML( $field->getDefault() );
411 }
412 }
413
414 if ( $displayFormat !== 'raw' ) {
415 $classes = [ 'mw-htmlform-cloner-row' ];
416
417 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
418 $classes[] = 'mw-htmlform-nolabel';
419 }
420
421 $attribs = [ 'class' => $classes ];
422
423 if ( $displayFormat === 'table' ) {
424 $html = Html::rawElement( 'table',
425 $attribs,
426 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
427 } else {
428 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
429 }
430 }
431
432 $html .= $hidden;
433
434 if ( !empty( $this->mParams['row-legend'] ) ) {
435 $legend = $this->msg( $this->mParams['row-legend'] )->text();
436 $html = Xml::fieldset( $legend, $html );
437 }
438
439 return $html;
440 }
441
446 protected function getDeleteButtonHtml( $key ): HTMLFormField {
447 $name = "{$this->mName}[$key][delete]";
448 $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
449 $field = HTMLForm::loadInputFromParameters( $name, [
450 'type' => 'submit',
451 'formnovalidate' => true,
452 'name' => $name,
453 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
454 'cssclass' => 'mw-htmlform-cloner-delete-button',
455 'default' => $this->getMessage( $label )->text(),
456 'disabled' => $this->mParams['disabled'] ?? false,
457 ], $this->mParent );
458 return $field;
459 }
460
461 protected function getCreateButtonHtml(): HTMLFormField {
462 $name = "{$this->mName}[create]";
463 $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
464 return HTMLForm::loadInputFromParameters( $name, [
465 'type' => 'submit',
466 'formnovalidate' => true,
467 'name' => $name,
468 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
469 'cssclass' => 'mw-htmlform-cloner-create-button',
470 'default' => $this->getMessage( $label )->text(),
471 'disabled' => $this->mParams['disabled'] ?? false,
472 ], $this->mParent );
473 }
474
475 public function getInputHTML( $values ) {
476 $html = '';
477
478 foreach ( (array)$values as $key => $value ) {
479 if ( $key === 'nonjs' ) {
480 continue;
481 }
482 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
483 $this->getInputHTMLForKey( $key, $value )
484 );
485 }
486
487 $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
488 $html = Html::rawElement( 'ul', [
489 'id' => "mw-htmlform-cloner-list-{$this->mID}",
490 'class' => 'mw-htmlform-cloner-ul',
491 'data-template' => $template,
492 'data-unique-id' => $this->uniqueId,
493 ], $html );
494
495 $field = $this->getCreateButtonHtml();
496 $html .= $field->getInputHTML( $field->getDefault() );
497
498 return $html;
499 }
500
508 protected function getInputOOUIForKey( $key, array $values ) {
509 $html = '';
510 $hidden = '';
511
512 $fields = $this->getFieldsForKey( $key );
513 foreach ( $fields as $fieldname => $field ) {
514 $v = array_key_exists( $fieldname, $values )
515 ? $values[$fieldname]
516 : $field->getDefault();
517
518 if ( $field instanceof HTMLHiddenField ) {
519 // HTMLHiddenField doesn't generate its own HTML
520 list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
521 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
522 } else {
523 $html .= $field->getOOUI( $v );
524 }
525 }
526
527 if ( !isset( $fields['delete'] ) ) {
528 $field = $this->getDeleteButtonHtml( $key );
529 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
530 $fieldHtml->setInfusable( true );
531
532 $html .= $fieldHtml;
533 }
534
535 $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
536
537 $html .= $hidden;
538
539 if ( !empty( $this->mParams['row-legend'] ) ) {
540 $legend = $this->msg( $this->mParams['row-legend'] )->text();
541 $html = Xml::fieldset( $legend, $html );
542 }
543
544 return $html;
545 }
546
547 public function getInputOOUI( $values ) {
548 $html = '';
549
550 foreach ( (array)$values as $key => $value ) {
551 if ( $key === 'nonjs' ) {
552 continue;
553 }
554 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
555 $this->getInputOOUIForKey( $key, $value )
556 );
557 }
558
559 $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
560 $html = Html::rawElement( 'ul', [
561 'id' => "mw-htmlform-cloner-list-{$this->mID}",
562 'class' => 'mw-htmlform-cloner-ul',
563 'data-template' => $template,
564 'data-unique-id' => $this->uniqueId,
565 ], $html );
566
567 $field = $this->getCreateButtonHtml();
568 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
569 $fieldHtml->setInfusable( true );
570
571 $html .= $fieldHtml;
572
573 return $html;
574 }
575}
Similar to FauxRequest, but only fakes URL parameters and method (POST or GET) and use the base reque...
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.
MediaWiki exception.