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    /** @var int */
51    private static $counter = 0;
52
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.
57     */
58    protected $uniqueId;
59
60    /** @var array<string, HTMLFormField[]> */
61    protected $mFields = [];
62
63    /**
64     * @stable to call
65     * @inheritDoc
66     */
67    public function __construct( $params ) {
68        $this->uniqueId = $this->getClassName() . ++self::$counter . 'x';
69        parent::__construct( $params );
70
71        if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) {
72            throw new InvalidArgumentException( 'HTMLFormFieldCloner called without any fields' );
73        }
74
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
81            ];
82            unset( $info['name'], $info['class'] );
83
84            if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) {
85                throw new InvalidArgumentException(
86                    'HTMLFormFieldCloner delete field, if specified, must be of type "submit"'
87                );
88            }
89
90            if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) {
91                $info['cssclass'] .= " $class";
92            }
93
94            $this->mParams['fields']['delete'] = $info;
95        }
96    }
97
98    /**
99     * @param string $key Array key under which these fields should be named
100     * @return HTMLFormField[]
101     */
102    protected function getFieldsForKey( $key ) {
103        if ( !isset( $this->mFields[$key] ) ) {
104            $this->mFields[$key] = $this->createFieldsForKey( $key );
105        }
106        return $this->mFields[$key];
107    }
108
109    /**
110     * Create the HTMLFormFields that go inside this element, using the
111     * specified key.
112     *
113     * @param string $key Array key under which these fields should be named
114     * @return HTMLFormField[]
115     */
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;
124            }
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" );
129            }
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;
136                    }
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];
144                    }
145                }
146            }
147            $cloner = $this;
148            $info['cloner'] = &$cloner;
149            $info['cloner-key'] = $key;
150            $field = HTMLForm::loadInputFromParameters( $fieldname, $info, $this->mParent );
151            $fields[$fieldname] = $field;
152        }
153        return $fields;
154    }
155
156    /**
157     * Re-key the specified values array to match the names applied by
158     * createFieldsForKey().
159     *
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
163     */
164    protected function rekeyValuesArray( $key, $values ) {
165        $data = [];
166        foreach ( $values as $fieldname => $value ) {
167            $name = "{$this->mName}[$key][$fieldname]";
168            $data[$name] = $value;
169        }
170        return $data;
171    }
172
173    /**
174     * @param string $name
175     * @return string[]
176     */
177    protected function parseFieldPath( $name ) {
178        $fieldKeys = [];
179        while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $name, $m ) ) {
180            array_unshift( $fieldKeys, $m[2] );
181            $name = $m[1];
182        }
183        array_unshift( $fieldKeys, $name );
184        return $fieldKeys;
185    }
186
187    /**
188     * Find the nearest field to a field in this cloner matched the given name,
189     * walk through the chain of cloners.
190     *
191     * @param HTMLFormField $field
192     * @param string $find
193     * @return HTMLFormField|null
194     */
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;
201        }
202        if ( !isset( $this->mParams['fields'][$find] ) ) {
203            $cloner = $this->mParams['cloner'] ?? null;
204            if ( $cloner instanceof self ) {
205                return $cloner->findNearestField( $this, $find );
206            }
207            return null;
208        }
209        $fields = $this->getFieldsForKey( $field->mParams['cloner-key'] );
210        return $fields[$find];
211    }
212
213    /**
214     * @param HTMLFormField $field
215     * @return string[]
216     */
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 );
222        }
223        return $path;
224    }
225
226    /**
227     * Extract field data for a given field that belongs to this cloner.
228     *
229     * @param HTMLFormField $field
230     * @param mixed[] $alldata
231     * @return mixed
232     */
233    public function extractFieldData( $field, $alldata ) {
234        foreach ( $this->getFieldPath( $field ) as $key ) {
235            $alldata = $alldata[$key];
236        }
237        return $alldata[$field->mParams['fieldname']];
238    }
239
240    protected function needsLabel() {
241        return false;
242    }
243
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();
249        }
250
251        $values = $request->getArray( $this->mName ) ?? [];
252
253        $ret = [];
254        foreach ( $values as $key => $value ) {
255            if ( $key === 'create' || isset( $value['delete'] ) ) {
256                $ret['nonjs'] = 1;
257                continue;
258            }
259
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();
263
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;
270                }
271                if ( !empty( $field->mParams['disabled'] ) ) {
272                    $row[$fieldname] = $field->getDefault();
273                } else {
274                    $row[$fieldname] = $field->loadDataFromRequest( $subrequest );
275                }
276            }
277            $ret[] = $row;
278        }
279
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;
287                }
288                $row[$fieldname] = $field->getDefault();
289            }
290            $ret[] = $row;
291        }
292
293        return $ret;
294    }
295
296    public function getDefault() {
297        $ret = parent::getDefault();
298
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;
306                }
307                $row[$fieldname] = $field->getDefault();
308            }
309            $ret = [ $row ];
310        }
311
312        return $ret;
313    }
314
315    /**
316     * @inheritDoc
317     * @phan-param array[] $values
318     */
319    public function cancelSubmit( $values, $alldata ) {
320        if ( isset( $values['nonjs'] ) ) {
321            return true;
322        }
323
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;
329                }
330                if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) {
331                    return true;
332                }
333            }
334        }
335
336        return parent::cancelSubmit( $values, $alldata );
337    }
338
339    /**
340     * @inheritDoc
341     * @phan-param array[] $values
342     */
343    public function validate( $values, $alldata ) {
344        if ( isset( $this->mParams['required'] )
345            && $this->mParams['required'] !== false
346            && !$values
347        ) {
348            return $this->msg( 'htmlform-cloner-required' );
349        }
350
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;
356        }
357
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;
363                }
364                $ok = $field->validate( $value[$fieldname], $alldata );
365                if ( $ok !== true ) {
366                    return false;
367                }
368            }
369        }
370
371        return parent::validate( $values, $alldata );
372    }
373
374    /**
375     * Get the input HTML for the specified key.
376     *
377     * @param string $key Array key under which the fields should be named
378     * @param array $values
379     * @return string
380     */
381    protected function getInputHTMLForKey( $key, array $values ) {
382        $displayFormat = $this->mParams['format'] ?? $this->mParent->getDisplayFormat();
383
384        // Conveniently, PHP method names are case-insensitive.
385        $getFieldHtmlMethod = $displayFormat == 'table' ? 'getTableRow' : ( 'get' . $displayFormat );
386
387        $html = '';
388        $hidden = '';
389        $hasLabel = false;
390
391        $fields = $this->getFieldsForKey( $key );
392        foreach ( $fields as $fieldname => $field ) {
393            $v = array_key_exists( $fieldname, $values )
394                ? $values[$fieldname]
395                : $field->getDefault();
396
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 );
403
404                $labelValue = trim( $field->getLabel() );
405                if ( $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' && $labelValue !== '' ) {
406                    $hasLabel = true;
407                }
408            }
409        }
410
411        if ( !isset( $fields['delete'] ) ) {
412            $field = $this->getDeleteButtonHtml( $key );
413
414            if ( $displayFormat === 'table' ) {
415                $html .= $field->$getFieldHtmlMethod( $field->getDefault() );
416            } else {
417                $html .= $field->getInputHTML( $field->getDefault() );
418            }
419        }
420
421        if ( $displayFormat !== 'raw' ) {
422            $classes = [ 'mw-htmlform-cloner-row' ];
423
424            if ( !$hasLabel ) { // Avoid strange spacing when no labels exist
425                $classes[] = 'mw-htmlform-nolabel';
426            }
427
428            $attribs = [ 'class' => $classes ];
429
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" );
436            }
437        }
438
439        $html .= $hidden;
440
441        if ( !empty( $this->mParams['row-legend'] ) ) {
442            $legend = $this->msg( $this->mParams['row-legend'] )->text();
443            $html = Xml::fieldset( $legend, $html );
444        }
445
446        return $html;
447    }
448
449    /**
450     * @param string $key Array key indicating to which field the delete button belongs
451     * @return HTMLFormField
452     */
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;
466    }
467
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 );
480    }
481
482    public function getInputHTML( $values ) {
483        $html = '';
484
485        foreach ( (array)$values as $key => $value ) {
486            if ( $key === 'nonjs' ) {
487                continue;
488            }
489            $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
490                $this->getInputHTMLForKey( $key, $value )
491            );
492        }
493
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 );
501
502        $field = $this->getCreateButtonHtml();
503        $html .= $field->getInputHTML( $field->getDefault() );
504
505        return $html;
506    }
507
508    /**
509     * Get the input OOUI HTML for the specified key.
510     *
511     * @param string $key Array key under which the fields should be named
512     * @param array $values
513     * @return string
514     */
515    protected function getInputOOUIForKey( $key, array $values ) {
516        $html = '';
517        $hidden = '';
518
519        $fields = $this->getFieldsForKey( $key );
520        foreach ( $fields as $fieldname => $field ) {
521            $v = array_key_exists( $fieldname, $values )
522                ? $values[$fieldname]
523                : $field->getDefault();
524
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 );
531            }
532        }
533
534        if ( !isset( $fields['delete'] ) ) {
535            $field = $this->getDeleteButtonHtml( $key );
536            $fieldHtml = $field->getInputOOUI( $field->getDefault() );
537            $fieldHtml->setInfusable( true );
538
539            $html .= $fieldHtml;
540        }
541
542        $html = Html::rawElement( 'div', [ 'class' => 'mw-htmlform-cloner-row' ], "\n$html\n" );
543
544        $html .= $hidden;
545
546        if ( !empty( $this->mParams['row-legend'] ) ) {
547            $legend = $this->msg( $this->mParams['row-legend'] )->text();
548            $html = Xml::fieldset( $legend, $html );
549        }
550
551        return $html;
552    }
553
554    public function getInputOOUI( $values ) {
555        $html = '';
556
557        foreach ( (array)$values as $key => $value ) {
558            if ( $key === 'nonjs' ) {
559                continue;
560            }
561            $html .= Html::rawElement( 'li', [ 'class' => 'mw-htmlform-cloner-li' ],
562                $this->getInputOOUIForKey( $key, $value )
563            );
564        }
565
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 );
573
574        $field = $this->getCreateButtonHtml();
575        $fieldHtml = $field->getInputOOUI( $field->getDefault() );
576        $fieldHtml->setInfusable( true );
577
578        $html .= $fieldHtml;
579
580        return $html;
581    }
582}
583
584/** @deprecated class alias since 1.42 */
585class_alias( HTMLFormFieldCloner::class, 'HTMLFormFieldCloner' );