diff --git a/README.md b/README.md index 33e0d25..05f8b8c 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 42636f0..3fa3bab 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 3dba60c..a74d234 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 6ada86e..eecf1fc 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 0000000..cf81af2 --- /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 39e2a94..0a69fce 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 3f56796..0acb5ee 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([]); + }); +});