rilwis/meta-box

View on GitHub
js/validation/validation.js

Summary

Maintainability
A
30 mins
Test Coverage
( function ( $, rwmb, i18n ) {
    'use strict';

    /**
     * Extract the validation key from an input's name attribute. Usually it's the field ID, but sometimes (like for `file`), it's the field's input name.
     *
     * field[]    => field   // Fields with multiple values: file, checkbox list, etc.
     * field[1]   => field   // Cloneable fields
     * field[1][] => field   // Cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[field][]    => field  // Group with fields with multiple values: file, checkbox list, etc.
     * group[field][1]   => field  // Group with cloneable fields
     * group[field][1][] => field  // Group with cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[1][field][]    => field  // Cloneable group with fields with multiple values: file, checkbox list, etc.
     * group[1][field][1]   => field  // Cloneable group with cloneable fields
     * group[1][field][1][] => field  // Cloneable group with cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[subgroup][field][]    => field  // Subgroup with fields with multiple values: file, checkbox list, etc.
     * group[subgroup][field][1]   => field  // Subgroup with cloneable fields
     * group[subgroup][field][1][] => field  // Subgroup with cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[subgroup][1][field][]    => field  // Cloneable subgroup with fields with multiple values: file, checkbox list, etc.
     * group[subgroup][1][field][1]   => field  // Cloneable subgroup with cloneable fields
     * group[subgroup][1][field][1][] => field  // Cloneable subgroup with cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[1][subgroup][field][]    => field  // Cloneable group with subgroup with fields with multiple values: file, checkbox list, etc.
     * group[1][subgroup][field][1]   => field  // Cloneable group with subgroup with cloneable fields
     * group[1][subgroup][field][1][] => field  // Cloneable group with subgroup with cloneable fields with multiple values: file, checkbox list, etc.
     *
     * group[1][subgroup][1][field][]    => field  // Cloneable group with cloneable subgroup with fields with multiple values: file, checkbox list, etc.
     * group[1][subgroup][1][field][1]   => field  // Cloneable group with cloneable subgroup with cloneable fields
     * group[1][subgroup][1][field][1][] => field  // Cloneable group with cloneable subgroup with cloneable fields with multiple values: file, checkbox list, etc.
     */
    const getValidationKey = name => {
        // Detect name parts in format of anything[] or anything[1].
        let parts = name.match( /^(.+?)(?:\[\d+\]|(?:\[\]))?$/ );

        if ( parts[ 1 ] && isNaN( parts[ 1 ] ) ) {
            // Remove []
            let words = name.match( /(\w+)|(\[\w+\])/g );
            let resultArray = [ words.join( "" ) ];

            // Remove characters "[" and "]".
            words.forEach( matchedValue => {
                if ( matchedValue.startsWith( "[" ) ) {
                    resultArray.push( matchedValue.substring( 1, matchedValue.length - 1 ) );
                } else {
                    resultArray.push( matchedValue );
                }
            } );

            parts[ 0 ] = resultArray[ 0 ];
            parts[ 1 ] = isNaN( resultArray[ resultArray.length - 1 ] ) ? resultArray[ resultArray.length - 1 ] : resultArray[ resultArray.length - 2 ];
        }

        return parts.pop();
    };

    /**
     * Fix validation not working for cloneable files or fields in groups.
     */
    $.validator.staticRules = function ( element ) {
        let rules = {},
            validator = $.data( element.form, "validator" );

        // No rules.
        if ( validator.settings.rules === null || Object.keys( validator.settings.rules ).length === 0 ) {
            return rules;
        }

        // Do not validate hidden fields.
        if ( element.type === 'hidden' ) {
            return rules;
        }

        let key = getValidationKey( element.name );

        /**
         * Cloneable files or files in groups.
         * Input name is transformed into format `_file_{unique_id}`
         * There is also a hidden input with name `_index_{field_id}` with value `_file_{unique_id}`
         *
         * In this case, `key` is always `_file_{unique_id}`
         *
         * Note that for cloneable files, validation rule is set for `_index_{field_id}`. For files in groups, validation rule is still `{field_id}`.
         */
        if ( element.type === 'file' && ( $( element ).closest( '.rwmb-clone' ).length > 0 || $( element ).closest( '.rwmb-group-wrapper' ).length > 0 ) ) {
            const $input = $( element ).closest( '.rwmb-input' );
            const $indexInput = $input.find( '*[value="' + key + '"]' );

            key = getValidationKey( $indexInput.attr( 'name' ) );

            // Remove prefix `_index_` from input name when in groups.
            if ( !validator.settings.rules[ key ] && key.includes( '_index_' ) ) {
                key = key.slice( 7 );
            }

            if ( validator.settings.rules[ key ] ) {
                // Set message for element.
                validator.settings.messages[ element.name ] = validator.settings.messages[ key ];
                // Set rule for element.
                return $.validator.normalizeRule( validator.settings.rules[ key ] ) || {};
            }

            return rules;
        }

        // For normal fields and fields in groups: set rules by their field IDs (validation keys).

        // Set message for element.
        validator.settings.messages[ element.name ] = validator.settings.messages[ key ];
        // Set rule for element.
        return $.validator.normalizeRule( validator.settings.rules[ key ] ) || {};
    };

    /**
     * Make jQuery Validation works with multiple inputs with same names.
     * Need for file, image fields where users can upload multiple files with same input names.
     *
     * @link https://stackoverflow.com/q/931687/371240
     */
    $.validator.prototype.checkForm = function () {
        this.prepareForm();
        for ( var i = 0, elements = ( this.currentElements = this.elements() ); elements[ i ]; i++ ) {
            if ( this.findByName( elements[ i ].name ).length !== undefined && this.findByName( elements[ i ].name ).length > 1 ) {
                for ( var cnt = 0; cnt < this.findByName( elements[ i ].name ).length; cnt++ ) {
                    this.check( this.findByName( elements[ i ].name )[ cnt ] );
                }
            } else {
                this.check( elements[ i ] );
            }
        }
        return this.valid();
    };

    class Validation {
        constructor( formSelector ) {
            this.$form = $( formSelector );
            this.validationElements = this.$form.find( '.rwmb-validation' );
            this.showAsterisks();
            this.getSettings();
        }

        init() {
            this.$form
                // Update underlying textarea before submit.
                // Don't use submitHandler() because form can be submitted via Ajax on the front end.
                .on( 'submit', function () {
                    if ( typeof tinyMCE !== 'undefined' ) {
                        tinyMCE.triggerSave();
                    }
                } )
                .validate( this.settings );
        }

        showAsterisks() {
            this.validationElements.each( function () {
                const data = $( this ).data( 'validation' );

                $.each( data.rules, function ( k, v ) {
                    if ( !v[ 'required' ] ) {
                        return;
                    }
                    let $el = $( '[name="' + k + '"]' );
                    if ( !$el.length ) {
                        $el = $( '[name*="[' + k + ']"]' ); // Subfields in groups.
                    }
                    if ( $el.length ) {
                        $el.closest( '.rwmb-input' ).siblings( '.rwmb-label' ).find( 'label' ).append( '<span class="rwmb-required">*</span>' );
                    }
                } );
            } );
        }

        getSettings() {
            this.settings = {
                ignore: ':not(.rwmb-media,.rwmb-image_select,.rwmb-wysiwyg,.rwmb-color,.rwmb-map,.rwmb-osm,.rwmb-switch,[class|="rwmb"])',
                errorPlacement: function ( error, element ) {
                    error.appendTo( element.closest( '.rwmb-input' ) );
                },
                errorClass: 'rwmb-error',
                errorElement: 'p',
                invalidHandler: this.invalidHandler.bind( this )
            };

            // Gather all validation rules.
            var that = this;
            this.validationElements.each( function () {
                $.extend( true, that.settings, $( this ).data( 'validation' ) );
            } );
        }

        invalidHandler() {
            this.showMessage();
            // Group field will automatically expand and show an error warning when collapsing
            for ( var i = 0; i < this.$form.data( 'validator' ).errorList.length; i++ ) {
                $( '#' + this.$form.data( 'validator' ).errorList[ i ].element.id ).closest( '.rwmb-group-collapsed' ).removeClass( 'rwmb-group-collapsed' );
            }
            // Custom event for showing error fields inside tabs/hidden divs. Use setTimeout() to run after error class is added to inputs.
            var that = this;
            setTimeout( function () {
                that.$form.trigger( 'after_validate' );
            }, 200 );
        }

        showMessage() {
            // Re-enable the submit ( publish/update ) button and hide the ajax indicator
            $( '#publish' ).removeClass( 'button-primary-disabled' );
            $( '#ajax-loading' ).attr( 'style', '' );
            $( '#rwmb-validation-message' ).remove();
            this.$form.before( '<div id="rwmb-validation-message" class="notice notice-error is-dismissible"><p>' + i18n.message + '</p></div>' );
        }
    };

    class GutenbergValidation extends Validation {
        init() {
            var that = this,
                editor = wp.data.dispatch( 'core/editor' );
            
            if ( ! editor ) {
                return false;
            }

            const savePost = editor.savePost; // Reference original method.

            if ( that.settings ) {
                that.$form.validate( that.settings );
            }

            // Change the editor method.
            editor.savePost = function ( options = {} ) {
                // Bypass the validation when previewing in Gutenberg.
                if ( typeof options === 'object' && options.isPreview ) {
                    return savePost( options );
                }

                // Must call savePost() here instead of in submitHandler() because the form has inline onsubmit callback.
                if ( that.$form.valid() ) {
                    return savePost( options );
                }
            };
        }

        showMessage() {
            wp.data.dispatch( 'core/notices' ).createErrorNotice( i18n.message, {
                id: 'meta-box-validation',
                isDismissible: true
            } );
        }
    };

    // Run on document ready.
    function init() {
        if ( rwmb.isGutenberg ) {
            var advanced = new GutenbergValidation( '.metabox-location-advanced' ),
                normal = new GutenbergValidation( '.metabox-location-normal' ),
                side = new GutenbergValidation( '.metabox-location-side' );

            side.init();
            normal.init();
            advanced.init();
            return;
        }

        // Edit post, edit term, edit user, front-end form.
        var $forms = $( '#post, #edittag, #your-profile, .rwmb-form' );
        $forms.each( function () {
            var form = new Validation( this );
            form.init();
        } );
    };

    rwmb.$document
        .on( 'mb_ready', init );

} )( jQuery, rwmb, rwmbValidation );