MediaWiki  master
HTMLFormFieldCloner.php
Go to the documentation of this file.
1 <?php
2 
39  private static $counter = 0;
40 
46  protected $uniqueId;
47 
48  public function __construct( $params ) {
49  $this->uniqueId = static::class . ++self::$counter . 'x';
50  parent::__construct( $params );
51 
52  if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
53  throw new MWException( 'HTMLFormFieldCloner called without any fields' );
54  }
55 
56  // Make sure the delete button, if explicitly specified, is sane
57  if ( isset( $this->mParams['fields']['delete'] ) ) {
58  $class = 'mw-htmlform-cloner-delete-button';
59  $info = $this->mParams['fields']['delete'] + [
60  'formnovalidate' => true,
61  'cssclass' => $class
62  ];
63  unset( $info['name'], $info['class'] );
64 
65  if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
66  throw new MWException(
67  'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
68  );
69  }
70 
71  if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
72  $info['cssclass'] .= " $class";
73  }
74 
75  $this->mParams['fields']['delete'] = $info;
76  }
77  }
78 
86  protected function createFieldsForKey( $key ) {
87  $fields = [];
88  foreach ( $this->mParams['fields'] as $fieldname => $info ) {
89  $name = "{$this->mName}[$key][$fieldname]";
90  if ( isset( $info['name'] ) ) {
91  $info['name'] = "{$this->mName}[$key][{$info['name']}]";
92  } else {
93  $info['name'] = $name;
94  }
95  if ( isset( $info['id'] ) ) {
96  $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
97  } else {
98  $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
99  }
100  // Copy the hide-if rules to "child" fields, so that the JavaScript code handling them
101  // (resources/src/mediawiki/htmlform/hide-if.js) doesn't have to handle nested fields.
102  if ( $this->mHideIf ) {
103  if ( isset( $info['hide-if'] ) ) {
104  // Hide child field if either its rules say it's hidden, or parent's rules say it's hidden
105  $info['hide-if'] = [ 'OR', $info['hide-if'], $this->mHideIf ];
106  } else {
107  // Hide child field if parent's rules say it's hidden
108  $info['hide-if'] = $this->mHideIf;
109  }
110  }
111  $field = HTMLForm::loadInputFromParameters( $name, $info, $this->mParent );
112  $fields[$fieldname] = $field;
113  }
114  return $fields;
115  }
116 
125  protected function rekeyValuesArray( $key, $values ) {
126  $data = [];
127  foreach ( $values as $fieldname => $value ) {
128  $name = "{$this->mName}[$key][$fieldname]";
129  $data[$name] = $value;
130  }
131  return $data;
132  }
133 
134  protected function needsLabel() {
135  return false;
136  }
137 
138  public function loadDataFromRequest( $request ) {
139  // It's possible that this might be posted with no fields. Detect that
140  // by looking for an edit token.
141  if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
142  return $this->getDefault();
143  }
144 
145  $values = $request->getArray( $this->mName );
146  if ( $values === null ) {
147  $values = [];
148  }
149 
150  $ret = [];
151  foreach ( $values as $key => $value ) {
152  if ( $key === 'create' || isset( $value['delete'] ) ) {
153  $ret['nonjs'] = 1;
154  continue;
155  }
156 
157  // Add back in $request->getValues() so things that look for e.g.
158  // wpEditToken don't fail.
159  $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
160 
161  $fields = $this->createFieldsForKey( $key );
162  $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
163  $row = [];
164  foreach ( $fields as $fieldname => $field ) {
165  if ( $field->skipLoadData( $subrequest ) ) {
166  continue;
167  } elseif ( !empty( $field->mParams['disabled'] ) ) {
168  $row[$fieldname] = $field->getDefault();
169  } else {
170  $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
171  }
172  }
173  $ret[] = $row;
174  }
175 
176  if ( isset( $values['create'] ) ) {
177  // Non-JS client clicked the "create" button.
178  $fields = $this->createFieldsForKey( $this->uniqueId );
179  $row = [];
180  foreach ( $fields as $fieldname => $field ) {
181  if ( !empty( $field->mParams['nodata'] ) ) {
182  continue;
183  } else {
184  $row[$fieldname] = $field->getDefault();
185  }
186  }
187  $ret[] = $row;
188  }
189 
190  return $ret;
191  }
192 
193  public function getDefault() {
194  $ret = parent::getDefault();
195 
196  // The default default is one entry with all subfields at their
197  // defaults.
198  if ( $ret === null ) {
199  $fields = $this->createFieldsForKey( $this->uniqueId );
200  $row = [];
201  foreach ( $fields as $fieldname => $field ) {
202  if ( !empty( $field->mParams['nodata'] ) ) {
203  continue;
204  } else {
205  $row[$fieldname] = $field->getDefault();
206  }
207  }
208  $ret = [ $row ];
209  }
210 
211  return $ret;
212  }
213 
214  public function cancelSubmit( $values, $alldata ) {
215  if ( isset( $values['nonjs'] ) ) {
216  return true;
217  }
218 
219  foreach ( $values as $key => $value ) {
220  $fields = $this->createFieldsForKey( $key );
221  foreach ( $fields as $fieldname => $field ) {
222  if ( !array_key_exists( $fieldname, $value ) ) {
223  continue;
224  }
225  if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
226  return true;
227  }
228  }
229  }
230 
231  return parent::cancelSubmit( $values, $alldata );
232  }
233 
234  public function validate( $values, $alldata ) {
235  if ( isset( $this->mParams['required'] )
236  && $this->mParams['required'] !== false
237  && !$values
238  ) {
239  return $this->msg( 'htmlform-cloner-required' );
240  }
241 
242  if ( isset( $values['nonjs'] ) ) {
243  // The submission was a non-JS create/delete click, so fail
244  // validation in case cancelSubmit() somehow didn't already handle
245  // it.
246  return false;
247  }
248 
249  foreach ( $values as $key => $value ) {
250  $fields = $this->createFieldsForKey( $key );
251  foreach ( $fields as $fieldname => $field ) {
252  if ( !array_key_exists( $fieldname, $value ) ) {
253  continue;
254  }
255  if ( $field->isHidden( $alldata ) ) {
256  continue;
257  }
258  $ok = $field->validate( $value[$fieldname], $alldata );
259  if ( $ok !== true ) {
260  return false;
261  }
262  }
263  }
264 
265  return parent::validate( $values, $alldata );
266  }
267 
275  protected function getInputHTMLForKey( $key, array $values ) {
276  $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
277 
278  // Conveniently, PHP method names are case-insensitive.
279  $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
280 
281  $html = '';
282  $hidden = '';
283  $hasLabel = false;
284 
285  $fields = $this->createFieldsForKey( $key );
286  foreach ( $fields as $fieldname => $field ) {
287  $v = array_key_exists( $fieldname, $values )
288  ? $values[$fieldname]
289  : $field->getDefault();
290 
291  if ( $field instanceof HTMLHiddenField ) {
292  // HTMLHiddenField doesn't generate its own HTML
293  list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
294  $hidden .= Html::hidden( $name, $value, $params ) . "\n";
295  } else {
296  $html .= $field->$getFieldHtmlMethod( $v );
297 
298  $labelValue = trim( $field->getLabel() );
299  if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
300  $hasLabel = true;
301  }
302  }
303  }
304 
305  if ( !isset( $fields['delete'] ) ) {
306  $field = $this->getDeleteButtonHtml( $key );
307 
308  if ( $displayFormat === 'table' ) {
309  $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
310  } else {
311  $html .= $field->getInputHTML( $field->getDefault() );
312  }
313  }
314 
315  if ( $displayFormat !== 'raw' ) {
316  $classes = [
317  'mw-htmlform-cloner-row',
318  ];
319 
320  if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
321  $classes[] = 'mw-htmlform-nolabel';
322  }
323 
324  $attribs = [
325  'class' => implode( ' ', $classes ),
326  ];
327 
328  if ( $displayFormat === 'table' ) {
329  $html = Html::rawElement( 'table',
330  $attribs,
331  Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
332  } else {
333  $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
334  }
335  }
336 
337  $html .= $hidden;
338 
339  if ( !empty( $this->mParams['row-legend'] ) ) {
340  $legend = $this->msg( $this->mParams['row-legend'] )->text();
341  $html = Xml::fieldset( $legend, $html );
342  }
343 
344  return $html;
345  }
346 
351  protected function getDeleteButtonHtml( $key ) : HTMLFormField {
352  $name = "{$this->mName}[$key][delete]";
353  $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
354  $field = HTMLForm::loadInputFromParameters( $name, [
355  'type' => 'submit',
356  'formnovalidate' => true,
357  'name' => $name,
358  'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
359  'cssclass' => 'mw-htmlform-cloner-delete-button',
360  'default' => $this->getMessage( $label )->text(),
361  ], $this->mParent );
362  return $field;
363  }
364 
365  protected function getCreateButtonHtml() : HTMLFormField {
366  $name = "{$this->mName}[create]";
367  $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
368  return HTMLForm::loadInputFromParameters( $name, [
369  'type' => 'submit',
370  'formnovalidate' => true,
371  'name' => $name,
372  'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
373  'cssclass' => 'mw-htmlform-cloner-create-button',
374  'default' => $this->getMessage( $label )->text(),
375  ], $this->mParent );
376  }
377 
378  public function getInputHTML( $values ) {
379  $html = '';
380 
381  foreach ( (array)$values as $key => $value ) {
382  if ( $key === 'nonjs' ) {
383  continue;
384  }
385  $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
386  $this->getInputHTMLForKey( $key, $value )
387  );
388  }
389 
390  $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
391  $html = Html::rawElement( 'ul', [
392  'id' => "mw-htmlform-cloner-list-{$this->mID}",
393  'class' => 'mw-htmlform-cloner-ul',
394  'data-template' => $template,
395  'data-unique-id' => $this->uniqueId,
396  ], $html );
397 
398  $field = $this->getCreateButtonHtml();
399  $html .= $field->getInputHTML( $field->getDefault() );
400 
401  return $html;
402  }
403 
411  protected function getInputOOUIForKey( $key, array $values ) {
412  $html = '';
413  $hidden = '';
414 
415  $fields = $this->createFieldsForKey( $key );
416  foreach ( $fields as $fieldname => $field ) {
417  $v = array_key_exists( $fieldname, $values )
418  ? $values[$fieldname]
419  : $field->getDefault();
420 
421  if ( $field instanceof HTMLHiddenField ) {
422  // HTMLHiddenField doesn't generate its own HTML
423  list( $name, $value, $params ) = $field->getHiddenFieldData( $v );
424  $hidden .= Html::hidden( $name, $value, $params ) . "\n";
425  } else {
426  $html .= $field->getOOUI( $v );
427  }
428  }
429 
430  if ( !isset( $fields['delete'] ) ) {
431  $field = $this->getDeleteButtonHtml( $key );
432  $fieldHtml = $field->getInputOOUI( $field->getDefault() );
433  $fieldHtml->setInfusable( true );
434 
435  $html .= $fieldHtml;
436  }
437 
438  $classes = [
439  'mw-htmlform-cloner-row',
440  ];
441 
442  $attribs = [
443  'class' => implode( ' ', $classes ),
444  ];
445 
446  $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
447 
448  $html .= $hidden;
449 
450  if ( !empty( $this->mParams['row-legend'] ) ) {
451  $legend = $this->msg( $this->mParams['row-legend'] )->text();
452  $html = Xml::fieldset( $legend, $html );
453  }
454 
455  return $html;
456  }
457 
458  public function getInputOOUI( $values ) {
459  $html = '';
460 
461  foreach ( (array)$values as $key => $value ) {
462  if ( $key === 'nonjs' ) {
463  continue;
464  }
465  $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
466  $this->getInputOOUIForKey( $key, $value )
467  );
468  }
469 
470  $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
471  $html = Html::rawElement( 'ul', [
472  'id' => "mw-htmlform-cloner-list-{$this->mID}",
473  'class' => 'mw-htmlform-cloner-ul',
474  'data-template' => $template,
475  'data-unique-id' => $this->uniqueId,
476  ], $html );
477 
478  $field = $this->getCreateButtonHtml();
479  $fieldHtml = $field->getInputOOUI( $field->getDefault() );
480  $fieldHtml->setInfusable( true );
481 
482  $html .= $fieldHtml;
483 
484  return $html;
485  }
486 }
static rawElement( $element, $attribs=[], $contents='')
Returns an HTML element in a string.
Definition: Html.php:209
Similar to FauxRequest, but only fakes URL parameters and method (POST or GET) and use the base reque...
cancelSubmit( $values, $alldata)
getInputOOUIForKey( $key, array $values)
Get the input OOUI HTML for the specified key.
static loadInputFromParameters( $fieldname, $descriptor, HTMLForm $parent=null)
Initialise a new Object for the field.
Definition: HTMLForm.php:512
getInputHTMLForKey( $key, array $values)
Get the input HTML for the specified key.
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:1295
static fieldset( $legend=false, $content=false, $attribs=[])
Shortcut for creating fieldsets.
Definition: Xml.php:609
A container for HTMLFormFields that allows for multiple copies of the set of fields to be displayed t...
createFieldsForKey( $key)
Create the HTMLFormFields that go inside this element, using the specified key.
validate( $values, $alldata)
rekeyValuesArray( $key, $values)
Re-key the specified values array to match the names applied by createFieldsForKey().
msg( $key,... $params)
Get a translated interface message.
string $uniqueId
String uniquely identifying this cloner instance and unlikely to exist otherwise in the generated HTM...
static hidden( $name, $value, array $attribs=[])
Convenience function to produce an input element with type=hidden.
Definition: Html.php:802
getMessage( $value)
Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a name + parameters array) into a Message.