/** * Custom Ninja Forms Field Validation * * This controller is used to for client-side custom (asynchronous) sanitization/validation * of user-submitted data. */ ( function ( $, Backbone, Marionette, undefined ) { 'use strict'; const DEBUG_CONTROLLER = false; const DEBUG_BLOCK_LAST_ACTION = false; /** * @var {string} VALIDATING_FIELD - The Ninja Forms error code for a field in progress of validation. * @var {string} INVALID_FIELD - The Ninja Forms error code for a validated field. */ const VALIDATING_FIELD = 'custom_validation_validating'; const INVALID_FIELD = 'custom_validation_field_invalid'; const nfRadio = Backbone.Radio; const formChannel = nfRadio.channel( 'form' ); const fieldsChannel = nfRadio.channel( 'fields' ); const submitChannel = nfRadio.channel( 'submit' ); /** * Manages the validation state of a form. * * @typedef {Object} FormState * * @property {?FormModel} model - The Ninja Forms form model. * @property {?string} async - The name of the validation method * in the process of asynchronous validating a field value. * @property {?string} busy - The name of the validation method * in the process of (synchronous) validating a field value. * @property {?function} lastAction - A callback to retry the last form * action attempted, either form submission or multi-part breadcrumb * and pagination navigation. */ class FormState { #model = null; #async = null; #busy = null; #lastAction = null; /** * @param {FormModel} model */ constructor( model ) { if ( ! ( model instanceof Backbone.Model ) || ! model.has( 'formContentData' ) ) { throw new TypeError( 'Form Validation expected model to be a Ninja Forms FormModel' ); } this.#model = model; } get hasAction() { return ( this.#lastAction != null ); } get isAsync() { return ( this.#async != null ); } get isBusy() { return ( this.#busy != null ); } get model() { return this.#model; } set lastAction( callback ) { if ( typeof callback !== 'function' ) { throw new TypeError( 'Form Validation expected action to be a function' ); } this.#lastAction = callback; } /** * If name matches, enable BUSY and maybe ASYNC. * * @param {string} name - The name of the busy method. * @param {?boolean} [isAsync] - Whether the busy method is async. */ maybeBusy( name, isAsync = null ) { if ( this.#busy != null && this.#busy !== name ) { return; } this.#busy = name; if ( typeof isAsync === 'boolean' ) { this.#async = isAsync ? name : null; } } /** * If name matches, disable BUSY and ASYNC. * * @param {string} name - The name of the busy method. * @param {boolean} [retry] - Whether to retry the last action. */ maybeIdle( name, retry = false ) { if ( this.#busy !== name ) { return; } if ( retry && typeof this.#lastAction === 'function' ) { if ( DEBUG_BLOCK_LAST_ACTION ) { DEBUG_CONTROLLER && console.log( 'Ignoring Last Action' ); } else { DEBUG_CONTROLLER && console.log( 'Attempting Last Action' ); this.#lastAction(); } this.#lastAction = null; } this.#async = null; this.#busy = null; } } /** * Stores the validation state of all forms. * * @typedef {Object} FieldValidationState */ const FieldValidationState = {}; const FieldValidationController = Marionette.Object.extend( { initialize: function () { DEBUG_CONTROLLER && console.group( 'FieldValidationController.initialize' ); this.listenTo( formChannel, 'render:view', this.onRenderView ); this.listenTo( fieldsChannel, 'change:modelValue', this.validateModelData ); this.listenTo( submitChannel, 'validate:field', this.validateModelData ); DEBUG_CONTROLLER && console.groupEnd(); }, /** * Prepares the listeners for each form. * * @param {MainLayoutView} mainLayoutView */ onRenderView: function ( mainLayoutView ) { DEBUG_CONTROLLER && console.group( 'FieldValidationController.onRenderView' ); DEBUG_CONTROLLER && console.log( 'MainLayoutView:', mainLayoutView ); if ( mainLayoutView?.model ) { const formModel = mainLayoutView.model; const formID = formModel.get( 'id' ); DEBUG_CONTROLLER && console.log( 'FormModel:', formModel ); FieldValidationState[ formID ] = new FormState( formModel ); this.listenTo( nfRadio.channel( `form-${formID}` ), 'before:submit', this.onNFBeforeSubmit ); if ( mainLayoutView?.$( '.nf-mp-body' )?.length ) { const formContentData = formModel.get( 'formContentData' ); if ( mainLayoutView?.$el?.length ) { mainLayoutView.$el .on( 'click', '.nf-breadcrumb', ( event ) => this.onNFMPClickPart( event, formModel ) ) .on( 'click', '.nf-next', ( event ) => this.onNFMPClickPart( event, formModel ) ) .on( 'click', '.nf-previous', ( event ) => this.onNFMPClickPart( event, formModel ) ); } } } DEBUG_CONTROLLER && console.groupEnd(); }, /** * @param {ninja-forms:FormModel} formModel - The Ninja Forms form model. */ onNFBeforeSubmit: function ( formModel ) { DEBUG_CONTROLLER && console.group( 'FieldValidationController.onNFBeforeSubmit' ); DEBUG_CONTROLLER && console.log( 'Form:', formModel ); const formID = formModel.get( 'id' ); const validitationState = formID && FieldValidationState[ formID ]; if ( validitationState && validitationState.isBusy && validitationState.isAsync ) { DEBUG_CONTROLLER && console.log( 'Interrupted Form Submit' ); validitationState.lastAction = () => nfRadio.channel( `form-${formID}` ).request( 'submit', formModel ); } DEBUG_CONTROLLER && console.groupEnd(); }, /** * @param {jQuery.Event} event - The jQuery click event. * @param {ninja-forms:FormModel} formModel - The Ninja Forms form model. */ onNFMPClickPart: function ( event, formModel ) { DEBUG_CONTROLLER && console.group( 'FieldValidationController.onNFMPClickPart' ); DEBUG_CONTROLLER && console.log( 'Event:', event ); DEBUG_CONTROLLER && console.log( 'Form:', formModel ); const formID = formModel.get( 'id' ); const validitationState = formID && FieldValidationState[ formID ]; if ( validitationState && validitationState.isBusy && validitationState.isAsync ) { const target = event.target; const formContentData = formModel.get( 'formContentData' ); if ( target?.classList && formContentData ) { if ( target.classList.contains( 'nf-breadcrumb' ) && 'index' in target.dataset ) { DEBUG_CONTROLLER && console.log( 'Interrupted Breadcrumb Part Change' ); validitationState.lastAction = () => formContentData.setElement( formContentData.getVisibleParts()[ target.dataset.index ] ); } else if ( target.classList.contains( 'nf-next' ) ) { DEBUG_CONTROLLER && console.log( 'Interrupted Next Part Change' ); validitationState.lastAction = () => formContentData.next(); } else if ( target.classList.contains( 'nf-previous' ) ) { DEBUG_CONTROLLER && console.log( 'Interrupted Previous Part Change' ); validitationState.lastAction = () => formContentData.previous(); } } } DEBUG_CONTROLLER && console.groupEnd(); }, /** * Validate field value. * * @param {mixed} value * @param {NFFieldModel} fieldModel */ validateField: function ( value, fieldModel ) { DEBUG_CONTROLLER && console.group( 'FieldValidationController.validateField' ); DEBUG_CONTROLLER && console.log( 'Field:', fieldModel ); const formID = fieldModel.get( 'formID' ); const validitationState = formID && FieldValidationState[ formID ]; if ( validitationState && validitationState.isBusy ) { DEBUG_CONTROLLER && console.log( `${fieldModel.get( 'label' )}:`, 'Skipping; Busy' ); DEBUG_CONTROLLER && console.groupEnd(); return; } const last_value = fieldModel.get( 'last_value' ); if ( last_value != null && value === last_value ) { DEBUG_CONTROLLER && console.log( `${fieldModel.get( 'label' )}:`, 'Skipping; Same Value' ); DEBUG_CONTROLLER && console.groupEnd(); return; } if ( value === '' && fieldModel.get( 'required' ) ) { fieldsChannel.request( 'remove:error', fieldModel.get( 'id' ), INVALID_FIELD ); DEBUG_CONTROLLER && console.log( `${fieldModel.get( 'label' )}:`, 'Skipping; Empty Value' ); DEBUG_CONTROLLER && console.groupEnd(); return; } fieldModel.set( 'last_value', value ); const validator = fieldModel.get( 'custom_validation' ); if ( validator ) { const validator_fn = `validate_${validator}_field`; if ( 'function' === typeof FieldValidators[ validator_fn ] ) { fieldsChannel.request( 'remove:error', fieldModel.get( 'id' ), INVALID_FIELD ); FieldValidators[ validator_fn ]( value, fieldModel ); DEBUG_CONTROLLER && console.groupEnd(); return; } } DEBUG_CONTROLLER && console.log( `${fieldModel.get( 'label' )}:`, 'Not Validatable' ); DEBUG_CONTROLLER && console.groupEnd(); }, /** * Validate field model data. * * @param {NFFieldModel} fieldModel */ validateModelData: function ( fieldModel ) { DEBUG_CONTROLLER && console.group( 'FieldValidationController.validateModelData' ); if ( ! fieldModel.get( 'custom_validation' ) || ! fieldModel.get( 'visible' ) || fieldModel.get( 'clean' ) ) { DEBUG_CONTROLLER && console.log( `${fieldModel.get( 'label' )}:`, 'Not Validatable' ); DEBUG_CONTROLLER && console.groupEnd(); return; } const value = fieldModel.get( 'value' ); this.validateField( value, fieldModel ); DEBUG_CONTROLLER && console.groupEnd(); }, } ); const FieldValidators = { /** * Returns an error message from the given error constant. * * @param {?list|object} [codes] - The error codes to retrieve. * If NULL, return all messages. * @param {object} formSettings - The form settings. * @return {object} - A plain object of messages keyed * by their error constant values. */ get_error_messages: function ( codes, formSettings ) { if ( codes == null ) { return {}; } if ( formSettings == null && typeof formSettings !== 'object' ) { return {}; } if ( codes == null ) { return formSettings; } const filtered = {}; if ( Array.isArray( codes ) ) { for ( const code of codes ) { if ( code in formSettings ) { filtered[ code ] = formSettings[ code ]; } } } else if ( typeof codes === 'object' ) { for ( const code in codes ) { const details = codes[ code ]; if ( null == details || false === details || ! ( code in formSettings ) ) { continue; } filtered[ code ] = this.format_message( messages[ code ], details ); } } return filtered; }, /** * Returns a map of error strings from the given error * constants. * * @param {string} code - The error code. * @param {object} details - The error details. * @return {string} - An error message. */ format_message: function ( message, details ) { if ( null == details || typeof details !== 'object' ) { return message; } for ( const pattern in details ) { let replacement = details[ pattern ]; if ( Array.isArray( replacement ) ) { replacement = replacement.join(); } message = message.replace( pattern, replacement ); } return message; } /** * Performs the expensive/remote validation. * * @async * @param {mixed} value * @param {NFFieldModel} fieldModel * @param {object} [options] * @return {Promise} */ validate_example_field: async function ( value, fieldModel, options = {} ) { DEBUG_CONTROLLER && console.group( 'FieldValidators.validate_example_field' ); const formID = fieldModel.get('formID'); const validitationState = formID && FieldValidationState[ formID ]; validitationState?.maybeBusy( 'validate_example_field', true ); const formSettings = validitationState?.model?.get('settings'); fieldsChannel.request( 'add:error', fieldModel.get('id'), VALIDATING_FIELD, formSettings[ VALIDATING_FIELD ] ); const results = {}; /** EXPENSIVE/REMOTE VALIDATION HERE */ const valid = await fetch( 'https://example.com/', { body: value } ); fieldsChannel.request( 'remove:error', fieldModel.get( 'id' ), VALIDATING_FIELD ); if ( valid ) { if ( DEBUG_CONTROLLER ) { if ( Object.keys( results ).length ) { console.log( 'Valid:', results ); } else { console.log( 'Valid' ); } DEBUG_CONTROLLER && console.groupEnd(); } validitationState?.maybeIdle( 'validate_example_field', valid ); return true; } const hasResults = ( Object.keys( results ).length > 0 ); if ( DEBUG_CONTROLLER ) { if ( hasResults ) { console.log( 'Invalid:', results ); } else { console.log( 'Invalid' ); } } const messages = this.get_error_messages( results, formSettings ); const message = ( Object.values( messages )[0] ?? formSettings[ INVALID_FIELD ] ); fieldsChannel.request( 'add:error', fieldModel.get('id'), INVALID_FIELD, message ); DEBUG_CONTROLLER && console.groupEnd(); validitationState?.maybeIdle( 'validate_example_field', valid ); return false; } }; if ( DEBUG_CONTROLLER ) { if ( DEBUG_BLOCK_LAST_ACTION ) { console.warn( 'Debugging Custom Form Validation; Disabled Last Action Retry' ); } else { console.warn( 'Debugging Custom Form Validation' ); } window.NFFieldValidationController = FieldValidationController; window.NFFieldValidationState = FieldValidationState; } $( function () { new FieldValidationController(); } ); } )( jQuery, Backbone, Marionette );