MediaWiki  master
HTMLFormFieldCloner.php
Go to the documentation of this file.
1 <?php
2 
5 
43  private static $counter = 0;
44 
50  protected $uniqueId;
51 
52  /* @var HTMLFormField[] */
53  protected $mFields = [];
54 
59  public function __construct( $params ) {
60  $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
61  parent::__construct( $params );
62 
63  if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
64  throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
65  }
66 
67  // Make sure the delete button, if explicitly specified, is sensible
68  if ( isset( $this->mParams['fields']['delete'] ) ) {
69  $class = 'mw-htmlform-cloner-delete-button';
70  $info = $this->mParams['fields']['delete'] + [
71  'formnovalidate' => true,
72  'cssclass' => $class
73  ];
74  unset( $info['name'], $info['class'] );
75 
76  if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
77  throw new InvalidArgumentException(
78  'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
79  );
80  }
81 
82  if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
83  $info['cssclass'] .= " $class";
84  }
85 
86  $this->mParams['fields']['delete'] = $info;
87  }
88  }
89 
94  protected function getFieldsForKey( $key ) {
95  if ( !isset( $this->mFields[$key] ) ) {
96  $this->mFields[$key] = $this->createFieldsForKey( $key );
97  }
98  return $this->mFields[$key];
99  }
100 
108  protected function createFieldsForKey( $key ) {
109  $fields = [];
110  foreach ( $this->mParams['fields'] as $fieldname => $info ) {
111  $name = "{$this->mName}[$key][$fieldname]";
112  if ( isset( $info['name'] ) ) {
113  $info['name'] = "{$this->mName}[$key][{$info['name']}]";
114  } else {
115  $info['name'] = $name;
116  }
117  if ( isset( $info['id'] ) ) {
118  $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
119  } else {
120  $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
121  }
122  // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
123  // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
124  if ( $this->mCondState ) {
125  foreach ( [ 'hide', 'disable' ] as $type ) {
126  if ( !isset( $this->mCondState[$type] ) ) {
127  continue;
128  }
129  $param = $type . '-if';
130  if ( isset( $info[$param] ) ) {
131  // Hide or disable child field if either its rules say so, or parent's rules say so.
132  $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
133  } else {
134  // Hide or disable child field if parent's rules say so.
135  $info[$param] = $this->mCondState[$type];
136  }
137  }
138  }
139  $cloner = $this;
140  $info['cloner'] = &$cloner;
141  $info['cloner-key'] = $key;
142  $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
143  $fields[$fieldname] = $field;
144  }
145  return $fields;
146  }
147 
156  protected function rekeyValuesArray( $key, $values ) {
157  $data = [];
158  foreach ( $values as $fieldname => $value ) {
159  $name = "{$this->mName}[$key][$fieldname]";
160  $data[$name] = $value;
161  }
162  return $data;
163  }
164 
169  protected function parseFieldPath( $name ) {
170  $fieldKeys = [];
171  while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
172  array_unshift( $fieldKeys, $m[2] );
173  $name = $m[1];
174  }
175  array_unshift( $fieldKeys, $name );
176  return $fieldKeys;
177  }
178 
187  public function findNearestField( $field, $find ) {
188  $findPath = $this->parseFieldPath( $find );
189  // Access to fields as child or in other group is not allowed.
190  // Further support for a more complicated path may conduct here.
191  if ( count( $findPath ) > 1 ) {
192  return null;
193  }
194  if ( !isset( $this->mParams['fields'][$find] ) ) {
195  if ( isset( $this->mParams['cloner'] ) ) {
196  return $this->mParams['cloner']->findNearestField( $this, $find );
197  }
198  return null;
199  }
200  $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
201  return $fields[$find];
202  }
203 
208  protected function getFieldPath( $field ) {
209  $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
210  if ( isset( $this->mParams['cloner'] ) ) {
211  $path = array_merge( $this->mParams['cloner']->getFieldPath( $this ), $path );
212  }
213  return $path;
214  }
215 
223  public function extractFieldData( $field, $alldata ) {
224  foreach ( $this->getFieldPath( $field ) as $key ) {
225  $alldata = $alldata[$key];
226  }
227  return $alldata[$field->mParams['fieldname']];
228  }
229 
230  protected function needsLabel() {
231  return false;
232  }
233 
234  public function loadDataFromRequest( $request ) {
235  // It's possible that this might be posted with no fields. Detect that
236  // by looking for an edit token.
237  if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
238  return $this->getDefault();
239  }
240 
241  $values = $request->getArray( $this->mName ) ?? [];
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  [ $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  [ $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 }
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:555
This class is a collection of static functions that serve two purposes:
Definition: Html.php:55
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:626