2 * HTMLForm enhancements:
3 * Set up 'hide-if' and 'disable-if' behaviors for form fields that have them.
7 * Helper function for conditional states to find the nearby form field.
9 * Find the closest match for the given name, "closest" being the minimum
10 * level of parents to go to find a form field matching the given name or
11 * ending in array keys matching the given name (e.g. "baz" matches
16 * @param {jQuery} $root
17 * @param {string} name
18 * @return {jQuery|null}
20 function conditionGetField( $root, name ) {
21 const nameFilter = function () {
22 return this.name === name;
24 let $found = $root.find( '[name]' ).filter( nameFilter );
25 if ( !$found.length ) {
26 // Field cloner can load from template dynamically and fire event on sub element
27 $found = $root.closest( 'form' ).find( '[name]' ).filter( nameFilter );
29 return $found.length ? $found : null;
33 * Helper function to get the OOUI widget containing the given field, if any.
37 * @param {jQuery} $field
38 * @return {OO.ui.Widget|null}
40 function getWidget( $field ) {
41 const $widget = $field.closest( '.oo-ui-widget[data-ooui]' );
42 if ( $widget.length ) {
43 return OO.ui.Widget.static.infuse( $widget );
49 * Helper function for conditional states to return a test function and list of
50 * dependent fields for a conditional states specification.
54 * @param {jQuery} $root
57 * @return {Array} return.0 Dependent fields, array of jQuery objects
58 * @return {Function} return.1 Test function
60 function conditionParse( $root, spec ) {
72 for ( let i = 1; i < l; i++ ) {
73 if ( !Array.isArray( spec[ i ] ) ) {
74 throw new Error( op + ' parameters must be arrays' );
76 v = conditionParse( $root, spec[ i ] );
77 fields = fields.concat( v[ 0 ] );
82 const valueChk = { AND: false, OR: true, NAND: false, NOR: true };
83 const valueRet = { AND: true, OR: false, NAND: false, NOR: true };
85 for ( let j = 0; j < l; j++ ) {
86 if ( valueChk[ op ] === funcs[ j ]() ) {
87 return !valueRet[ op ];
90 return valueRet[ op ];
93 return [ fields, func ];
98 throw new Error( 'NOT takes exactly one parameter' );
100 if ( !Array.isArray( spec[ 1 ] ) ) {
101 throw new Error( 'NOT parameters must be arrays' );
103 v = conditionParse( $root, spec[ 1 ] );
106 return [ fields, function () {
113 throw new Error( op + ' takes exactly two parameters' );
115 const $field = conditionGetField( $root, spec[ 1 ] );
117 return [ [], function () {
124 const getVal = function () {
125 // When the value is requested for the first time,
126 // determine if we need to treat this field as a OOUI widget.
127 if ( widget === undefined ) {
128 widget = getWidget( $field );
132 if ( widget.supports( 'isSelected' ) ) {
133 const selected = widget.isSelected();
134 return selected ? widget.getValue() : '';
136 return widget.getValue();
139 if ( $field.prop( 'type' ) === 'radio' || $field.prop( 'type' ) === 'checkbox' ) {
140 const $selected = $field.filter( ':checked' );
141 return $selected.length ? $selected.val() : '';
151 return getVal() === v;
156 return getVal() !== v;
161 return [ [ $field ], func ];
165 throw new Error( 'Unrecognized operation \'' + op + '\'' );
170 * Helper function to get the list of ResourceLoader modules needed to infuse the OOUI widgets
171 * containing the given fields.
175 * @param {jQuery} $fields
178 function gatherOOUIModules( $fields ) {
179 const $oouiFields = $fields.filter( '[data-ooui]' );
182 if ( $oouiFields.length ) {
183 modules.push( 'mediawiki.htmlform.ooui' );
184 $oouiFields.each( function () {
185 const data = $( this ).data( 'mw-modules' );
187 // We can trust this value, 'data-mw-*' attributes are banned from user content in Sanitizer
188 const extraModules = data.split( ',' );
189 modules.push( ...extraModules );
197 mw.hook( 'htmlform.enhance' ).add( ( $root ) => {
198 const $exclude = $root.find( '.mw-htmlform-autoinfuse-lazy' )
199 .find( '.mw-htmlform-hide-if, .mw-htmlform-disable-if' );
200 const $fields = $root.find( '.mw-htmlform-hide-if, .mw-htmlform-disable-if' ).not( $exclude );
202 // Load modules for the fields we will hide/disable
203 mw.loader.using( gatherOOUIModules( $fields ) ).done( () => {
204 $fields.each( function () {
205 const $el = $( this );
207 let spec, $elOrLayout, $form;
208 if ( $el.is( '[data-ooui]' ) ) {
209 // $elOrLayout should be a FieldLayout that mixes in mw.htmlform.Element
210 $elOrLayout = OO.ui.FieldLayout.static.infuse( $el );
211 $form = $elOrLayout.$element.closest( 'form' );
212 spec = $elOrLayout.condState;
215 $form = $el.closest( 'form' );
216 spec = $el.data( 'condState' );
225 [ 'hide', 'disable' ].forEach( ( type ) => {
226 if ( spec[ type ] ) {
227 const v = conditionParse( $form, spec[ type ] );
228 fields = fields.concat( fields, v[ 0 ] );
229 test[ type ] = v[ 1 ];
232 const func = function () {
233 const shouldHide = spec.hide ? test.hide() : false;
234 const shouldDisable = shouldHide || ( spec.disable ? test.disable() : false );
236 // Remove server-side CSS class that hides the elements, and re-compute the state
237 if ( $elOrLayout instanceof $ ) {
238 $elOrLayout.removeClass( 'mw-htmlform-hide-if-hidden' );
240 $elOrLayout.$element.removeClass( 'mw-htmlform-hide-if-hidden' );
242 // The .toggle() method works mostly the same for jQuery objects and OO.ui.Widget
243 $elOrLayout.toggle( !shouldHide );
246 // Disable fields with either 'disable-if' or 'hide-if' rules
247 // Hidden fields should be disabled to avoid users meet validation failure on these fields,
248 // because disabled fields will not be submitted with the form.
249 if ( $elOrLayout instanceof $ ) {
250 // This also finds elements inside any nested fields (in case of HTMLFormFieldCloner),
251 // which is problematic. But it works because:
252 // * HTMLFormFieldCloner::createFieldsForKey() copies '*-if' rules to nested fields
253 // * jQuery collections like $fields are in document order, so we register event
254 // handlers for parents first
255 // * Event handlers are fired in the order they were registered, so even if the handler
256 // for parent messed up the child, the handle for child will run next and fix it
257 $elOrLayout.find( 'input, textarea, select' ).each( function () {
258 const $this = $( this );
259 if ( shouldDisable ) {
260 if ( $this.data( 'was-disabled' ) === undefined ) {
261 $this.data( 'was-disabled', $this.prop( 'disabled' ) );
263 $this.prop( 'disabled', true );
265 $this.prop( 'disabled', $this.data( 'was-disabled' ) );
269 // $elOrLayout is a OO.ui.FieldLayout
270 if ( shouldDisable ) {
271 if ( $elOrLayout.wasDisabled === undefined ) {
272 $elOrLayout.wasDisabled = $elOrLayout.fieldWidget.isDisabled();
274 $elOrLayout.fieldWidget.setDisabled( true );
275 } else if ( $elOrLayout.wasDisabled !== undefined ) {
276 $elOrLayout.fieldWidget.setDisabled( $elOrLayout.wasDisabled );
281 const oouiNodes = fields.map(
282 // We expect undefined for non-OOUI nodes (T308626)
283 ( $node ) => $node.closest( '.oo-ui-fieldLayout[data-ooui]' )[ 0 ]
289 // Load modules for the fields whose state we will check
290 mw.loader.using( gatherOOUIModules( $( oouiNodes ) ) ).done( () => {
291 for ( let i = 0; i < fields.length; i++ ) {
292 const widget = getWidget( fields[ i ] );
294 fields[ i ] = widget;
296 // The .on() method works mostly the same for jQuery objects and OO.ui.Widget
297 fields[ i ].on( 'change', func );