wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.htmlform/cond-state.js

Summary

Maintainability
D
1 day
Test Coverage
/*
 * HTMLForm enhancements:
 * Set up 'hide-if' and 'disable-if' behaviors for form fields that have them.
 */

/**
 * Helper function for conditional states to find the nearby form field.
 *
 * Find the closest match for the given name, "closest" being the minimum
 * level of parents to go to find a form field matching the given name or
 * ending in array keys matching the given name (e.g. "baz" matches
 * "foo[bar][baz]").
 *
 * @ignore
 * @private
 * @param {jQuery} $root
 * @param {string} name
 * @return {jQuery|null}
 */
function conditionGetField( $root, name ) {
    const nameFilter = function () {
        return this.name === name;
    };
    let $found = $root.find( '[name]' ).filter( nameFilter );
    if ( !$found.length ) {
        // Field cloner can load from template dynamically and fire event on sub element
        $found = $root.closest( 'form' ).find( '[name]' ).filter( nameFilter );
    }
    return $found.length ? $found : null;
}

/**
 * Helper function to get the OOUI widget containing the given field, if any.
 *
 * @ignore
 * @private
 * @param {jQuery} $field
 * @return {OO.ui.Widget|null}
 */
function getWidget( $field ) {
    const $widget = $field.closest( '.oo-ui-widget[data-ooui]' );
    if ( $widget.length ) {
        return OO.ui.Widget.static.infuse( $widget );
    }
    return null;
}

/**
 * Helper function for conditional states to return a test function and list of
 * dependent fields for a conditional states specification.
 *
 * @ignore
 * @private
 * @param {jQuery} $root
 * @param {Array} spec
 * @return {Array}
 * @return {Array} return.0 Dependent fields, array of jQuery objects
 * @return {Function} return.1 Test function
 */
function conditionParse( $root, spec ) {
    let v, fields, func;

    const op = spec[ 0 ];
    let l = spec.length;
    switch ( op ) {
        case 'AND':
        case 'OR':
        case 'NAND':
        case 'NOR': {
            const funcs = [];
            fields = [];
            for ( let i = 1; i < l; i++ ) {
                if ( !Array.isArray( spec[ i ] ) ) {
                    throw new Error( op + ' parameters must be arrays' );
                }
                v = conditionParse( $root, spec[ i ] );
                fields = fields.concat( v[ 0 ] );
                funcs.push( v[ 1 ] );
            }

            l = funcs.length;
            const valueChk = { AND: false, OR: true, NAND: false, NOR: true };
            const valueRet = { AND: true, OR: false, NAND: false, NOR: true };
            func = function () {
                for ( let j = 0; j < l; j++ ) {
                    if ( valueChk[ op ] === funcs[ j ]() ) {
                        return !valueRet[ op ];
                    }
                }
                return valueRet[ op ];
            };

            return [ fields, func ];
        }

        case 'NOT':
            if ( l !== 2 ) {
                throw new Error( 'NOT takes exactly one parameter' );
            }
            if ( !Array.isArray( spec[ 1 ] ) ) {
                throw new Error( 'NOT parameters must be arrays' );
            }
            v = conditionParse( $root, spec[ 1 ] );
            fields = v[ 0 ];
            func = v[ 1 ];
            return [ fields, function () {
                return !func();
            } ];

        case '===':
        case '!==': {
            if ( l !== 3 ) {
                throw new Error( op + ' takes exactly two parameters' );
            }
            const $field = conditionGetField( $root, spec[ 1 ] );
            if ( !$field ) {
                return [ [], function () {
                    return false;
                } ];
            }
            v = spec[ 2 ];

            let widget;
            const getVal = function () {
                // When the value is requested for the first time,
                // determine if we need to treat this field as a OOUI widget.
                if ( widget === undefined ) {
                    widget = getWidget( $field );
                }

                if ( widget ) {
                    if ( widget.supports( 'isSelected' ) ) {
                        const selected = widget.isSelected();
                        return selected ? widget.getValue() : '';
                    } else {
                        return widget.getValue();
                    }
                } else {
                    if ( $field.prop( 'type' ) === 'radio' || $field.prop( 'type' ) === 'checkbox' ) {
                        const $selected = $field.filter( ':checked' );
                        return $selected.length ? $selected.val() : '';
                    } else {
                        return $field.val();
                    }
                }
            };

            switch ( op ) {
                case '===':
                    func = function () {
                        return getVal() === v;
                    };
                    break;
                case '!==':
                    func = function () {
                        return getVal() !== v;
                    };
                    break;
            }

            return [ [ $field ], func ];
        }

        default:
            throw new Error( 'Unrecognized operation \'' + op + '\'' );
    }
}

/**
 * Helper function to get the list of ResourceLoader modules needed to infuse the OOUI widgets
 * containing the given fields.
 *
 * @ignore
 * @private
 * @param {jQuery} $fields
 * @return {string[]}
 */
