Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 256
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTMLFormFieldCloner
0.00% covered (danger)
0.00%
0 / 255
0.00% covered (danger)
0.00%
0 / 19
8190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getFieldsForKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 createFieldsForKey
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
72
 rekeyValuesArray
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 parseFieldPath
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 findNearestField
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getFieldPath
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 extractFieldData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 needsLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadDataFromRequest
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
156
 getDefault
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 cancelSubmit
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 validate
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
110
 getInputHTMLForKey
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
210
 getDeleteButtonHtml
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getCreateButtonHtml
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getInputHTML
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 getInputOOUIForKey
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 getInputOOUI
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\HTMLForm\Field;
4
5use InvalidArgumentException;
6use MediaWiki\Html\Html;
7use MediaWiki\HTMLForm\HTMLForm;
8use MediaWiki\HTMLForm\HTMLFormField;
9use MediaWiki\Parser\Sanitizer;
10use MediaWiki\Request\DerivativeRequest;
11use MediaWiki\Xml\Xml;
12
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.
16 *
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'.
31 *
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.
38 *
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.
41 *
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.
45 *
46 * @since 1.23
47 * @stable to extend
48 */
49class HTMLFormFieldCloner extends HTMLFormField {
50    private static $counter = 0;
51
52    /**
53     * @var string String uniquely identifying this cloner instance and
54     * unlikely to exist otherwise in the generated HTML, while still being
55     * valid as part of an HTML id.
56     */
57    protected $uniqueId;
58
59    /** @var array<string, HTMLFormField[]> */
60    protected $mFields = [];
61
62    /**
63     * @stable to call
64     * @inheritDoc
65     */
66    public function __construct( $params ) {
67        $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
68        parent::__construct( $params );
69
70        if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
71            throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
72        }
73
74        // Make sure the delete button, if explicitly specified, is sensible
75        if ( isset( $this->mParams['fields']['delete'] ) ) {
76            $class = 'mw-htmlform-cloner-delete-button';
77            $info = $this->mParams['fields']['delete'] + [
78                'formnovalidate' => true,
79                'cssclass' => $class
80            ];
81            unset( $info['name'], $info['class'] );
82
83            if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
84                throw new InvalidArgumentException(
85                    'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
86                );
87            }
88
89            if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
90                $info['cssclass'] .= " $class";
91            }
92
93            $this->mParams['fields']['delete'] = $info;
94        }
95    }
96
97    /**
98     * @param string $key Array key under which these fields should be named
99     * @return HTMLFormField[]
100     */
101    protected function getFieldsForKey( $key ) {
102        if ( !isset( $this->mFields[$key] ) ) {
103            $this->mFields[$key] = $this->createFieldsForKey( $key );
104        }
105        return $this->mFields[$key];
106    }
107
108    /**
109     * Create the HTMLFormFields that go inside this element, using the
110     * specified key.
111     *
112     * @param string $key Array key under which these fields should be named
113     * @return HTMLFormField[]
114     */
115    protected function createFieldsForKey( $key ) {
116        $fields = [];
117        foreach ( $this->mParams['fields'] as $fieldname => $info ) {
118            $name = "{$this->mName}[$key][$fieldname]";
119            if ( isset( $info['name'] ) ) {
120                $info['name'] = "{$this->mName}[$key][{$info['name']}]";
121            } else {
122                $info['name'] = $name;
123            }
124            if ( isset( $info['id'] ) ) {
125                $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--{$info['id']}" );
126            } else {
127                $info['id'] = Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--$fieldname" );
128            }
129            // Copy the hide-if and disable-if rules to "child" fields, so that the JavaScript code handling them
130            // (resources/src/mediawiki.htmlform/cond-state.js) doesn't have to handle nested fields.
131            if ( $this->mCondState ) {
132                foreach ( [ 'hide', 'disable' ] as $type ) {
133                    if ( !isset( $this->mCondState[$type] ) ) {
134                        continue;
135                    }
136                    $param = $type . '-if';
137                    if ( isset( $info[$param] ) ) {
138                        // Hide or disable child field if either its rules say so, or parent's rules say so.
139                        $info[$param] = [ 'OR', $info[$param], $this->mCondState[$type] ];
140                    } else {
141                        // Hide or disable child field if parent's rules say so.
142                        $info[$param] = $this->mCondState[$type];
143                    }
144                }
145            }
146            $cloner = $this;
147            $info['cloner'] = &$cloner;
148            $info['cloner-key'] = $key;
149            $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
150            $fields[$fieldname] = $field;
151        }
152        return $fields;
153    }
154
155    /**
156     * Re-key the specified values array to match the names applied by
157     * createFieldsForKey().
158     *
159     * @param string $key Array key under which these fields should be named
160     * @param array $values Values array from the request
161     * @return array
162     */
163    protected function rekeyValuesArray( $key, $values ) {
164        $data = [];
165        foreach ( $values as $fieldname => $value ) {
166            $name = "{$this->mName}[$key][$fieldname]";
167            $data[$name] = $value;
168        }
169        return $data;
170    }
171
172    /**
173     * @param string $name
174     * @return string[]
175     */
176    protected function parseFieldPath( $name ) {
177        $fieldKeys = [];
178        while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
179            array_unshift( $fieldKeys, $m[2] );
180            $name = $m[1];
181        }
182        array_unshift( $fieldKeys, $name );
183        return $fieldKeys;
184    }
185
186    /**
187     * Find the nearest field to a field in this cloner matched the given name,
188     * walk through the chain of cloners.
189     *
190     * @param HTMLFormField $field
191     * @param string $find
192     * @return HTMLFormField|null
193     */
194    public function findNearestField( $field, $find ) {
195        $findPath = $this->parseFieldPath( $find );
196        // Access to fields as child or in other group is not allowed.
197        // Further support for a more complicated path may conduct here.
198        if ( count( $findPath ) > 1 ) {
199            return null;
200        }
201        if ( !isset( $this->mParams['fields'][$find] ) ) {
202            $cloner = $this->mParams['cloner'] ?? null;
203            if ( $cloner instanceof self ) {
204                return $cloner->findNearestField( $this, $find );
205            }
206            return null;
207        }
208        $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
209        return $fields[$find];
210    }
211
212    /**
213     * @param HTMLFormField $field
214     * @return string[]
215     */
216    protected function getFieldPath( $field ) {
217        $path = [ $this->mParams['fieldname'], $field->mParams['cloner-key'] ];
218        $cloner = $this->mParams['cloner'] ?? null;
219        if ( $cloner instanceof self ) {
220            $path = array_merge( $cloner->getFieldPath( $this ), $path );
221        }
222        return $path;
223    }
224
225    /**
226     * Extract field data for a given field that belongs to this cloner.
227     *
228     * @param HTMLFormField $field
229     * @param mixed[] $alldata
230     * @return mixed
231     */
232    public function extractFieldData( $field, $alldata ) {
233        foreach ( $this->getFieldPath( $field ) as $key ) {
234            $alldata = $alldata[$key];
235        }
236        return $alldata[$field->mParams['fieldname']];
237    }
238
239    protected function needsLabel() {
240        return false;
241    }
242
243    public function loadDataFromRequest( $request ) {
244        // It's possible that this might be posted with no fields. Detect that
245        // by looking for an edit token.
246        if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) {
247            return $this->getDefault();
248        }
249
250        $values = $request->getArray( $this->mName ) ?? [];
251
252        $ret = [];
253        foreach ( $values as $key => $value ) {
254            if ( $key === 'create' || isset( $value['delete'] ) ) {
255                $ret['nonjs'] = 1;
256                continue;
257            }
258
259            // Add back in $request->getValues() so things that look for e.g.
260            // wpEditToken don't fail.
261            $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues();
262
263            $fields = $this->getFieldsForKey( $key );
264            $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() );
265            $row = [];
266            foreach ( $fields as $fieldname => $field ) {
267                if ( $field->skipLoadData( $subrequest ) ) {
268                    continue;
269                }
270                if ( !empty( $field->mParams['disabled'] ) ) {
271                    $row[$fieldname] = $field->getDefault();
272                } else {
273                    $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
274                }
275            }
276            $ret[] = $row;
277        }
278
279        if ( isset( $values['create'] ) ) {
280            // Non-JS client clicked the "create" button.
281            $fields = $this->getFieldsForKey( $this->uniqueId );
282            $row = [];
283            foreach ( $fields as $fieldname => $field ) {
284                if ( !empty( $field->mParams['nodata'] ) ) {
285                    continue;
286                }
287                $row[$fieldname] = $field->getDefault();
288            }
289            $ret[] = $row;
290        }
291
292        return $ret;
293    }
294
295    public function getDefault() {
296        $ret = parent::getDefault();
297
298        // The default is one entry with all subfields at their defaults.
299        if ( $ret === null ) {
300            $fields = $this->getFieldsForKey( $this->uniqueId );
301            $row = [];
302            foreach ( $fields as $fieldname => $field ) {
303                if ( !empty( $field->mParams['nodata'] ) ) {
304                    continue;
305                }
306                $row[$fieldname] = $field->getDefault();
307            }
308            $ret = [ $row ];
309        }
310
311        return $ret;
312    }
313
314    /**
315     * @inheritDoc
316     * @phan-param array[] $values
317     */
318    public function cancelSubmit( $values, $alldata ) {
319        if ( isset( $values['nonjs'] ) ) {
320            return true;
321        }
322
323        foreach ( $values as $key => $value ) {
324            $fields = $this->getFieldsForKey( $key );
325            foreach ( $fields as $fieldname => $field ) {
326                if ( !array_key_exists( $fieldname, $value ) ) {
327                    continue;
328                }
329                if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
330                    return true;
331                }
332            }
333        }
334
335        return parent::cancelSubmit( $values, $alldata );
336    }
337
338    /**
339     * @inheritDoc
340     * @phan-param array[] $values
341     */
342    public function validate( $values, $alldata ) {
343        if ( isset( $this->mParams['required'] )
344            && $this->mParams['required'] !== false
345            && !$values
346        ) {
347            return $this->msg( 'htmlform-cloner-required' );
348        }
349
350        if ( isset( $values['nonjs'] ) ) {
351            // The submission was a non-JS create/delete click, so fail
352            // validation in case cancelSubmit() somehow didn't already handle
353            // it.
354            return false;
355        }
356
357        foreach ( $values as $key => $value ) {
358            $fields = $this->getFieldsForKey( $key );
359            foreach ( $fields as $fieldname => $field ) {
360                if ( !array_key_exists( $fieldname, $value ) || $field->isHidden( $alldata ) ) {
361                    continue;
362                }
363                $ok = $field->validate( $value[$fieldname], $alldata );
364                if ( $ok !== true ) {
365                    return false;
366                }
367            }
368        }
369
370        return parent::validate( $values, $alldata );
371    }
372
373    /**
374     * Get the input HTML for the specified key.
375     *
376     * @param string $key Array key under which the fields should be named
377     * @param array $values
378     * @return string
379     */
380    protected function getInputHTMLForKey( $key, array $values ) {
381        $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
382
383        // Conveniently, PHP method names are case-insensitive.
384        $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
385
386        $html = '';
387        $hidden = '';
388        $hasLabel = false;
389
390        $fields = $this->getFieldsForKey( $key );
391        foreach ( $fields as $fieldname => $field ) {
392            $v = array_key_exists( $fieldname, $values )
393                ? $values[$fieldname]
394                : $field->getDefault();
395
396            if ( $field instanceof HTMLHiddenField ) {
397                // HTMLHiddenField doesn't generate its own HTML
398                [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
399                $hidden .= Html::hidden( $name, $value, $params ) . "\n";
400            } else {
401                $html .= $field->$getFieldHtmlMethod( $v );
402
403                $labelValue = trim( $field->getLabel() );
404                if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
405                    $hasLabel = true;
406                }
407            }
408        }
409
410        if ( !isset( $fields['delete'] ) ) {
411            $field = $this->getDeleteButtonHtml( $key );
412
413            if ( $displayFormat === 'table' ) {
414                $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
415            } else {
416                $html .= $field->getInputHTML( $field->getDefault() );
417            }
418        }
419
420        if ( $displayFormat !== 'raw' ) {
421            $classes = [ 'mw-htmlform-cloner-row' ];
422
423            if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
424                $classes[] = 'mw-htmlform-nolabel';
425            }
426
427            $attribs = [ 'class' => $classes ];
428
429            if ( $displayFormat === 'table' ) {
430                $html = Html::rawElement( 'table',
431                    $attribs,
432                    Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n";
433            } else {
434                $html = Html::rawElement( 'div', $attribs, "\n$html\n" );
435            }
436        }
437
438        $html .= $hidden;
439
440        if ( !empty( $this->mParams['row-legend'] ) ) {
441            $legend = $this->msg( $this->mParams['row-legend'] )->text();
442            $html = Xml::fieldset( $legend, $html );
443        }
444
445        return $html;
446    }
447
448    /**
449     * @param string $key Array key indicating to which field the delete button belongs
450     * @return HTMLFormField
451     */
452    protected function getDeleteButtonHtml( $key ): HTMLFormField {
453        $name = "{$this->mName}[$key][delete]";
454        $label = $this->mParams['delete-button-message'] ?? 'htmlform-cloner-delete';
455        $field = HTMLForm::loadInputFromParameters( $name, [
456            'type' => 'submit',
457            'formnovalidate' => true,
458            'name' => $name,
459            'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--$key--delete" ),
460            'cssclass' => 'mw-htmlform-cloner-delete-button',
461            'default' => $this->getMessage( $label )->text(),
462            'disabled' => $this->mParams['disabled'] ?? false,
463        ], $this->mParent );
464        return $field;
465    }
466
467    protected function getCreateButtonHtml(): HTMLFormField {
468        $name = "{$this->mName}[create]";
469        $label = $this->mParams['create-button-message'] ?? 'htmlform-cloner-create';
470        return HTMLForm::loadInputFromParameters( $name, [
471            'type' => 'submit',
472            'formnovalidate' => true,
473            'name' => $name,
474            'id' => Sanitizer::escapeIdForAttribute( "{$this->mID}--create" ),
475            'cssclass' => 'mw-htmlform-cloner-create-button',
476            'default' => $this->getMessage( $label )->text(),
477            'disabled' => $this->mParams['disabled'] ?? false,
478        ], $this->mParent );
479    }
480
481    public function getInputHTML( $values ) {
482        $html = '';
483
484        foreach ( (array)$values as $key => $value ) {
485            if ( $key === 'nonjs' ) {
486                continue;
487            }
488            $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
489                $this->getInputHTMLForKey( $key, $value )
490            );
491        }
492
493        $template = $this->getInputHTMLForKey( $this->uniqueId, [] );
494        $html = Html::rawElement( 'ul', [
495            'id' => "mw-htmlform-cloner-list-{$this->mID}",
496            'class' => 'mw-htmlform-cloner-ul',
497            'data-template' => $template,
498            'data-unique-id' => $this->uniqueId,
499        ], $html );
500
501        $field = $this->getCreateButtonHtml();
502        $html .= $field->getInputHTML( $field->getDefault() );
503
504        return $html;
505    }
506
507    /**
508     * Get the input OOUI HTML for the specified key.
509     *
510     * @param string $key Array key under which the fields should be named
511     * @param array $values
512     * @return string
513     */
514    protected function getInputOOUIForKey( $key, array $values ) {
515        $html = '';
516        $hidden = '';
517
518        $fields = $this->getFieldsForKey( $key );
519        foreach ( $fields as $fieldname => $field ) {
520            $v = array_key_exists( $fieldname, $values )
521                ? $values[$fieldname]
522                : $field->getDefault();
523
524            if ( $field instanceof HTMLHiddenField ) {
525                // HTMLHiddenField doesn't generate its own HTML
526                [ $name, $value, $params ] = $field->getHiddenFieldData( $v );
527                $hidden .= Html::hidden( $name, $value, $params ) . "\n";
528            } else {
529                $html .= $field->getOOUI( $v );
530            }
531        }
532
533        if ( !isset( $fields['delete'] ) ) {
534            $field = $this->getDeleteButtonHtml( $key );
535            $fieldHtml = $field->getInputOOUI( $field->getDefault() );
536            $fieldHtml->setInfusable( true );
537
538            $html .= $fieldHtml;
539        }
540
541        $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
542
543        $html .= $hidden;
544
545        if ( !empty( $this->mParams['row-legend'] ) ) {
546            $legend = $this->msg( $this->mParams['row-legend'] )->text();
547            $html = Xml::fieldset( $legend, $html );
548        }
549
550        return $html;
551    }
552
553    public function getInputOOUI( $values ) {
554        $html = '';
555
556        foreach ( (array)$values as $key => $value ) {
557            if ( $key === 'nonjs' ) {
558                continue;
559            }
560            $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
561                $this->getInputOOUIForKey( $key, $value )
562            );
563        }
564
565        $template = $this->getInputOOUIForKey( $this->uniqueId, [] );
566        $html = Html::rawElement( 'ul', [
567            'id' => "mw-htmlform-cloner-list-{$this->mID}",
568            'class' => 'mw-htmlform-cloner-ul',
569            'data-template' => $template,
570            'data-unique-id' => $this->uniqueId,
571        ], $html );
572
573        $field = $this->getCreateButtonHtml();
574        $fieldHtml = $field->getInputOOUI( $field->getDefault() );
575        $fieldHtml->setInfusable( true );
576
577        $html .= $fieldHtml;
578
579        return $html;
580    }
581}
582
583/** @deprecated class alias since 1.42 */
584class_alias( HTMLFormFieldCloner::class, 'HTMLFormFieldCloner' );