Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTMLMultiSelectField
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 11
1722
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 validate
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getInputHTML
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 formatOptions
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 getOneCheckbox
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getOptionsOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInputOOUI
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
210
 loadDataFromRequest
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getDefault
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 filterDataForSubmit
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 needsLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\HTMLForm\Field;
4
5use MediaWiki\Html\Html;
6use MediaWiki\HTMLForm\HTMLFormField;
7use MediaWiki\HTMLForm\HTMLNestedFilterable;
8use MediaWiki\HTMLForm\OOUIHTMLForm;
9use MediaWiki\Request\WebRequest;
10use MediaWiki\Xml\Xml;
11use RuntimeException;
12
13/**
14 * Multi-select field
15 *
16 * @stable to extend
17 */
18class HTMLMultiSelectField extends HTMLFormField implements HTMLNestedFilterable {
19    /** @var string */
20    private $mPlaceholder;
21
22    /**
23     * @stable to call
24     *
25     * @param array $params
26     *   In adition to the usual HTMLFormField parameters, this can take the following fields:
27     *   - dropdown: If given, the options will be displayed inside a dropdown with a text field that
28     *     can be used to filter them. This is desirable mostly for very long lists of options.
29     *     This only works for users with JavaScript support and falls back to the list of checkboxes.
30     *   - flatlist: If given, the options will be displayed on a single line (wrapping to following
31     *     lines if necessary), rather than each one on a line of its own. This is desirable mostly
32     *     for very short lists of concisely labelled options.
33     */
34    public function __construct( $params ) {
35        parent::__construct( $params );
36
37        // If the disabled-options parameter is not provided, use an empty array
38        if ( !isset( $this->mParams['disabled-options'] ) ) {
39            $this->mParams['disabled-options'] = [];
40        }
41
42        if ( isset( $params['dropdown'] ) ) {
43            $this->mClass .= ' mw-htmlform-dropdown';
44            if ( isset( $params['placeholder'] ) ) {
45                $this->mPlaceholder = $params['placeholder'];
46            } elseif ( isset( $params['placeholder-message'] ) ) {
47                $this->mPlaceholder = $this->msg( $params['placeholder-message'] )->text();
48            }
49        }
50
51        if ( isset( $params['flatlist'] ) ) {
52            $this->mClass .= ' mw-htmlform-flatlist';
53        }
54    }
55
56    /**
57     * @inheritDoc
58     * @stable to override
59     */
60    public function validate( $value, $alldata ) {
61        $p = parent::validate( $value, $alldata );
62
63        if ( $p !== true ) {
64            return $p;
65        }
66
67        if ( !is_array( $value ) ) {
68            return false;
69        }
70
71        // Reject nested arrays (T274955)
72        $value = array_filter( $value, 'is_scalar' );
73
74        # If all options are valid, array_intersect of the valid options
75        # and the provided options will return the provided options.
76        $validOptions = HTMLFormField::flattenOptions( $this->getOptions() );
77
78        $validValues = array_intersect( $value, $validOptions );
79        if ( count( $validValues ) == count( $value ) ) {
80            return true;
81        } else {
82            return $this->msg( 'htmlform-select-badoption' );
83        }
84    }
85
86    /**
87     * @inheritDoc
88     * @stable to override
89     */
90    public function getInputHTML( $value ) {
91        $value = HTMLFormField::forceToStringRecursive( $value );
92        $html = $this->formatOptions( $this->getOptions(), $value );
93
94        return $html;
95    }
96
97    /**
98     * @stable to override
99     *
100     * @param array $options
101     * @param mixed $value
102     *
103     * @return string
104     */
105    public function formatOptions( $options, $value ) {
106        $html = '';
107
108        $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
109
110        foreach ( $options as $label => $info ) {
111            if ( is_array( $info ) ) {
112                $html .= Html::rawElement( 'h1', [], $label ) . "\n";
113                $html .= $this->formatOptions( $info, $value );
114            } else {
115                $thisAttribs = [
116                    'id' => "{$this->mID}-$info",
117                    'value' => $info,
118                ];
119                if ( in_array( $info, $this->mParams['disabled-options'], true ) ) {
120                    $thisAttribs['disabled'] = 'disabled';
121                }
122                $checked = in_array( $info, $value, true );
123
124                $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs, $label );
125
126                $html .= ' ' . Html::rawElement(
127                    'div',
128                    [ 'class' => 'mw-htmlform-flatlist-item' ],
129                    $checkbox
130                );
131            }
132        }
133
134        return $html;
135    }
136
137    protected function getOneCheckbox( $checked, $attribs, $label ) {
138        if ( $this->mParent instanceof OOUIHTMLForm ) {
139            throw new RuntimeException( __METHOD__ . ' is not supported' );
140        } else {
141            $elementFunc = [ Html::class, $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ];
142            $checkbox =
143                Xml::check( "{$this->mName}[]", $checked, $attribs ) .
144                "\u{00A0}" .
145                call_user_func( $elementFunc,
146                    'label',
147                    [ 'for' => $attribs['id'] ],
148                    $label
149                );
150            return $checkbox;
151        }
152    }
153
154    /**
155     * Get options and make them into arrays suitable for OOUI.
156     * @stable to override
157     */
158    public function getOptionsOOUI() {
159        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
160        // Sections make this difficult. See getInputOOUI().
161        throw new RuntimeException( __METHOD__ . ' is not supported' );
162    }
163
164    /**
165     * Get the OOUI version of this field.
166     *
167     * Returns OOUI\CheckboxMultiselectInputWidget for fields that only have one section,
168     * string otherwise.
169     *
170     * @stable to override
171     * @since 1.28
172     * @param string[] $value
173     * @return string|\OOUI\CheckboxMultiselectInputWidget
174     * @suppress PhanParamSignatureMismatch
175     */
176    public function getInputOOUI( $value ) {
177        $this->mParent->getOutput()->addModules( 'oojs-ui-widgets' );
178
179        // Reject nested arrays (T274955)
180        $value = array_filter( $value, 'is_scalar' );
181
182        $hasSections = false;
183        $optionsOouiSections = [];
184        $options = $this->getOptions();
185        // If the options are supposed to be split into sections, each section becomes a separate
186        // CheckboxMultiselectInputWidget.
187        foreach ( $options as $label => $section ) {
188            if ( is_array( $section ) ) {
189                $optionsOouiSections[ $label ] = Html::listDropdownOptionsOoui( $section );
190                unset( $options[$label] );
191                $hasSections = true;
192            }
193        }
194        // If anything remains in the array, they are sectionless options. Put them in a separate widget
195        // at the beginning.
196        if ( $options ) {
197            $optionsOouiSections = array_merge(
198                [ '' => Html::listDropdownOptionsOoui( $options ) ],
199                $optionsOouiSections
200            );
201        }
202        '@phan-var array[][] $optionsOouiSections';
203
204        $out = [];
205        foreach ( $optionsOouiSections as $sectionLabel => $optionsOoui ) {
206            $attr = [];
207            $attr['name'] = "{$this->mName}[]";
208
209            $attr['value'] = $value;
210
211            $options = $optionsOoui;
212            foreach ( $options as &$option ) {
213                $option['disabled'] = in_array( $option['data'], $this->mParams['disabled-options'], true );
214            }
215            if ( $this->mOptionsLabelsNotFromMessage ) {
216                foreach ( $options as &$option ) {
217                    $option['label'] = new \OOUI\HtmlSnippet( $option['label'] );
218                }
219            }
220            unset( $option );
221            $attr['options'] = $options;
222
223            $attr += \OOUI\Element::configFromHtmlAttributes(
224                $this->getAttributes( [ 'disabled', 'tabindex' ] )
225            );
226
227            if ( $this->mClass !== '' ) {
228                $attr['classes'] = [ $this->mClass ];
229            }
230
231            $widget = new \OOUI\CheckboxMultiselectInputWidget( $attr );
232            if ( $sectionLabel ) {
233                $out[] = new \OOUI\FieldsetLayout( [
234                    'items' => [ $widget ],
235                    // @phan-suppress-next-line SecurityCheck-XSS Key is html, taint cannot track that
236                    'label' => new \OOUI\HtmlSnippet( $sectionLabel ),
237                ] );
238            } else {
239                $out[] = $widget;
240            }
241        }
242
243        if ( !$hasSections && $out ) {
244            if ( $this->mPlaceholder ) {
245                $out[0]->setData( ( $out[0]->getData() ?: [] ) + [
246                    'placeholder' => $this->mPlaceholder,
247                ] );
248            }
249            // Directly return the only OOUI\CheckboxMultiselectInputWidget.
250            // This allows it to be made infusable and later tweaked by JS code.
251            return $out[0];
252        }
253
254        return implode( '', $out );
255    }
256
257    /**
258     * @stable to override
259     * @param WebRequest $request
260     *
261     * @return string|array
262     */
263    public function loadDataFromRequest( $request ) {
264        $fromRequest = $request->getArray( $this->mName, [] );
265        // Fetch the value in either one of the two following case:
266        // - we have a valid submit attempt (form was just submitted)
267        // - we have a value (an URL manually built by the user, or GET form with no wpFormIdentifier)
268        if ( $this->isSubmitAttempt( $request ) || $fromRequest ) {
269            // Checkboxes are just not added to the request arrays if they're not checked,
270            // so it's perfectly possible for there not to be an entry at all
271            // @phan-suppress-next-line PhanTypeMismatchReturnNullable getArray does not return null
272            return $fromRequest;
273        } else {
274            // That's ok, the user has not yet submitted the form, so show the defaults
275            return $this->getDefault();
276        }
277    }
278
279    /**
280     * @inheritDoc
281     * @stable to override
282     */
283    public function getDefault() {
284        return $this->mDefault ?? [];
285    }
286
287    /**
288     * @inheritDoc
289     * @stable to override
290     */
291    public function filterDataForSubmit( $data ) {
292        $data = HTMLFormField::forceToStringRecursive( $data );
293        $options = HTMLFormField::flattenOptions( $this->getOptions() );
294        $forcedOn = array_intersect( $this->mParams['disabled-options'], $this->getDefault() );
295
296        $res = [];
297        foreach ( $options as $opt ) {
298            $res["$opt"] = in_array( $opt, $forcedOn, true ) || in_array( $opt, $data, true );
299        }
300
301        return $res;
302    }
303
304    /**
305     * @inheritDoc
306     * @stable to override
307     */
308    protected function needsLabel() {
309        return false;
310    }
311}
312
313/** @deprecated class alias since 1.42 */
314class_alias( HTMLMultiSelectField::class, 'HTMLMultiSelectField' );