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