function gatherOOUIModules( $fields ) {
    const $oouiFields = $fields.filter( '[data-ooui]' );
    const modules = [];

    if ( $oouiFields.length ) {
        modules.push( 'mediawiki.htmlform.ooui' );
        $oouiFields.each( function () {
            const data = $( this ).data( 'mw-modules' );
            if ( data ) {
                // We can trust this value, 'data-mw-*' attributes are banned from user content in Sanitizer
                const extraModules = data.split( ',' );
                modules.push( ...extraModules );
            }
        } );
    }

    return modules;
}

mw.hook( 'htmlform.enhance' ).add( ( $root ) => {
    const $exclude = $root.find( '.mw-htmlform-autoinfuse-lazy' )
        .find( '.mw-htmlform-hide-if, .mw-htmlform-disable-if' );
    const $fields = $root.find( '.mw-htmlform-hide-if, .mw-htmlform-disable-if' ).not( $exclude );

    // Load modules for the fields we will hide/disable
    mw.loader.using( gatherOOUIModules( $fields ) ).done( () => {
        $fields.each( function () {
            const $el = $( this );

            let spec, $elOrLayout, $form;
            if ( $el.is( '[data-ooui]' ) ) {
                // $elOrLayout should be a FieldLayout that mixes in mw.htmlform.Element
                $elOrLayout = OO.ui.FieldLayout.static.infuse( $el );
                $form = $elOrLayout.$element.closest( 'form' );
                spec = $elOrLayout.condState;
            } else {
                $elOrLayout = $el;
                $form = $el.closest( 'form' );
                spec = $el.data( 'condState' );
            }

            if ( !spec ) {
                return;
            }

            let fields = [];
            const test = {};
            [ 'hide', 'disable' ].forEach( ( type ) => {
                if ( spec[ type ] ) {
                    const v = conditionParse( $form, spec[ type ] );
                    fields = fields.concat( fields, v[ 0 ] );
                    test[ type ] = v[ 1 ];
                }
            } );
            const func = function () {
                const shouldHide = spec.hide ? test.hide() : false;
                const shouldDisable = shouldHide || ( spec.disable ? test.disable() : false );
                if ( spec.hide ) {
                    // The .toggle() method works mostly the same for jQuery objects and OO.ui.Widget
                    $elOrLayout.toggle( !shouldHide );
                }

                // Disable fields with either 'disable-if' or 'hide-if' rules
                // Hidden fields should be disabled to avoid users meet validation failure on these fields,
                // because disabled fields will not be submitted with the form.
                if ( $elOrLayout instanceof $ ) {
                    // This also finds elements inside any nested fields (in case of HTMLFormFieldCloner),
                    // which is problematic. But it works because:
                    // * HTMLFormFieldCloner::createFieldsForKey() copies '*-if' rules to nested fields
                    // * jQuery collections like $fields are in document order, so we register event
                    //   handlers for parents first
                    // * Event handlers are fired in the order they were registered, so even if the handler
                    //   for parent messed up the child, the handle for child will run next and fix it
                    $elOrLayout.find( 'input, textarea, select' ).each( function () {
                        const $this = $( this );
                        if ( shouldDisable ) {
                            if ( $this.data( 'was-disabled' ) === undefined ) {
                                $this.data( 'was-disabled', $this.prop( 'disabled' ) );
                            }
                            $this.prop( 'disabled', true );
                        } else {
                            $this.prop( 'disabled', $this.data( 'was-disabled' ) );
                        }
                    } );
                } else {
                    // $elOrLayout is a OO.ui.FieldLayout
                    if ( shouldDisable ) {
                        if ( $elOrLayout.wasDisabled === undefined ) {
                            $elOrLayout.wasDisabled = $elOrLayout.fieldWidget.isDisabled();
                        }
                        $elOrLayout.fieldWidget.setDisabled( true );
                    } else if ( $elOrLayout.wasDisabled !== undefined ) {
                        $elOrLayout.fieldWidget.setDisabled( $elOrLayout.wasDisabled );
                    }
                }
            };

            const oouiNodes = fields.map(
                // We expect undefined for non-OOUI nodes (T308626)
                ( $node ) => $node.closest( '.oo-ui-fieldLayout[data-ooui]' )[ 0 ]
            ).filter(
                // Remove undefined
                ( node ) => !!node
            );

            // Load modules for the fields whose state we will check
            mw.loader.using( gatherOOUIModules( $( oouiNodes ) ) ).done( () => {
                for ( let i = 0; i < fields.length; i++ ) {
                    const widget = getWidget( fields[ i ] );
                    if ( widget ) {
                        fields[ i ] = widget;
                    }
                    // The .on() method works mostly the same for jQuery objects and OO.ui.Widget
                    fields[ i ].on( 'change', func );
                }
                func();
            } );
        } );
    } );
} );