MediaWiki  master
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.
static loadInputFromParameters( $fieldname, $descriptor, HTMLForm $parent=null)
Initialise a new Object for the field.
Definition: HTMLForm.php:561
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 ...
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:624