Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/Resources/doc/3_11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
6 changes: 6 additions & 0 deletions src/Resources/doc/3_12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/Resources/doc/3_2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` 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
Expand Down
98 changes: 98 additions & 0 deletions src/Resources/doc/3_21.md
Original file line number Diff line number Diff line change
@@ -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).
104 changes: 102 additions & 2 deletions src/Resources/public/js/SvarohJsFormValidator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
};

Expand Down Expand Up @@ -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]();
}
};
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
*
Expand Down
Loading
Loading