Localisation updates from https://translatewiki.net.
[mediawiki.git] / includes / htmlform / fields / HTMLFormFieldCloner.php
blob4f84a7911a488e7d4b1c48ecf89f826739fd3d52
1 <?php
3 namespace MediaWiki\HTMLForm\Field;
5 use InvalidArgumentException;
6 use MediaWiki\Html\Html;
7 use MediaWiki\HTMLForm\HTMLForm;
8 use MediaWiki\HTMLForm\HTMLFormField;
9 use MediaWiki\Parser\Sanitizer;
10 use MediaWiki\Request\DerivativeRequest;
11 use MediaWiki\Xml\Xml;
13 /**
14 * A container for HTMLFormFields that allows for multiple copies of the set of
15 * fields to be displayed to and entered by the user.
17 * Recognized parameters, besides the general ones, include:
18 * fields - HTMLFormField descriptors for the subfields this cloner manages.
19 * The format is just like for the HTMLForm. A field with key 'delete' is
20 * special: it must have type = submit and will serve to delete the group
21 * of fields.
22 * required - If specified, at least one group of fields must be submitted.
23 * format - HTMLForm display format to use when displaying the subfields:
24 * 'table', 'div', or 'raw'. This is ignored when using OOUI.
25 * row-legend - If non-empty, each group of subfields will be enclosed in a
26 * fieldset. The value is the name of a message key to use as the legend.
27 * create-button-message - Message to use as the text of the button to
28 * add an additional group of fields.
29 * delete-button-message - Message to use as the text of automatically-
30 * generated 'delete' button. Ignored if 'delete' is included in 'fields'.
32 * In the generated HTML, the subfields will be named along the lines of
33 * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index'
34 * may be a number or an arbitrary string, and may likely change when the page
35 * is resubmitted. Cloners may be nested, resulting in field names along the
36 * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and
37 * corresponding ids.
39 * Use of cloner may result in submissions of the page that are not submissions
40 * of the HTMLForm, when non-JavaScript clients use the create or remove buttons.
42 * The result is an array, with values being arrays mapping subfield names to
43 * their values. On non-HTMLForm-submission page loads, there may also be
44 * additional (string) keys present with other types of values.
46 * @since 1.23
47 * @stable to extend
49 class HTMLFormFieldCloner extends HTMLFormField {
50 /** @var int */
51 private static $counter = 0;
53 /**
54 * @var string String uniquely identifying this cloner instance and
55 * unlikely to exist otherwise in the generated HTML, while still being
56 * valid as part of an HTML id.
58 protected $uniqueId;
60 /** @var array<string, HTMLFormField[]> */
61 protected $mFields = [];
63 /**
64 * @stable to call
65 * @inheritDoc
67 public function __construct( $params ) {
68 $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
69 parent::__construct( $params );
71 if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
72 throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
75 // Make sure the delete button, if explicitly specified, is sensible
76 if ( isset( $this->mParams['fields']['delete'] ) ) {
77 $class = 'mw-htmlform-cloner-delete-button';
78 $info = $this->mParams['fields']['delete'] + [
79 'formnovalidate' => true,
80 'cssclass' => $class
82 unset( $info['name'], $info['class'] );
84 if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
85 throw new InvalidArgumentException(
86 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
90 if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
91 $info['cssclass'] .= " $class";
94 $this->mParams['fields']['delete'] = $info;
98 /**
99 * @param string $key Array key under which these fields should be named
100 * @return HTMLFormField[]
102 protected function getFieldsForKey( $key ) {
103 if ( !isset( $this->mFields[$key] ) ) {
104 $this->mFields[$key] = $this->createFieldsForKey( $key );
106 return $this->mFields[$key];
110 * Create the HTMLFormFields that go inside this element, using the
111 * specified key.
113 * @param string $key Array key under which these fields should be named
114 * @return HTMLFormField[]
116 protected function createFieldsForKey( $key ) {
117 $fields = [];
118 foreach ( $this->mParams['fields'] as $fieldname => $info ) {
119 $name = "{$this->mName}[$key][$fieldname]";
120 if ( isset( $info['name'] ) ) {
121 $info['name'] = "{$this->mName}[$key][{$info['name']}]";
122 } else {
123 $info['name'] = $name;
125 if ( isset( $info['id'] ) ) {
126 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
127 } else {
128 $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
130 // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
131 // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
132 if ( $this->mCondState ) {
133 foreach ( [ 'hide', 'disable' ] as $type ) {
134 if ( !isset( $this->mCondState[$type] ) ) {
135 continue;
137 $param = $type . '-if';
138 if ( isset( $info[$param] ) ) {
139 // Hide or disable child field if either its rules say so, or parent's rules say so.
140 $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
141 } else {
142 // Hide or disable child field if parent's rules say so.
143 $info[$param] = $this->mCondState[$type];
147 $cloner = $this;
148 $info['cloner'] = &$cloner;
149 $info['cloner-key'] = $key;
150 $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
151 $fields[$fieldname] = $field;
153 return $fields;
157 * Re-key the specified values array to match the names applied by
158 * createFieldsForKey().
160 * @param string $key Array key under which these fields should be named
161 * @param array $values Values array from the request
162 * @return array
164 protected function rekeyValuesArray( $key, $values ) {
165 $data = [];
166 foreach ( $values as $fieldname => $value ) {
167 $name = "{$this->mName}[$key][$fieldname]";
168 $data[$name] = $value;
170 return $data;
174 * @param string $name
175 * @return string[]
177 protected function parseFieldPath( $name ) {
178 $fieldKeys = [];
179 while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
180 array_unshift( $fieldKeys, $m[2] );
181 $name = $m[1];
183 array_unshift( $fieldKeys, $name );
184 return $fieldKeys;
188 * Find the nearest field to a field in this cloner matched the given name,
189 * walk through the chain of cloners.
191 * @param HTMLFormField $field
192 * @param string $find
193 * @return HTMLFormField|null
195 public function findNearestField( $field, $find ) {
196 $findPath = $this->parseFieldPath( $find );
197 // Access to fields as child or in other group is not allowed.
198 // Further support for a more complicated path may conduct here.
199 if ( count( $findPath ) > 1 ) {
200 return null;
202 if ( !isset( $this->mParams['fields'][$find] ) ) {
203 $cloner = $this->mParams['cloner'] ?? null;
204 if ( $cloner instanceof self ) {
205 return $cloner->findNearestField( $this, $find );
207 return null;
209 $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
210 return $fields[$find];
214 * @param HTMLFormField $field
215 * @return string[]
217 protected function getFieldPath( $field ) {
218 $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
219 $cloner = $this->mParams['cloner'] ?? null;
220 if ( $cloner instanceof self ) {
221 $path = array_merge( $cloner->getFieldPath( $this ), $path );
223 return $path;
227 * Extract field data for a given field that belongs to this cloner.
229 * @param HTMLFormField $field
230 * @param mixed[] $alldata
231 * @return mixed
233 public function extractFieldData( $field, $alldata ) {
234 foreach ( $this->getFieldPath( $field ) as $key ) {
235 $alldata = $alldata[$key];
237 return $alldata[$field->mParams['fieldname']];
240 protected function needsLabel() {
241 return false;
244 public function loadDataFromRequest( $request ) {
245 // It's possible that this might be posted with no fields. Detect that
246 // by looking for an edit token.
247 if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
248 return $this->getDefault();
251 $values = $request->getArray( $this->mName ) ?? [];
253 $ret = [];
254 foreach ( $values as $key => $value ) {
255 if ( $key === 'create' || isset( $value['delete'] ) ) {
256 $ret['nonjs'] = 1;
257 continue;
260 // Add back in $request->getValues() so things that look for e.g.
261 // wpEditToken don't fail.
262 $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
264 $fields = $this->getFieldsForKey( $key );
265 $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
266 $row = [];
267 foreach ( $fields as $fieldname => $field ) {
268 if ( $field->skipLoadData( $subrequest ) ) {
269 continue;
271 if ( !empty( $field->mParams['disabled'] ) ) {
272 $row[$fieldname] = $field->getDefault();
273 } else {
274 $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
277 $ret[] = $row;
280 if ( isset( $values['create'] ) ) {
281 // Non-JS client clicked the "create" button.
282 $fields = $this->getFieldsForKey( $this->uniqueId );
283 $row = [];
284 foreach ( $fields as $fieldname => $field ) {
285 if ( !empty( $field->mParams['nodata'] ) ) {
286 continue;
288 $row[$fieldname] = $field->getDefault();
290 $ret[] = $row;
293 return $ret;
296 public function getDefault() {
297 $ret = parent::getDefault();
299 // The default is one entry with all subfields at their defaults.
300 if ( $ret === null ) {
301 $fields = $this->getFieldsForKey( $this->uniqueId );
302 $row = [];
303 foreach ( $fields as $fieldname => $field ) {
304 if ( !empty( $field->mParams['nodata'] ) ) {
305 continue;
307 $row[$fieldname] = $field->getDefault();
309 $ret = [ $row ];
312 return $ret;
316 * @inheritDoc
317 * @phan-param array[] $values
319 public function cancelSubmit( $values, $alldata ) {
320 if ( isset( $values['nonjs'] ) ) {
321 return true;
324 foreach ( $values as $key => $value ) {
325 $fields = $this->getFieldsForKey( $key );
326 foreach ( $fields as $fieldname => $field ) {
327 if ( !array_key_exists( $fieldname, $value ) ) {
328 continue;
330 if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
331 return true;
336 return parent::cancelSubmit( $values, $alldata );
340 * @inheritDoc
341 * @phan-param array[] $values
343 public function validate( $values, $alldata ) {
344 if ( isset( $this->mParams['required'] )
345 && $this->mParams['required'] !== false
346 && !$values
348 return $this->msg( 'htmlform-cloner-required' );
351 if ( isset( $values['nonjs'] ) ) {
352 // The submission was a non-JS create/delete click, so fail
353 // validation in case cancelSubmit() somehow didn't already handle
354 // it.
355 return false;
358 foreach ( $values as $key => $value ) {
359 $fields = $this->getFieldsForKey( $key );
360 foreach ( $fields as $fieldname => $field ) {
361 if ( !array_key_exists( $fieldname, $value ) || $field->isHidden( $alldata ) ) {
362 continue;
364 $ok = $field->validate( $value[$fieldname], $alldata );
365 if ( $ok !== true ) {
366 return false;
371 return parent::validate( $values, $alldata );
375 * Get the input HTML for the specified key.
377 * @param string $key Array key under which the fields should be named
378 * @param array $values
379 * @return string
381 protected function getInputHTMLForKey( $key, array $values ) {
382 $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
384 // Conveniently, PHP method names are case-insensitive.
385 $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
387 $html = '';
388 $hidden = '';
389 $hasLabel = false;
391 $fields = $this->getFieldsForKey( $key );
392 foreach ( $fields as $fieldname => $field ) {
393 $v = array_key_exists( $fieldname, $values )
394 ? $values[$fieldname]
395 : $field->getDefault();
397 if ( $field instanceof HTMLHiddenField ) {
398 // HTMLHiddenField doesn't generate its own HTML
399 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
400 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
401 } else {
402 $html .= $field->$getFieldHtmlMethod( $v );
404 $labelValue = trim( $field->getLabel() );
405 if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
406 $hasLabel = true;
411 if ( !isset( $fields['delete'] ) ) {
412 $field = $this->getDeleteButtonHtml( $key );
414 if ( $displayFormat === 'table' ) {
415 $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
416 } else {
417 $html .= $field->getInputHTML( $field->getDefault() );
421 if ( $displayFormat !== 'raw' ) {
422 $classes = [ 'mw-htmlform-cloner-row' ];
424 if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
425 $classes[] = 'mw-htmlform-nolabel';
428 $attribs = [ 'class' => $classes ];
430 if ( $displayFormat === 'table' ) {
431 $html = Html::rawElement( 'table',
432 $attribs,
433 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
434 } else {
435 $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
439 $html .= $hidden;
441 if ( !empty( $this->mParams['row-legend'] ) ) {
442 $legend = $this->msg( $this->mParams['row-legend'] )->text();
443 $html = Xml::fieldset( $legend, $html );
446 return $html;
450 * @param string $key Array key indicating to which field the delete button belongs
451 * @return HTMLFormField
453 protected function getDeleteButtonHtml( $key ): HTMLFormField {
454 $name = "{$this->mName}[$key][delete]";
455 $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
456 $field = HTMLForm::loadInputFromParameters( $name, [
457 'type' => 'submit',
458 'formnovalidate' => true,
459 'name' => $name,
460 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
461 'cssclass' => 'mw-htmlform-cloner-delete-button',
462 'default' => $this->getMessage( $label )->text(),
463 'disabled' => $this->mParams['disabled'] ?? false,
464 ], $this->mParent );
465 return $field;
468 protected function getCreateButtonHtml(): HTMLFormField {
469 $name = "{$this->mName}[create]";
470 $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
471 return HTMLForm::loadInputFromParameters( $name, [
472 'type' => 'submit',
473 'formnovalidate' => true,
474 'name' => $name,
475 'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
476 'cssclass' => 'mw-htmlform-cloner-create-button',
477 'default' => $this->getMessage( $label )->text(),
478 'disabled' => $this->mParams['disabled'] ?? false,
479 ], $this->mParent );
482 public function getInputHTML( $values ) {
483 $html = '';
485 foreach ( (array)$values as $key => $value ) {
486 if ( $key === 'nonjs' ) {
487 continue;
489 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
490 $this->getInputHTMLForKey( $key, $value )
494 $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
495 $html = Html::rawElement( 'ul', [
496 'id' => "mw-htmlform-cloner-list-{$this->mID}",
497 'class' => 'mw-htmlform-cloner-ul',
498 'data-template' => $template,
499 'data-unique-id' => $this->uniqueId,
500 ], $html );
502 $field = $this->getCreateButtonHtml();
503 $html .= $field->getInputHTML( $field->getDefault() );
505 return $html;
509 * Get the input OOUI HTML for the specified key.
511 * @param string $key Array key under which the fields should be named
512 * @param array $values
513 * @return string
515 protected function getInputOOUIForKey( $key, array $values ) {
516 $html = '';
517 $hidden = '';
519 $fields = $this->getFieldsForKey( $key );
520 foreach ( $fields as $fieldname => $field ) {
521 $v = array_key_exists( $fieldname, $values )
522 ? $values[$fieldname]
523 : $field->getDefault();
525 if ( $field instanceof HTMLHiddenField ) {
526 // HTMLHiddenField doesn't generate its own HTML
527 [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
528 $hidden .= Html::hidden( $name, $value, $params ) . "\n";
529 } else {
530 $html .= $field->getOOUI( $v );
534 if ( !isset( $fields['delete'] ) ) {
535 $field = $this->getDeleteButtonHtml( $key );
536 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
537 $fieldHtml->setInfusable( true );
539 $html .= $fieldHtml;
542 $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
544 $html .= $hidden;
546 if ( !empty( $this->mParams['row-legend'] ) ) {
547 $legend = $this->msg( $this->mParams['row-legend'] )->text();
548 $html = Xml::fieldset( $legend, $html );
551 return $html;
554 public function getInputOOUI( $values ) {
555 $html = '';
557 foreach ( (array)$values as $key => $value ) {
558 if ( $key === 'nonjs' ) {
559 continue;
561 $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
562 $this->getInputOOUIForKey( $key, $value )
566 $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
567 $html = Html::rawElement( 'ul', [
568 'id' => "mw-htmlform-cloner-list-{$this->mID}",
569 'class' => 'mw-htmlform-cloner-ul',
570 'data-template' => $template,
571 'data-unique-id' => $this->uniqueId,
572 ], $html );
574 $field = $this->getCreateButtonHtml();
575 $fieldHtml = $field->getInputOOUI( $field->getDefault() );
576 $fieldHtml->setInfusable( true );
578 $html .= $fieldHtml;
580 return $html;
584 /** @deprecated class alias since 1.42 */
585 class_alias( HTMLFormFieldCloner::class, 'HTMLFormFieldCloner' );