MediaWiki  master
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  // @phan-suppress-next-line SecurityCheck-DoubleEscaped data-template contains html, but that is okay here
489  $html = Html::rawElement( 'ul', [
490  'id' => "mw-htmlform-cloner-list-{$this->mID}",
491  'class' => 'mw-htmlform-cloner-ul',
492  'data-template' => $template,
493  'data-unique-id' => $this->uniqueId,
494  ], $html );
495 
496  $field = $this->getCreateButtonHtml();
497  $html .= $field->getInputHTML( $field->getDefault() );
498 
499  return $html;
500  }
501 
509  protected function getInputOOUIForKey( $key, array $values ) {
510  $html = '';
511  $hidden = '';
512 
513  $fields = $this->getFieldsForKey( $key );
514  foreach ( $fields as $fieldname => $field ) {
515  $v = array_key_exists( $fieldname, $values )
516  ? $values[$fieldname]
517  : $field->getDefault();
518 
519  if ( $field instanceof HTMLHiddenField ) {
520  // HTMLHiddenField doesn't generate its own HTML
521  list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
522  $hidden .= Html::hidden( $name, $value, $params ) . "\n";
523  } else {
524  $html .= $field->getOOUI( $v );
525  }
526  }
527 
528  if ( !isset( $fields['delete'] ) ) {
529  $field = $this->getDeleteButtonHtml( $key );
530  $fieldHtml = $field->getInputOOUI( $field->getDefault() );
531  $fieldHtml->setInfusable( true );
532 
533  $html .= $fieldHtml;
534  }
535 
536  $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
537 
538  $html .= $hidden;
539 
540  if ( !empty( $this->mParams['row-legend'] ) ) {
541  $legend = $this->msg( $this->mParams['row-legend'] )->text();
542  $html = Xml::fieldset( $legend, $html );
543  }
544 
545  return $html;
546  }
547 
548  public function getInputOOUI( $values ) {
549  $html = '';
550 
551  foreach ( (array)$values as $key => $value ) {
552  if ( $key === 'nonjs' ) {
553  continue;
554  }
555  $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
556  $this->getInputOOUIForKey( $key, $value )
557  );
558  }
559 
560  $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
561  // @phan-suppress-next-line SecurityCheck-DoubleEscaped data-template contains html, but that is okay here
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 }
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.
static loadInputFromParameters( $fieldname, $descriptor, HTMLForm $parent=null)
Initialise a new Object for the field.
Definition: HTMLForm.php:547
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:31
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