Add AJAX form validation to Snowboard (#777)

Refs:
- https://wintercms.com/docs/snowboard/extras#ajax-validation
- Workshop Theme examples: 431e761c05
This commit is contained in:
Ben Thomson 2022-11-28 09:37:00 +08:00 committed by GitHub
parent 9b2282b280
commit 1ee713afae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 229 additions and 21 deletions

View File

@ -30,8 +30,6 @@ body>div.flash-message.error{background:#bb380c}
body>div.flash-message.warning{background:#b87410}
body>div.flash-message.info{background:#3f91cc}
@media (max-width:768px){body>div.flash-message{left:10px;right:10px;top:10px;margin-left:0;width:auto}}
[data-request][data-request-validate] [data-validate-for]:not(.visible),
[data-request][data-request-validate] [data-validate-error]:not(.visible){display:none}
a.wn-loading:after,
button.wn-loading:after,
span.wn-loading:after,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -503,12 +503,14 @@ class Request extends Snowboard.PluginBase {
if (error instanceof Error) {
this.processErrorMessage(error.message);
} else {
let skipError = false;
// Process validation errors
if (error.X_WINTER_ERROR_FIELDS) {
this.processValidationErrors(error.X_WINTER_ERROR_FIELDS);
skipError = this.processValidationErrors(error.X_WINTER_ERROR_FIELDS);
}
if (error.X_WINTER_ERROR_MESSAGE) {
if (error.X_WINTER_ERROR_MESSAGE && !skipError) {
this.processErrorMessage(error.X_WINTER_ERROR_MESSAGE);
}
}
@ -626,12 +628,16 @@ class Request extends Snowboard.PluginBase {
processValidationErrors(fields) {
if (typeof this.options.handleValidationErrors === 'function') {
if (this.options.handleValidationErrors.apply(this, [this.form, fields]) === false) {
return;
return true;
}
}
// Allow plugins to cancel the validation errors being handled
this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this);
if (this.snowboard.globalEvent('ajaxValidationErrors', this.form, fields, this) === false) {
return true;
}
return false;
}
/**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,213 @@
/**
* Adds AJAX-driven form validation to Snowboard requests.
*
* Documentation for this feature can be found here:
* https://wintercms.com/docs/snowboard/extras#ajax-validation
*
* @copyright 2022 Winter.
* @author Ben Thomson <git@alfreido.com>
*/
export default class FormValidation extends Snowboard.Singleton {
/**
* Constructor.
*/
construct() {
this.errorBags = [];
}
/**
* Defines listeners.
*
* @returns {Object}
*/
listens() {
return {
ready: 'ready',
ajaxStart: 'clearValidation',
ajaxValidationErrors: 'doValidation',
};
}
/**
* Ready event handler.
*/
ready() {
this.collectErrorBags(document);
}
/**
* Retrieves validation errors from an AJAX response and passes them through to the error bags.
*
* This handler returns false to cancel any further validation handling, and prevents the flash
* message that is displayed by default for field errors in AJAX requests from showing.
*
* @param {HTMLFormElement} form
* @param {Object} invalidFields
* @param {Request} request
* @returns {Boolean}
*/
doValidation(form, invalidFields, request) {
if (request.element.dataset.requestValidate === undefined) {
return null;
}
if (!form) {
return null;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === form);
errorBags.forEach((errorBag) => {
this.showErrorBag(errorBag, invalidFields);
});
return false;
}
/**
* Clears any validation errors in the given form.
*
* @param {Promise} promise
* @param {Request} request
* @returns {void}
*/
clearValidation(promise, request) {
if (request.element.dataset.requestValidate === undefined) {
return;
}
if (!request.form) {
return;
}
const errorBags = this.errorBags.filter((errorBag) => errorBag.form === request.form);
errorBags.forEach((errorBag) => {
this.hideErrorBag(errorBag);
});
}
/**
* Collects error bags (elements with "data-validate-error" attribute) and links them to a
* placeholder and form.
*
* The error bags will be initially hidden, and will only show when validation errors occur.
*
* @param {HTMLElement} rootNode
*/
collectErrorBags(rootNode) {
rootNode.querySelectorAll('[data-validate-error], [data-validate-for]').forEach((errorBag) => {
const form = errorBag.closest('form[data-request-validate]');
// If this error bag does not reside within a validating form, remove it
if (!form) {
errorBag.parentNode.removeChild(errorBag);
return;
}
// Find message list node, if available
let messageListElement = null;
if (errorBag.matches('[data-validate-error]')) {
messageListElement = errorBag.querySelector('[data-message]');
}
// Create a placeholder node
const placeholder = document.createComment('');
// Register error bag and replace with placeholder
const errorBagData = {
element: errorBag,
form,
validateFor: (errorBag.dataset.validateFor)
? errorBag.dataset.validateFor.split(/\s*,\s*/)
: '*',
placeholder,
messageListElement: (messageListElement)
? messageListElement.cloneNode(true)
: null,
messageListAnchor: null,
customMessage: (errorBag.dataset.validateFor)
? (errorBag.textContent !== '' || errorBag.childNodes.length > 0)
: false,
};
// If an message list element exists, create another placeholder to act as an anchor point
if (messageListElement) {
const messageListAnchor = document.createComment('');
messageListElement.parentNode.replaceChild(messageListAnchor, messageListElement);
errorBagData.messageListAnchor = messageListAnchor;
}
errorBag.parentNode.replaceChild(placeholder, errorBag);
this.errorBags.push(errorBagData);
});
}
/**
* Hides an error bag, replacing the error messages with a placeholder node.
*
* @param {Object} errorBag
*/
hideErrorBag(errorBag) {
if (errorBag.element.isConnected) {
errorBag.element.parentNode.replaceChild(errorBag.placeholder, errorBag.element);
}
}
/**
* Shows an error bag with the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
*/
showErrorBag(errorBag, invalidFields) {
if (!this.errorBagValidatesField(errorBag, invalidFields)) {
return;
}
if (!errorBag.element.isConnected) {
errorBag.placeholder.parentNode.replaceChild(errorBag.element, errorBag.placeholder);
}
if (errorBag.validateFor !== '*') {
if (!errorBag.customMessage) {
const firstField = Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.shift();
[errorBag.element.innerHTML] = invalidFields[firstField];
}
} else if (errorBag.messageListElement) {
// Remove previous error messages
errorBag.element.querySelectorAll('[data-validation-message]').forEach((message) => {
message.parentNode.removeChild(message);
});
Object.entries(invalidFields).forEach((entry) => {
const [, errors] = entry;
errors.forEach((error) => {
const messageElement = errorBag.messageListElement.cloneNode(true);
messageElement.dataset.validationMessage = '';
messageElement.innerHTML = error;
errorBag.messageListAnchor.after(messageElement);
});
});
} else {
[errorBag.element.innerHTML] = invalidFields[Object.keys(invalidFields).shift()];
}
}
/**
* Determines if a given error bag applies for the given invalid fields.
*
* @param {Object} errorBag
* @param {Object} invalidFields
* @returns {Boolean}
*/
errorBagValidatesField(errorBag, invalidFields) {
if (errorBag.validateFor === '*') {
return true;
}
return Object.keys(invalidFields)
.filter((field) => errorBag.validateFor.includes(field))
.length > 0;
}
}

View File

@ -1,5 +1,6 @@
import Flash from './extras/Flash';
import FlashListener from './extras/FlashListener';
import FormValidation from './extras/FormValidation';
import Transition from './extras/Transition';
import AttachLoading from './extras/AttachLoading';
import StripeLoader from './extras/StripeLoader';
@ -18,6 +19,7 @@ if (window.Snowboard === undefined) {
Snowboard.addPlugin('transition', Transition);
Snowboard.addPlugin('flash', Flash);
Snowboard.addPlugin('flashListener', FlashListener);
Snowboard.addPlugin('formValidation', FormValidation);
Snowboard.addPlugin('attachLoading', AttachLoading);
Snowboard.addPlugin('stripeLoader', StripeLoader);
})(window.Snowboard);

View File

@ -153,17 +153,6 @@ body > div.flash-message {
}
}
//
// Form Validation
// --------------------------------------------------
[data-request][data-request-validate] [data-validate-for],
[data-request][data-request-validate] [data-validate-error] {
&:not(.visible) {
display: none;
}
}
//
// Element Loader
// --------------------------------------------------