From b6266f518b02d84091158bcd63aaa156687ce522 Mon Sep 17 00:00:00 2001 From: 66Ton99 <66ton99@gmail.com> Date: Sat, 22 Aug 2026 19:58:15 +0300 Subject: [PATCH] feat(js): dispatch validation lifecycle events showErrors is called both when previous errors are cleared and when new ones are rendered, so it cannot tell a started validation run from a finished one, and it marks a field as valid while an asynchronous constraint is still waiting for its answer. Dispatch native CustomEvent objects instead: svaroh:validating, svaroh:success and svaroh:failure on the element DOM node, and svaroh:form-validating, svaroh:form-success and svaroh:form-failure on the form DOM node. The success and failure events wait for the ajax queue to drain so they describe the final result of the run. Queued ajax callbacks are now taken off the queue before they run, they were kept and replayed on every following drain. Fixes formapro/JsFormValidatorBundle#60 --- README.md | 1 + src/Resources/doc/3_11.md | 4 + src/Resources/doc/3_12.md | 6 + src/Resources/doc/3_2.md | 5 + src/Resources/doc/3_21.md | 98 ++++++++ .../public/js/SvarohJsFormValidator.js | 104 +++++++- .../public/js/SvarohJsFormValidator.test.js | 237 ++++++++++++++++++ 7 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 src/Resources/doc/3_21.md diff --git a/README.md b/README.md index 33e0d258..05f8b8c7 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ If your form rendering is customized, start with 18. [The Range constraint on dates](src/Resources/doc/3_18.md) 19. [Repeated fields](src/Resources/doc/3_19.md) 20. [One form rendered several times](src/Resources/doc/3_20.md) +21. [Validation events](src/Resources/doc/3_21.md) ## Development diff --git a/src/Resources/doc/3_11.md b/src/Resources/doc/3_11.md index 42636f02..3fa3bab1 100644 --- a/src/Resources/doc/3_11.md +++ b/src/Resources/doc/3_11.md @@ -36,3 +36,7 @@ SvarohJsFormValidator.customize(field, { ``` `onValidate` should be defined on the parent form element. + +`onValidate` runs on submission only. To follow every validation run, including +the ones triggered by your own code, listen to +[the validation events](3_21.md). diff --git a/src/Resources/doc/3_12.md b/src/Resources/doc/3_12.md index 3dba60cf..a74d2349 100644 --- a/src/Resources/doc/3_12.md +++ b/src/Resources/doc/3_12.md @@ -3,6 +3,12 @@ This example validates text fields on blur and adds marker classes instead of rendering error messages: +`showErrors` is also called when the previous errors are cleared, so the `ready` +class below appears before the field has really been checked, and while an +asynchronous constraint such as `UniqueEntity` is still waiting for its answer. +[Validation events](3_21.md) report the start and the final result of a +validation run separately and avoid that. + ```css input[type=text].error, textarea.error { diff --git a/src/Resources/doc/3_2.md b/src/Resources/doc/3_2.md index 6ada86e8..eecf1fc1 100644 --- a/src/Resources/doc/3_2.md +++ b/src/Resources/doc/3_2.md @@ -10,6 +10,11 @@ field's own errors and errors coming from other sources. For example, By default, `sourceId` is used as a class name on error `
  • ` elements so those errors can be removed independently. +`showErrors` reports an error list, not the state of a validation run: it is +also called with an empty list when previous errors are cleared. Use +[the validation events](3_21.md) to know when validation starts and how it +ends. + #### Error path mapping Custom constraints may return `SvarohJsFormError` objects when an error should be diff --git a/src/Resources/doc/3_21.md b/src/Resources/doc/3_21.md new file mode 100644 index 00000000..cf81af21 --- /dev/null +++ b/src/Resources/doc/3_21.md @@ -0,0 +1,98 @@ +### 3.21 Validation events + +`showErrors` is called every time an error list changes, including when the +previous errors are cleared at the beginning of a validation run. It cannot tell +you whether validation has just started or has finished. Validation events can: +they are dispatched as native `CustomEvent` objects on the DOM node of the +element, so `addEventListener` and jQuery `on()` both receive them, and any +number of listeners can be attached without replacing `showErrors` or +`onValidate`. + +| Event | Level | Dispatched on | When | +|-------------------------|---------|-----------------------|-----------------------------------------| +| `svaroh:validating` | element | the field DOM node | validation of the element starts | +| `svaroh:success` | element | the field DOM node | the element ends up without errors | +| `svaroh:failure` | element | the field DOM node | the element ends up with errors | +| `svaroh:form-validating`| form | the form DOM node | validation of the whole form starts | +| `svaroh:form-success` | form | the form DOM node | the form and all its children are valid | +| `svaroh:form-failure` | form | the form DOM node | the form or one of its children failed | + +Element level events are fired for every element that is validated, form level +events only once per run of `validateRecursively()` on a root form element, +which is what a form submission triggers. As the root element is validated as +well, its own element level events are dispatched on the form DOM node too; +the distinct names keep the two levels apart. + +Element level events bubble, so they can be delegated on the form. Form level +events are dispatched directly on the form. + +Each event carries a `detail` object: + +- `detail.element` is the `SvarohJsFormElement` being validated. +- `detail.errors` of an element level event is a flat list of messages, all + sources merged, like the first argument of `showErrors`. +- `detail.errors` of a form level event is an object keyed by element id, like + the first argument of `onValidate`. + +```js +document.getElementById('user_email').addEventListener('svaroh:failure', function (event) { + console.log(event.detail.errors); +}); + +document.getElementById('user').addEventListener('svaroh:form-success', function (event) { + // The whole form is valid. +}); +``` + +jQuery plugin users listen to the same events: + +```js +$('#user').on('svaroh:form-failure', function (event) { + console.log(event.detail.errors); +}); +``` + +jQuery copies `detail` onto its own event object, so no bridge is needed. +`event.originalEvent.detail` holds the same object. + +#### Asynchronous constraints + +`UniqueEntity` and any other constraint that answers from the server report +their errors when their response arrives, not when `validate()` returns. The +`success` and `failure` events wait for the request queue to be empty, so they +always describe the final result. That is what `showErrors` alone cannot +express: it is called with an empty list while the uniqueness check is still +running, which marks the field as valid too early. + +This is the reliable version of the marker classes of +[Run validation on a custom event](3_12.md): + +```js +$('form') + .find('input[type=text], textarea') + .on('svaroh:validating', function () { + $(this).removeClass('error ready').addClass('validating'); + }) + .on('svaroh:success', function () { + $(this).removeClass('validating error').addClass('ready'); + }) + .on('svaroh:failure', function () { + $(this).removeClass('validating ready').addClass('error'); + }) + .blur(function () { + $(this).jsFormValidator('validate'); + }); +``` + +A disabled element is not validated and fires no event. + +#### Event names + +The `svaroh:` prefix can be changed if it collides with your own events: + +```js +SvarohJsFormValidator.eventPrefix = 'app-validation:'; +``` + +Set it before any validation runs, see +[Custom initialization](2_3.md). diff --git a/src/Resources/public/js/SvarohJsFormValidator.js b/src/Resources/public/js/SvarohJsFormValidator.js index 39e2a94b..0a69fce8 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.js +++ b/src/Resources/public/js/SvarohJsFormValidator.js @@ -67,6 +67,8 @@ export function SvarohJsFormElement() { return true; } + SvarohJsFormValidator.dispatchValidationEvent(self, 'validating', []); + // The browser is asked first, while its own diagnosis is still // readable: the message of this element is written back into the // "customError" state the browser would otherwise answer with @@ -131,10 +133,29 @@ export function SvarohJsFormElement() { SvarohJsFormValidator.syncNativeValidity(target, sourceId); } + // Constraints like UniqueEntity report their errors from an ajax + // response, so the outcome is only known once the queue has drained + SvarohJsFormValidator.onAjaxIdle(function () { + var errors = SvarohJsFormValidator.getElementErrors(self); + SvarohJsFormValidator.dispatchValidationEvent( + self, + errors.length ? 'failure' : 'success', + errors + ); + }); + return validationErrors.length === 0; }; this.validateRecursively = function () { + var self = this; + // Only the root element stands for the whole form + var isRoot = !this.parent; + + if (isRoot) { + SvarohJsFormValidator.dispatchValidationEvent(self, 'form-validating', {}); + } + var isValid = this.validate(); // Every child is validated, even after a failure, so that all the @@ -145,6 +166,16 @@ export function SvarohJsFormElement() { } } + if (isRoot) { + SvarohJsFormValidator.onAjaxIdle(function () { + SvarohJsFormValidator.dispatchValidationEvent( + self, + self.isValid() ? 'form-success' : 'form-failure', + SvarohJsFormValidator.getAllErrors(self, {}) + ); + }); + } + return isValid; }; @@ -281,10 +312,11 @@ function SvarohJsAjaxRequest() { } // Every callback waits for one drain of the queue; keeping them around - // would run the submit of a previous form again on the next drain + // would run the submit of a previous form again on the next drain, and + // a callback may register a new one of its own var callbacks = this.callbacks; this.callbacks = []; - for (var i in callbacks) { + for (var i = 0; i < callbacks.length; i++) { callbacks[i](); } }; @@ -559,6 +591,7 @@ var SvarohJsFormValidator = new function () { this.forms = {}; this.formInstances = {}; this.errorClass = 'form-errors'; + this.eventPrefix = 'svaroh:'; this.config = {}; this.ajax = new SvarohJsAjaxRequest(); this.customizeMethods = new SvarohJsCustomizeMethods(); @@ -1612,6 +1645,73 @@ var SvarohJsFormValidator = new function () { return container; }; + /** + * Returns a flat list of all the messages of an element, whatever their + * source is + * + * @param {SvarohJsFormElement} element + * + * @returns {Array} + */ + this.getElementErrors = function (element) { + var errors = []; + for (var sourceId in element.errors) { + errors = errors.concat(element.errors[sourceId]); + } + + return errors; + }; + + /** + * Runs a callback as soon as no validation request is pending anymore. + * Asynchronous constraints only report their errors when their response + * arrives, so an empty error list means nothing while the queue is busy. + * + * @param {Function} callback + */ + this.onAjaxIdle = function (callback) { + if (this.ajax.queue > 0) { + this.ajax.callbacks.push(callback); + } else { + callback(); + } + }; + + /** + * Dispatches a validation event on the DOM node of an element + * + * @param {SvarohJsFormElement} element + * @param {String} name + * @param {Array|Object} errors + * + * @returns {Event|null} + */ + this.dispatchValidationEvent = function (element, name, errors) { + var domNode = element.domNode; + if (!domNode || typeof domNode.dispatchEvent !== 'function') { + return null; + } + + var eventName = this.eventPrefix + name; + var detail = {element: element, errors: errors}; + var event; + if (typeof window.CustomEvent === 'function') { + event = new window.CustomEvent(eventName, { + bubbles: true, + cancelable: false, + detail: detail + }); + } else { + //IE9+ + event = document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, true, false, detail); + } + + domNode.dispatchEvent(event); + + return event; + }; + /** * Replace patterns with real values for the specified prototype * diff --git a/src/Resources/public/js/SvarohJsFormValidator.test.js b/src/Resources/public/js/SvarohJsFormValidator.test.js index 3f56796c..0acb5eeb 100644 --- a/src/Resources/public/js/SvarohJsFormValidator.test.js +++ b/src/Resources/public/js/SvarohJsFormValidator.test.js @@ -1609,3 +1609,240 @@ describe('SvarohJsFormValidator repeated forms', () => { expect(forms[0].jsFormValidator).toBe(instances[0]); }); }); + +describe('SvarohJsFormValidator validation events', () => { + afterEach(() => { + document.body.innerHTML = ''; + window.SvarohJsFormValidator.config = {}; + window.SvarohJsFormValidator.ajax.queue = 0; + window.SvarohJsFormValidator.ajax.callbacks = []; + jest.restoreAllMocks(); + }); + + function createForm() { + const form = document.createElement('form'); + document.body.appendChild(form); + + return form; + } + + function createElement(id, domNode) { + const element = new window.SvarohJsFormElement(); + element.id = id; + element.name = id; + element.domNode = domNode; + window.SvarohJsFormValidator.attachElement(element); + + return element; + } + + function createInput(form, id) { + const input = document.createElement('input'); + input.id = id; + form.appendChild(input); + + return input; + } + + function addConstraint(element, validate) { + element.data.form = { + constraints: [{ + groups: ['Default'], + validate, + }], + getters: {}, + groups: ['Default'], + }; + } + + function record(domNode, names) { + const fired = []; + for (const name of names) { + domNode.addEventListener('svaroh:' + name, function (event) { + fired.push({ name, detail: event.detail, target: event.target }); + }); + } + + return fired; + } + + test('fires validating and success on the element node of a valid element', () => { + const form = createForm(); + const element = createElement('user_email', createInput(form, 'user_email')); + const fired = record(element.domNode, ['validating', 'success', 'failure']); + + expect(element.validate()).toBe(true); + + expect(fired.map((item) => item.name)).toEqual(['validating', 'success']); + expect(fired[1].detail.element).toBe(element); + expect(fired[1].detail.errors).toEqual([]); + }); + + test('fires failure with the messages of every error source', () => { + const form = createForm(); + const element = createElement('user_email', createInput(form, 'user_email')); + addConstraint(element, () => ['Invalid email.']); + element.errors['unique-entity-1'] = ['This value is already used.']; + const fired = record(element.domNode, ['validating', 'success', 'failure']); + + expect(element.validate()).toBe(false); + + expect(fired.map((item) => item.name)).toEqual(['validating', 'failure']); + expect(fired[1].detail.errors).toEqual(['This value is already used.', 'Invalid email.']); + }); + + test('fires no event for a disabled element', () => { + const form = createForm(); + const element = createElement('user_email', createInput(form, 'user_email')); + element.disabled = true; + const fired = record(element.domNode, ['validating', 'success', 'failure']); + + expect(element.validate()).toBe(true); + + expect(fired).toEqual([]); + }); + + test('bubbles element events up to the form node', () => { + const form = createForm(); + const root = createElement('user', form); + const email = createElement('user_email', createInput(form, 'user_email')); + root.children.email = email; + email.parent = root; + const fired = record(form, ['failure']); + addConstraint(email, () => ['Invalid email.']); + + email.validate(); + + expect(fired).toHaveLength(1); + expect(fired[0].target).toBe(email.domNode); + expect(fired[0].detail.element).toBe(email); + }); + + test('fires form level events on the form node when the root element is validated', () => { + const form = createForm(); + const root = createElement('user', form); + const email = createElement('user_email', createInput(form, 'user_email')); + root.children.email = email; + email.parent = root; + addConstraint(email, () => ['Invalid email.']); + const fired = record(form, ['form-validating', 'form-success', 'form-failure']); + + root.validateRecursively(); + + expect(fired.map((item) => item.name)).toEqual(['form-validating', 'form-failure']); + expect(fired[1].detail.element).toBe(root); + expect(fired[1].detail.errors).toEqual({ + user_email: { + 'form-error-user': [], + 'form-error-user-email': ['Invalid email.'], + }, + }); + }); + + test('fires form-success when the whole form is valid', () => { + const form = createForm(); + const root = createElement('user', form); + const email = createElement('user_email', createInput(form, 'user_email')); + root.children.email = email; + email.parent = root; + const fired = record(form, ['form-validating', 'form-success', 'form-failure']); + + root.validateRecursively(); + + expect(fired.map((item) => item.name)).toEqual(['form-validating', 'form-success']); + expect(fired[1].detail.errors).toEqual({}); + }); + + test('fires no form level event for a child element', () => { + const form = createForm(); + const root = createElement('user', form); + const email = createElement('user_email', createInput(form, 'user_email')); + root.children.email = email; + email.parent = root; + const fired = record(form, ['form-validating', 'form-success', 'form-failure']); + + email.validateRecursively(); + + expect(fired).toEqual([]); + }); + + test('waits for the ajax queue to drain before reporting a UniqueEntity failure', () => { + const form = createForm(); + const root = createElement('user', form); + const email = createElement('user_email', createInput(form, 'user_email')); + root.children.email = email; + email.parent = root; + email.domNode.value = 'john@example.com'; + + const constraint = new window.SvarohJsFormValidatorBundleFormConstraintUniqueEntity(); + constraint.fields = ['email']; + constraint.groups = ['Default']; + constraint.uniqueId = 7; + root.data.entity = { + constraints: [constraint], + getters: {}, + groups: ['Default'], + }; + window.SvarohJsFormValidator.config = { + routing: { check_unique_entity: '/check_unique_entity' }, + }; + + const request = { + open: jest.fn(), + setRequestHeader: jest.fn(), + send: jest.fn(), + readyState: 0, + status: 0, + responseText: '', + }; + jest.spyOn(window.SvarohJsFormValidator.ajax, 'createRequest').mockImplementation(() => request); + + const firedOnField = record(email.domNode, ['validating', 'success', 'failure']); + const firedOnForm = record(form, ['form-validating', 'form-success', 'form-failure']); + + root.validateRecursively(); + + // The uniqueness answer is still on its way, so nothing is decided yet + expect(window.SvarohJsFormValidator.ajax.queue).toBe(1); + expect(firedOnField.map((item) => item.name)).toEqual(['validating']); + expect(firedOnForm.map((item) => item.name)).toEqual(['form-validating']); + + request.readyState = 4; + request.status = 200; + request.responseText = 'false'; + request.onreadystatechange(); + + expect(firedOnField.map((item) => item.name)).toEqual(['validating', 'failure']); + expect(firedOnField[1].detail.errors).toEqual(['This value is already used.']); + expect(firedOnForm.map((item) => item.name)).toEqual(['form-validating', 'form-failure']); + expect(firedOnForm[1].detail.errors).toEqual({ + user_email: { + 'form-error-user': [], + 'form-error-user-email': [], + 'unique-entity-7': ['This value is already used.'], + }, + }); + }); + + test('runs a queued ajax callback once only', () => { + const ajax = window.SvarohJsFormValidator.ajax; + const callback = jest.fn(); + ajax.queue = 0; + ajax.callbacks = [callback]; + + ajax.checkQueue(); + ajax.checkQueue(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(ajax.callbacks).toEqual([]); + }); + + test('runs an onAjaxIdle callback at once when no request is pending', () => { + const callback = jest.fn(); + + window.SvarohJsFormValidator.onAjaxIdle(callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(window.SvarohJsFormValidator.ajax.callbacks).toEqual([]); + }); +});