|
/** |
|
* 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<FormID, FormState>} 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<string>|object<string, mixed>} [codes] - The error codes to retrieve. |
|
* If NULL, return all messages. |
|
* @param {object<string, mixed>} formSettings - The form settings. |
|
* @return {object<string, string>} - 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<string, bool>} [options] |
|
* @return {Promise<boolean>} |
|
*/ |
|
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 ); |