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([]);
+ });
+});