Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.htmlform / cond-state.js
blob9ea4a2fe4c0c3be2cf484fce0ab1e16ed71d910f
1 /*
2  * HTMLForm enhancements:
3  * Set up 'hide-if' and 'disable-if' behaviors for form fields that have them.
4  */
6 /**
7  * Helper function for conditional states to find the nearby form field.
8  *
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
12  * "foo[bar][baz]").
13  *
14  * @ignore
15  * @private
16  * @param {jQuery} $root
17  * @param {string} name
18  * @return {jQuery|null}
19  */
20 function conditionGetField( $root, name ) {
21         const nameFilter = function () {
22                 return this.name === name;
23         };
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 );
28         }
29         return $found.length ? $found : null;
32 /**
33  * Helper function to get the OOUI widget containing the given field, if any.
34  *
35  * @ignore
36  * @private
37  * @param {jQuery} $field
38  * @return {OO.ui.Widget|null}
39  */
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 );
44         }
45         return null;
48 /**
49  * Helper function for conditional states to return a test function and list of
50  * dependent fields for a conditional states specification.
51  *
52  * @ignore
53  * @private
54  * @param {jQuery} $root
55  * @param {Array} spec
56  * @return {Array}
57  * @return {Array} return.0 Dependent fields, array of jQuery objects
58  * @return {Function} return.1 Test function
59  */
60 function conditionParse( $root, spec ) {
61         let v, fields, func;
63         const op = spec[ 0 ];
64         let l = spec.length;
65         switch ( op ) {
66                 case 'AND':
67                 case 'OR':
68                 case 'NAND':
69                 case 'NOR': {
70                         const funcs = [];
71                         fields = [];
72                         for ( let i = 1; i < l; i++ ) {
73                                 if ( !Array.isArray( spec[ i ] ) ) {
74                                         throw new Error( op + ' parameters must be arrays' );
75                                 }
76                                 v = conditionParse( $root, spec[ i ] );
77                                 fields = fields.concat( v[ 0 ] );
78                                 funcs.push( v[ 1 ] );
79                         }
81                         l = funcs.length;
82                         const valueChk = { AND: false, OR: true, NAND: false, NOR: true };
83                         const valueRet = { AND: true, OR: false, NAND: false, NOR: true };
84                         func = function () {
85                                 for ( let j = 0; j < l; j++ ) {
86                                         if ( valueChk[ op ] === funcs[ j ]() ) {
87                                                 return !valueRet[ op ];
88                                         }
89                                 }
90                                 return valueRet[ op ];
91                         };
93                         return [ fields, func ];
94                 }
96                 case 'NOT':
97                         if ( l !== 2 ) {
98                                 throw new Error( 'NOT takes exactly one parameter' );
99                         }
100                         if ( !Array.isArray( spec[ 1 ] ) ) {
101                                 throw new Error( 'NOT parameters must be arrays' );
102                         }
103                         v = conditionParse( $root, spec[ 1 ] );
104                         fields = v[ 0 ];
105                         func = v[ 1 ];
106                         return [ fields, function () {
107                                 return !func();
108                         } ];
110                 case '===':
111                 case '!==': {
112                         if ( l !== 3 ) {
113                                 throw new Error( op + ' takes exactly two parameters' );
114                         }
115                         const $field = conditionGetField( $root, spec[ 1 ] );
116                         if ( !$field ) {
117                                 return [ [], function () {
118                                         return false;
119                                 } ];
120                         }
121                         v = spec[ 2 ];
123                         let widget;
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 );
129                                 }
131                                 if ( widget ) {
132                                         if ( widget.supports( 'isSelected' ) ) {
133                                                 const selected = widget.isSelected();
134                                                 return selected ? widget.getValue() : '';
135                                         } else {
136                                                 return widget.getValue();
137                                         }
138                                 } else {
139                                         if ( $field.prop( 'type' ) === 'radio' || $field.prop( 'type' ) === 'checkbox' ) {
140                                                 const $selected = $field.filter( ':checked' );
141                                                 return $selected.length ? $selected.val() : '';
142                                         } else {
143                                                 return $field.val();
144                                         }
145                                 }
146                         };
148                         switch ( op ) {
149                                 case '===':
150                                         func = function () {
151                                                 return getVal() === v;
152                                         };
153                                         break;
154                                 case '!==':
155                                         func = function () {
156                                                 return getVal() !== v;
157                                         };
158                                         break;
159                         }
161                         return [ [ $field ], func ];
162                 }
164                 default:
165                         throw new Error( 'Unrecognized operation \'' + op + '\'' );
166         }
170  * Helper function to get the list of ResourceLoader modules needed to infuse the OOUI widgets
171  * containing the given fields.
173  * @ignore
174  * @private
175  * @param {jQuery} $fields
176  * @return {string[]}
177  */
178 function gatherOOUIModules( $fields ) {
179         const $oouiFields = $fields.filter( '[data-ooui]' );
180         const modules = [];
182         if ( $oouiFields.length ) {
183                 modules.push( 'mediawiki.htmlform.ooui' );
184                 $oouiFields.each( function () {
185                         const data = $( this ).data( 'mw-modules' );
186                         if ( data ) {
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 );
190                         }
191                 } );
192         }
194         return modules;
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;
213                         } else {
214                                 $elOrLayout = $el;
215                                 $form = $el.closest( 'form' );
216                                 spec = $el.data( 'condState' );
217                         }
219                         if ( !spec ) {
220                                 return;
221                         }
223                         let fields = [];
224                         const test = {};
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 ];
230                                 }
231                         } );
232                         const func = function () {
233                                 const shouldHide = spec.hide ? test.hide() : false;
234                                 const shouldDisable = shouldHide || ( spec.disable ? test.disable() : false );
235                                 if ( spec.hide ) {
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' );
239                                         } else {
240                                                 $elOrLayout.$element.removeClass( 'mw-htmlform-hide-if-hidden' );
241                                         }
242                                         // The .toggle() method works mostly the same for jQuery objects and OO.ui.Widget
243                                         $elOrLayout.toggle( !shouldHide );
244                                 }
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' ) );
262                                                         }
263                                                         $this.prop( 'disabled', true );
264                                                 } else {
265                                                         $this.prop( 'disabled', $this.data( 'was-disabled' ) );
266                                                 }
267                                         } );
268                                 } else {
269                                         // $elOrLayout is a OO.ui.FieldLayout
270                                         if ( shouldDisable ) {
271                                                 if ( $elOrLayout.wasDisabled === undefined ) {
272                                                         $elOrLayout.wasDisabled = $elOrLayout.fieldWidget.isDisabled();
273                                                 }
274                                                 $elOrLayout.fieldWidget.setDisabled( true );
275                                         } else if ( $elOrLayout.wasDisabled !== undefined ) {
276                                                 $elOrLayout.fieldWidget.setDisabled( $elOrLayout.wasDisabled );
277                                         }
278                                 }
279                         };
281                         const oouiNodes = fields.map(
282                                 // We expect undefined for non-OOUI nodes (T308626)
283                                 ( $node ) => $node.closest( '.oo-ui-fieldLayout[data-ooui]' )[ 0 ]
284                         ).filter(
285                                 // Remove undefined
286                                 ( node ) => !!node
287                         );
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 ] );
293                                         if ( widget ) {
294                                                 fields[ i ] = widget;
295                                         }
296                                         // The .on() method works mostly the same for jQuery objects and OO.ui.Widget
297                                         fields[ i ].on( 'change', func );
298                                 }
299                                 func();
300                         } );
301                 } );
302         } );
303 } );