Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.19% covered (danger)
42.19%
208 / 493
32.76% covered (danger)
32.76%
19 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTMLFormField
42.28% covered (danger)
42.28%
208 / 492
32.76% covered (danger)
32.76%
19 / 58
10137.86
0.00% covered (danger)
0.00%
0 / 1
 getInputHTML
n/a
0 / 0
n/a
0 / 0
0
 getInputOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInputCodex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canDisplayErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 hasVisibleOutput
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNearestField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getNearestFieldValue
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getNearestFieldByName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateCondState
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 checkStateRecurse
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 parseCondState
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 parseCondStateForClient
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isHidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDisabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 cancelSubmit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
11.06
 filter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 needsLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setShowEmptyLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSubmitAttempt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 loadDataFromRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
68.18% covered (warning)
68.18%
30 / 44
0.00% covered (danger)
0.00%
0 / 1
40.04
 getTableRow
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 getDiv
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 getOOUI
62.79% covered (warning)
62.79%
27 / 43
0.00% covered (danger)
0.00%
0 / 1
29.19
 getCodex
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
110
 getClassName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLabelAlignOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldLayoutOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldInfuseOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getOOUIModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRaw
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getVForm
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getInline
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpTextHtmlTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlDiv
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlRaw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpMessages
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 getHelpText
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 isHelpInline
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorsAndErrorClass
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getErrorsRaw
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 getLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLabelHtml
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 getDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTooltipAndAccessKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTooltipAndAccessKeyOOUI
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 getAttributes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 lookupOptionsKeys
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 forceToStringRecursive
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getOptions
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
12.72
 getOptionsOOUI
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 flattenOptions
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 formatErrors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 getMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 skipLoadData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsJSForHtml5FormValidation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\HTMLForm;
4
5use HtmlArmor;
6use InvalidArgumentException;
7use MediaWiki\Context\RequestContext;
8use MediaWiki\Html\Html;
9use MediaWiki\HTMLForm\Field\HTMLCheckField;
10use MediaWiki\HTMLForm\Field\HTMLFormFieldCloner;
11use MediaWiki\Json\FormatJson;
12use MediaWiki\Linker\Linker;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Message\Message;
15use MediaWiki\Request\WebRequest;
16use MediaWiki\Status\Status;
17use MessageSpecifier;
18use StatusValue;
19
20/**
21 * The parent class to generate form fields.  Any field type should
22 * be a subclass of this.
23 *
24 * @stable to extend
25 */
26abstract class HTMLFormField {
27    /** @var array|array[] */
28    public $mParams;
29
30    /** @var callable(mixed,array,HTMLForm):(StatusValue|string|bool|Message) */
31    protected $mValidationCallback;
32    protected $mFilterCallback;
33    protected $mName;
34    protected $mDir;
35    protected $mLabel; # String label, as HTML. Set on construction.
36    protected $mID;
37    protected $mClass = '';
38    protected $mVFormClass = '';
39    protected $mHelpClass = false;
40    protected $mDefault;
41    private $mNotices;
42
43    /**
44     * @var array|null|false
45     */
46    protected $mOptions = false;
47    protected $mOptionsLabelsNotFromMessage = false;
48    /**
49     * @var array Array to hold params for 'hide-if' or 'disable-if' statements
50     */
51    protected $mCondState = [];
52    protected $mCondStateClass = [];
53
54    /**
55     * @var bool If true will generate an empty div element with no label
56     * @since 1.22
57     */
58    protected $mShowEmptyLabels = true;
59
60    /**
61     * @var HTMLForm|null
62     */
63    public $mParent;
64
65    /**
66     * This function must be implemented to return the HTML to generate
67     * the input object itself.  It should not implement the surrounding
68     * table cells/rows, or labels/help messages.
69     *
70     * @param mixed $value The value to set the input to; eg a default
71     *     text for a text input.
72     *
73     * @return string Valid HTML.
74     */
75    abstract public function getInputHTML( $value );
76
77    /**
78     * Same as getInputHTML, but returns an OOUI object.
79     * Defaults to false, which getOOUI will interpret as "use the HTML version"
80     * @stable to override
81     *
82     * @param string $value
83     * @return \OOUI\Widget|string|false
84     */
85    public function getInputOOUI( $value ) {
86        return false;
87    }
88
89    /**
90     * Same as getInputHTML, but for Codex. This is called by CodexHTMLForm.
91     *
92     * If not overridden, falls back to getInputHTML.
93     *
94     * @param string $value The value to set the input to
95     * @param bool $hasErrors Whether there are validation errors. If set to true, this method
96     *   should apply a CSS class for the error status (e.g. cdx-text-input--status-error)
97     *   if the component used supports that.
98     * @return string HTML
99     */
100    public function getInputCodex( $value, $hasErrors ) {
101        // If not overridden, fall back to getInputHTML()
102        return $this->getInputHTML( $value );
103    }
104
105    /**
106     * True if this field type is able to display errors; false if validation errors need to be
107     * displayed in the main HTMLForm error area.
108     * @stable to override
109     * @return bool
110     */
111    public function canDisplayErrors() {
112        return $this->hasVisibleOutput();
113    }
114
115    /**
116     * Get a translated interface message
117     *
118     * This is a wrapper around $this->mParent->msg() if $this->mParent is set
119     * and wfMessage() otherwise.
120     *
121     * Parameters are the same as wfMessage().
122     *
123     * @param string|string[]|MessageSpecifier $key
124     * @param mixed ...$params
125     * @return Message
126     */
127    public function msg( $key, ...$params ) {
128        if ( $this->mParent ) {
129            return $this->mParent->msg( $key, ...$params );
130        }
131        return wfMessage( $key, ...$params );
132    }
133
134    /**
135     * If this field has a user-visible output or not. If not,
136     * it will not be rendered
137     * @stable to override
138     *
139     * @return bool
140     */
141    public function hasVisibleOutput() {
142        return true;
143    }
144
145    /**
146     * Get the field name that will be used for submission.
147     *
148     * @since 1.38
149     * @return string
150     */
151    public function getName() {
152        return $this->mName;
153    }
154
155    /**
156     * Get the closest field matching a given name.
157     *
158     * It can handle array fields like the user would expect. The general
159     * algorithm is to look for $name as a sibling of $this, then a sibling
160     * of $this's parent, and so on.
161     *
162     * @param string $name
163     * @param bool $backCompat Whether to try striping the 'wp' prefix.
164     * @return HTMLFormField
165     */
166    protected function getNearestField( $name, $backCompat = false ) {
167        // When the field is belong to a HTMLFormFieldCloner
168        $cloner = $this->mParams['cloner'] ?? null;
169        if ( $cloner instanceof HTMLFormFieldCloner ) {
170            $field = $cloner->findNearestField( $this, $name );
171            if ( $field ) {
172                return $field;
173            }
174        }
175
176        if ( $backCompat && str_starts_with( $name, 'wp' ) &&
177            !$this->mParent->hasField( $name )
178        ) {
179            // Don't break the existed use cases.
180            return $this->mParent->getField( substr( $name, 2 ) );
181        }
182        return $this->mParent->getField( $name );
183    }
184
185    /**
186     * Fetch a field value from $alldata for the closest field matching a given
187     * name.
188     *
189     * @param array $alldata
190     * @param string $name
191     * @param bool $asDisplay Whether the reverting logic of HTMLCheckField
192     *     should be ignored.
193     * @param bool $backCompat Whether to try striping the 'wp' prefix.
194     * @return mixed
195     */
196    protected function getNearestFieldValue( $alldata, $name, $asDisplay = false, $backCompat = false ) {
197        $field = $this->getNearestField( $name, $backCompat );
198        // When the field belongs to a HTMLFormFieldCloner
199        $cloner = $field->mParams['cloner'] ?? null;
200        if ( $cloner instanceof HTMLFormFieldCloner ) {
201            $value = $cloner->extractFieldData( $field, $alldata );
202        } else {
203            // Note $alldata is an empty array when first rendering a form with a formIdentifier.
204            // In that case, $alldata[$field->mParams['fieldname']] is unset and we use the
205            // field's default value
206            $value = $alldata[$field->mParams['fieldname']] ?? $field->getDefault();
207        }
208
209        // Check invert state for HTMLCheckField
210        if ( $asDisplay && $field instanceof HTMLCheckField && ( $field->mParams['invert'] ?? false ) ) {
211            $value = !$value;
212        }
213
214        return $value;
215    }
216
217    /**
218     * Fetch a field value from $alldata for the closest field matching a given
219     * name.
220     *
221     * @deprecated since 1.38 Use getNearestFieldValue() instead.
222     * @param array $alldata
223     * @param string $name
224     * @param bool $asDisplay
225     * @return string
226     */
227    protected function getNearestFieldByName( $alldata, $name, $asDisplay = false ) {
228        return (string)$this->getNearestFieldValue( $alldata, $name, $asDisplay );
229    }
230
231    /**
232     * Validate the cond-state params, the existence check of fields should
233     * be done later.
234     *
235     * @param array $params
236     */
237    protected function validateCondState( $params ) {
238        $origParams = $params;
239        $op = array_shift( $params );
240
241        $makeException = function ( string $details ) use ( $origParams ): InvalidArgumentException {
242            return new InvalidArgumentException(
243                "Invalid hide-if or disable-if specification for $this->mName" .
244                $details . " in " . var_export( $origParams, true )
245            );
246        };
247
248        switch ( $op ) {
249            case 'NOT':
250                if ( count( $params ) !== 1 ) {
251                    throw $makeException( "NOT takes exactly one parameter" );
252                }
253                // Fall-through intentionally
254
255            case 'AND':
256            case 'OR':
257            case 'NAND':
258            case 'NOR':
259                foreach ( $params as $i => $p ) {
260                    if ( !is_array( $p ) ) {
261                        $type = gettype( $p );
262                        throw $makeException( "Expected array, found $type at index $i" );
263                    }
264                    $this->validateCondState( $p );
265                }
266                break;
267
268            case '===':
269            case '!==':
270                if ( count( $params ) !== 2 ) {
271                    throw $makeException( "$op takes exactly two parameters" );
272                }
273                [ $name, $value ] = $params;
274                if ( !is_string( $name ) || !is_string( $value ) ) {
275                    throw $makeException( "Parameters for $op must be strings" );
276                }
277                break;
278
279            default:
280                throw $makeException( "Unknown operation" );
281        }
282    }
283
284    /**
285     * Helper function for isHidden and isDisabled to handle recursive data structures.
286     *
287     * @param array $alldata
288     * @param array $params
289     * @return bool
290     */
291    protected function checkStateRecurse( array $alldata, array $params ) {
292        $op = array_shift( $params );
293        $valueChk = [ 'AND' => false, 'OR' => true, 'NAND' => false, 'NOR' => true ];
294        $valueRet = [ 'AND' => true, 'OR' => false, 'NAND' => false, 'NOR' => true ];
295
296        switch ( $op ) {
297            case 'AND':
298            case 'OR':
299            case 'NAND':
300            case 'NOR':
301                foreach ( $params as $p ) {
302                    if ( $valueChk[$op] === $this->checkStateRecurse( $alldata, $p ) ) {
303                        return !$valueRet[$op];
304                    }
305                }
306                return $valueRet[$op];
307
308            case 'NOT':
309                return !$this->checkStateRecurse( $alldata, $params[0] );
310
311            case '===':
312            case '!==':
313                [ $field, $value ] = $params;
314                $testValue = (string)$this->getNearestFieldValue( $alldata, $field, true, true );
315                switch ( $op ) {
316                    case '===':
317                        return ( $value === $testValue );
318                    case '!==':
319                        return ( $value !== $testValue );
320                }
321        }
322    }
323
324    /**
325     * Parse the cond-state array to use the field name for submission, since
326     * the key in the form descriptor is never known in HTML. Also check for
327     * field existence here.
328     *
329     * @param array $params
330     * @return mixed[]
331     */
332    protected function parseCondState( $params ) {
333        $op = array_shift( $params );
334
335        switch ( $op ) {
336            case 'AND':
337            case 'OR':
338            case 'NAND':
339            case 'NOR':
340                $ret = [ $op ];
341                foreach ( $params as $p ) {
342                    $ret[] = $this->parseCondState( $p );
343                }
344                return $ret;
345
346            case 'NOT':
347                return [ 'NOT', $this->parseCondState( $params[0] ) ];
348
349            case '===':
350            case '!==':
351                [ $name, $value ] = $params;
352                $field = $this->getNearestField( $name, true );
353                return [ $op, $field->getName(), $value ];
354        }
355    }
356
357    /**
358     * Parse the cond-state array for client-side.
359     *
360     * @return array[]
361     */
362    protected function parseCondStateForClient() {
363        $parsed = [];
364        foreach ( $this->mCondState as $type => $params ) {
365            $parsed[$type] = $this->parseCondState( $params );
366        }
367        return $parsed;
368    }
369
370    /**
371     * Test whether this field is supposed to be hidden, based on the values of
372     * the other form fields.
373     *
374     * @since 1.23
375     * @param array $alldata The data collected from the form
376     * @return bool
377     */
378    public function isHidden( $alldata ) {
379        return isset( $this->mCondState['hide'] ) &&
380            $this->checkStateRecurse( $alldata, $this->mCondState['hide'] );
381    }
382
383    /**
384     * Test whether this field is supposed to be disabled, based on the values of
385     * the other form fields.
386     *
387     * @since 1.38
388     * @param array $alldata The data collected from the form
389     * @return bool
390     */
391    public function isDisabled( $alldata ) {
392        return ( $this->mParams['disabled'] ?? false ) ||
393            $this->isHidden( $alldata ) ||
394            ( isset( $this->mCondState['disable'] )
395                && $this->checkStateRecurse( $alldata, $this->mCondState['disable'] ) );
396    }
397
398    /**
399     * Override this function if the control can somehow trigger a form
400     * submission that shouldn't actually submit the HTMLForm.
401     *
402     * @stable to override
403     * @since 1.23
404     * @param string|array $value The value the field was submitted with
405     * @param array $alldata The data collected from the form
406     *
407     * @return bool True to cancel the submission
408     */
409    public function cancelSubmit( $value, $alldata ) {
410        return false;
411    }
412
413    /**
414     * Override this function to add specific validation checks on the
415     * field input.  Don't forget to call parent::validate() to ensure
416     * that the user-defined callback mValidationCallback is still run
417     * @stable to override
418     *
419     * @param mixed $value The value the field was submitted with
420     * @param array $alldata The data collected from the form
421     *
422     * @return bool|string|Message True on success, or String/Message error to display, or
423     *   false to fail validation without displaying an error.
424     */
425    public function validate( $value, $alldata ) {
426        if ( $this->isHidden( $alldata ) ) {
427            return true;
428        }
429
430        if ( isset( $this->mParams['required'] )
431            && $this->mParams['required'] !== false
432            && ( $value === '' || $value === false || $value === null )
433        ) {
434            return $this->msg( 'htmlform-required' );
435        }
436
437        if ( !isset( $this->mValidationCallback ) ) {
438            return true;
439        }
440
441        $p = ( $this->mValidationCallback )( $value, $alldata, $this->mParent );
442
443        if ( $p instanceof StatusValue ) {
444            $language = $this->mParent ? $this->mParent->getLanguage() : RequestContext::getMain()->getLanguage();
445
446            return $p->isGood() ? true : Status::wrap( $p )->getHTML( false, false, $language );
447        }
448
449        return $p;
450    }
451
452    /**
453     * @stable to override
454     *
455     * @param mixed $value
456     * @param mixed[] $alldata
457     *
458     * @return mixed
459     */
460    public function filter( $value, $alldata ) {
461        if ( isset( $this->mFilterCallback ) ) {
462            $value = ( $this->mFilterCallback )( $value, $alldata, $this->mParent );
463        }
464
465        return $value;
466    }
467
468    /**
469     * Should this field have a label, or is there no input element with the
470     * appropriate id for the label to point to?
471     * @stable to override
472     *
473     * @return bool True to output a label, false to suppress
474     */
475    protected function needsLabel() {
476        return true;
477    }
478
479    /**
480     * Tell the field whether to generate a separate label element if its label
481     * is blank.
482     *
483     * @since 1.22
484     *
485     * @param bool $show Set to false to not generate a label.
486     * @return void
487     */
488    public function setShowEmptyLabel( $show ) {
489        $this->mShowEmptyLabels = $show;
490    }
491
492    /**
493     * Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to
494     * just view it? This can't normally be distinguished for e.g. checkboxes.
495     *
496     * Returns true if the request was posted and has a field for a CSRF token (wpEditToken), or
497     * has a form identifier (wpFormIdentifier).
498     *
499     * @todo Consider moving this to HTMLForm?
500     * @param WebRequest $request
501     * @return bool
502     */
503    protected function isSubmitAttempt( WebRequest $request ) {
504        // HTMLForm would add a hidden field of edit token for forms that require to be posted.
505        return ( $request->wasPosted() && $request->getCheck( 'wpEditToken' ) )
506            // The identifier matching or not has been checked in HTMLForm::prepareForm()
507            || $request->getCheck( 'wpFormIdentifier' );
508    }
509
510    /**
511     * Get the value that this input has been set to from a posted form,
512     * or the input's default value if it has not been set.
513     * @stable to override
514     *
515     * @param WebRequest $request
516     * @return mixed The value
517     */
518    public function loadDataFromRequest( $request ) {
519        if ( $request->getCheck( $this->mName ) ) {
520            return $request->getText( $this->mName );
521        } else {
522            return $this->getDefault();
523        }
524    }
525
526    /**
527     * Initialise the object
528     *
529     * @stable to call
530     * @param array $params Associative Array. See HTMLForm doc for syntax.
531     *
532     * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
533     */
534    public function __construct( $params ) {
535        $this->mParams = $params;
536
537        if ( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) {
538            $this->mParent = $params['parent'];
539        } else {
540            // Normally parent is added automatically by HTMLForm::factory.
541            // Several field types already assume unconditionally this is always set,
542            // so deprecate manually creating an HTMLFormField without a parent form set.
543            wfDeprecatedMsg(
544                __METHOD__ . ": Constructing an HTMLFormField without a 'parent' parameter",
545                "1.40"
546            );
547        }
548
549        # Generate the label from a message, if possible
550        if ( isset( $params['label-message'] ) ) {
551            $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
552        } elseif ( isset( $params['label'] ) ) {
553            if ( $params['label'] === '&#160;' || $params['label'] === "\u{00A0}" ) {
554                // Apparently some things set &nbsp directly and in an odd format
555                $this->mLabel = "\u{00A0}";
556            } else {
557                $this->mLabel = htmlspecialchars( $params['label'] );
558            }
559        } elseif ( isset( $params['label-raw'] ) ) {
560            $this->mLabel = $params['label-raw'];
561        }
562
563        $this->mName = $params['name'] ?? 'wp' . $params['fieldname'];
564
565        if ( isset( $params['dir'] ) ) {
566            $this->mDir = $params['dir'];
567        }
568
569        $this->mID = "mw-input-{$this->mName}";
570
571        if ( isset( $params['default'] ) ) {
572            $this->mDefault = $params['default'];
573        }
574
575        if ( isset( $params['id'] ) ) {
576            $this->mID = $params['id'];
577        }
578
579        if ( isset( $params['cssclass'] ) ) {
580            $this->mClass = $params['cssclass'];
581        }
582
583        if ( isset( $params['csshelpclass'] ) ) {
584            $this->mHelpClass = $params['csshelpclass'];
585        }
586
587        if ( isset( $params['validation-callback'] ) ) {
588            $this->mValidationCallback = $params['validation-callback'];
589        }
590
591        if ( isset( $params['filter-callback'] ) ) {
592            $this->mFilterCallback = $params['filter-callback'];
593        }
594
595        if ( isset( $params['hidelabel'] ) ) {
596            $this->mShowEmptyLabels = false;
597        }
598        if ( isset( $params['notices'] ) ) {
599            $this->mNotices = $params['notices'];
600        }
601
602        if ( isset( $params['hide-if'] ) && $params['hide-if'] ) {
603            $this->validateCondState( $params['hide-if'] );
604            $this->mCondState['hide'] = $params['hide-if'];
605            $this->mCondStateClass[] = 'mw-htmlform-hide-if';
606        }
607        if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
608            isset( $params['disable-if'] ) && $params['disable-if']
609        ) {
610            $this->validateCondState( $params['disable-if'] );
611            $this->mCondState['disable'] = $params['disable-if'];
612            $this->mCondStateClass[] = 'mw-htmlform-disable-if';
613        }
614    }
615
616    /**
617     * Get the complete table row for the input, including help text,
618     * labels, and whatever.
619     * @stable to override
620     *
621     * @param string $value The value to set the input to.
622     *
623     * @return string Complete HTML table row.
624     */
625    public function getTableRow( $value ) {
626        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
627        $inputHtml = $this->getInputHTML( $value );
628        $fieldType = $this->getClassName();
629        $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
630        $cellAttributes = [];
631        $rowAttributes = [];
632        $rowClasses = '';
633
634        if ( !empty( $this->mParams['vertical-label'] ) ) {
635            $cellAttributes['colspan'] = 2;
636            $verticalLabel = true;
637        } else {
638            $verticalLabel = false;
639        }
640
641        $label = $this->getLabelHtml( $cellAttributes );
642
643        $field = Html::rawElement(
644            'td',
645            [ 'class' => 'mw-input' ] + $cellAttributes,
646            $inputHtml . "\n$errors"
647        );
648
649        if ( $this->mCondState ) {
650            $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
651            $rowClasses .= implode( ' ', $this->mCondStateClass );
652        }
653
654        if ( $verticalLabel ) {
655            $html = Html::rawElement( 'tr',
656                $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
657            $html .= Html::rawElement( 'tr',
658                $rowAttributes + [
659                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
660                ],
661                $field );
662        } else {
663            $html = Html::rawElement( 'tr',
664                $rowAttributes + [
665                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
666                ],
667                $label . $field );
668        }
669
670        return $html . $helptext;
671    }
672
673    /**
674     * Get the complete div for the input, including help text,
675     * labels, and whatever.
676     * @stable to override
677     * @since 1.20
678     *
679     * @param string $value The value to set the input to.
680     *
681     * @return string Complete HTML table row.
682     */
683    public function getDiv( $value ) {
684        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
685        $inputHtml = $this->getInputHTML( $value );
686        $fieldType = $this->getClassName();
687        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
688        $cellAttributes = [];
689        $label = $this->getLabelHtml( $cellAttributes );
690
691        $outerDivClass = [
692            'mw-input',
693            'mw-htmlform-nolabel' => ( $label === '' )
694        ];
695
696        $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
697
698        if ( $horizontalLabel ) {
699            $field = "\u{00A0}" . $inputHtml . "\n$errors";
700        } else {
701            $field = Html::rawElement(
702                'div',
703                // @phan-suppress-next-line PhanUselessBinaryAddRight
704                [ 'class' => $outerDivClass ] + $cellAttributes,
705                $inputHtml . "\n$errors"
706            );
707        }
708
709        $wrapperAttributes = [ 'class' => [
710            "mw-htmlform-field-$fieldType",
711            $this->mClass,
712            $this->mVFormClass,
713            $errorClass,
714        ] ];
715        if ( $this->mCondState ) {
716            $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
717            $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
718        }
719        return Html::rawElement( 'div', $wrapperAttributes, $label . $field ) .
720            $helptext;
721    }
722
723    /**
724     * Get the OOUI version of the div. Falls back to getDiv by default.
725     * @stable to override
726     * @since 1.26
727     *
728     * @param string $value The value to set the input to.
729     *
730     * @return \OOUI\FieldLayout
731     */
732    public function getOOUI( $value ) {
733        $inputField = $this->getInputOOUI( $value );
734
735        if ( !$inputField ) {
736            // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
737            // generate the whole field, label and errors and all, then wrap it in a Widget.
738            // It might look weird, but it'll work OK.
739            return $this->getFieldLayoutOOUI(
740                new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
741                [ 'align' => 'top' ]
742            );
743        }
744
745        $infusable = true;
746        if ( is_string( $inputField ) ) {
747            // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
748            // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
749            // JavaScript doesn't know how to rebuilt the contents.
750            $inputField = new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $inputField ) ] );
751            $infusable = false;
752        }
753
754        $fieldType = $this->getClassName();
755        $help = $this->getHelpText();
756        $errors = $this->getErrorsRaw( $value );
757        foreach ( $errors as &$error ) {
758            $error = new \OOUI\HtmlSnippet( $error );
759        }
760
761        $config = [
762            'classes' => [ "mw-htmlform-field-$fieldType" ],
763            'align' => $this->getLabelAlignOOUI(),
764            'help' => ( $help !== null && $help !== '' ) ? new \OOUI\HtmlSnippet( $help ) : null,
765            'errors' => $errors,
766            'infusable' => $infusable,
767            'helpInline' => $this->isHelpInline(),
768            'notices' => $this->mNotices ?: [],
769        ];
770        if ( $this->mClass !== '' ) {
771            $config['classes'][] = $this->mClass;
772        }
773
774        $preloadModules = false;
775
776        if ( $infusable && $this->shouldInfuseOOUI() ) {
777            $preloadModules = true;
778            $config['classes'][] = 'mw-htmlform-autoinfuse';
779        }
780        if ( $this->mCondState ) {
781            $config['classes'] = array_merge( $config['classes'], $this->mCondStateClass );
782        }
783
784        // the element could specify, that the label doesn't need to be added
785        $label = $this->getLabel();
786        if ( $label && $label !== "\u{00A0}" && $label !== '&#160;' ) {
787            $config['label'] = new \OOUI\HtmlSnippet( $label );
788        }
789
790        if ( $this->mCondState ) {
791            $preloadModules = true;
792            $config['condState'] = $this->parseCondStateForClient();
793        }
794
795        $config['modules'] = $this->getOOUIModules();
796
797        if ( $preloadModules ) {
798            $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
799            $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
800        }
801
802        return $this->getFieldLayoutOOUI( $inputField, $config );
803    }
804
805    /**
806     * Get the Codex version of the div.
807     * @since 1.42
808     *
809     * @param string $value The value to set the input to.
810     * @return string HTML
811     */
812    public function getCodex( $value ) {
813        $isDisabled = ( $this->mParams['disabled'] ?? false );
814
815        // Label
816        $labelDiv = '';
817        $labelValue = trim( $this->getLabel() );
818        // For weird historical reasons, a non-breaking space is treated as an empty label
819        // Check for both a literal nbsp ("\u{00A0}") and the HTML-encoded version
820        if ( $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' ) {
821            $labelFor = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
822            $labelClasses = [ 'cdx-label' ];
823            if ( $isDisabled ) {
824                $labelClasses[] = 'cdx-label--disabled';
825            }
826            // <div class="cdx-label">
827            $labelDiv = Html::rawElement( 'div', [ 'class' => $labelClasses ],
828                // <label class="cdx-label__label" for="ID">
829                Html::rawElement( 'label', [ 'class' => 'cdx-label__label' ] + $labelFor,
830                    // <span class="cdx-label__label__text">
831                    Html::rawElement( 'span', [ 'class' => 'cdx-label__label__text' ],
832                        $labelValue
833                    )
834                )
835            );
836        }
837
838        // Help text
839        // <div class="cdx-field__help-text">
840        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText(), [ 'cdx-field__help-text' ] );
841
842        // Validation message
843        // <div class="cdx-field__validation-message">
844        // $errors is a <div class="cdx-message">
845        // FIXME right now this generates a block message (cdx-message--block), we want an inline message instead
846        $validationMessage = '';
847        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
848        if ( $errors !== '' ) {
849            $validationMessage = Html::rawElement( 'div', [ 'class' => 'cdx-field__validation-message' ],
850                $errors
851            );
852        }
853
854        // Control
855        $inputHtml = $this->getInputCodex( $value, $errors !== '' );
856        // <div class="cdx-field__control cdx-field__control--has-help-text">
857        $controlClasses = [ 'cdx-field__control' ];
858        if ( $helptext ) {
859            $controlClasses[] = 'cdx-field__control--has-help-text';
860        }
861        $control = Html::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml