diff --git a/src/Resources/doc/3_2.md b/src/Resources/doc/3_2.md
index 877a7e0..b19e249 100755
--- a/src/Resources/doc/3_2.md
+++ b/src/Resources/doc/3_2.md
@@ -10,6 +10,31 @@ 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.
+#### Error path mapping
+
+Custom constraints may return `FpJsFormError` objects when an error should be
+displayed on a child element instead of the element currently being validated.
+Set `atPath` to a child path from the validated element. Dot notation may be
+used for nested children.
+
+```js
+function AppPaymentConstraint() {
+ this.message = 'Choose a payment method.';
+ this.groups = ['Default'];
+
+ this.validate = function(value, element) {
+ var error = new FpJsFormError(this.message);
+ error.atPath = 'payment';
+
+ return [error];
+ };
+}
+```
+
+If the path cannot be resolved, the error is displayed on the validated element.
+Constraints that return plain strings continue to display errors on the
+validated element.
+
```js
$('#user_email').jsFormValidator({
showErrors: function(errors, sourceId) {
diff --git a/src/Resources/public/js/FpJsFormValidator.js b/src/Resources/public/js/FpJsFormValidator.js
index 4b32a91..a6e1e00 100755
--- a/src/Resources/public/js/FpJsFormValidator.js
+++ b/src/Resources/public/js/FpJsFormValidator.js
@@ -1,6 +1,35 @@
import './constraints';
import './transformers';
+export function FpJsFormError(message) {
+ this.message = message;
+ this.atPath = null;
+
+ this.getTarget = function(rootElement) {
+ if (!this.atPath) {
+ return rootElement;
+ }
+
+ var path = String(this.atPath).split('.');
+ var targetElement = rootElement;
+
+ for (var index = 0; index < path.length; index++) {
+ var pathSegment = path[index];
+ if (!pathSegment) {
+ continue;
+ }
+
+ if (!targetElement.children || !targetElement.children[pathSegment]) {
+ return rootElement;
+ }
+
+ targetElement = targetElement.children[pathSegment];
+ }
+
+ return targetElement || rootElement;
+ };
+}
+
export function FpJsFormElement() {
this.id = '';
this.name = '';
@@ -22,28 +51,46 @@ export function FpJsFormElement() {
};
this.validate = function () {
+ var self = this;
+ var sourceId = 'form-error-' + String(this.id).replace(/_/g, '-');
+ self.clearErrorsRecursively(sourceId);
+
if (this.disabled) {
return true;
}
- var self = this;
- var sourceId = 'form-error-' + String(this.id).replace(/_/g, '-');
- self.errors[sourceId] = FpJsFormValidator.validateElement(self);
-
- var errorPath = FpJsFormValidator.getErrorPathElement(self);
- var domNode = errorPath.domNode;
- if (!domNode) {
- for (var childName in errorPath.children) {
- var childDomNode = errorPath.children[childName].domNode;
- if (childDomNode) {
- domNode = childDomNode;
- break;
- }
+ var validationErrors = FpJsFormValidator.validateElement(self);
+ var invalidTargets = [];
+ for (var index = 0; index < validationErrors.length; index++) {
+ var validationError = validationErrors[index];
+ var errorTarget = validationError.getTarget
+ ? validationError.getTarget(self)
+ : self;
+ if (!errorTarget) {
+ errorTarget = self;
+ }
+
+ if (-1 === invalidTargets.indexOf(errorTarget)) {
+ invalidTargets.push(errorTarget);
+ }
+
+ if (!errorTarget.errors[sourceId]) {
+ errorTarget.errors[sourceId] = [];
+ }
+
+ errorTarget.errors[sourceId].push(validationError.message);
+ }
+
+ for (var i = 0; i < invalidTargets.length; i++) {
+ var target = invalidTargets[i];
+ var errorPath = FpJsFormValidator.getErrorPathElement(target);
+ var domNode = FpJsFormValidator.findErrorDomNode(errorPath);
+ if (domNode) {
+ errorPath.showErrors.apply(domNode, [target.errors[sourceId], sourceId]);
}
}
- errorPath.showErrors.apply(domNode, [self.errors[sourceId], sourceId]);
- return self.errors[sourceId].length == 0;
+ return validationErrors.length === 0;
};
this.validateRecursively = function () {
@@ -70,6 +117,27 @@ export function FpJsFormElement() {
return true;
};
+ this.clearErrors = function(sourceId) {
+ if (!sourceId) {
+ for (sourceId in this.errors) {
+ this.clearErrors(sourceId);
+ }
+ } else {
+ this.errors[sourceId] = [];
+ var domNode = FpJsFormValidator.findErrorDomNode(this);
+ if (domNode) {
+ this.showErrors.apply(domNode, [this.errors[sourceId], sourceId]);
+ }
+ }
+ };
+
+ this.clearErrorsRecursively = function (sourceId) {
+ this.clearErrors(sourceId);
+ for (var childName in this.children) {
+ this.children[childName].clearErrorsRecursively(sourceId);
+ }
+ };
+
this.showErrors = function (errors, sourceId) {
if (!(this instanceof HTMLElement)) {
return;
@@ -551,6 +619,12 @@ var FpJsFormValidator = new function () {
}
}
+ for (var index = 0; index < errors.length; index++) {
+ if (typeof errors[index] === 'string') {
+ errors[index] = new FpJsFormError(errors[index]);
+ }
+ }
+
return errors;
};
@@ -843,6 +917,21 @@ var FpJsFormValidator = new function () {
}
};
+ this.findErrorDomNode = function (element) {
+ if (element.domNode) {
+ return element.domNode;
+ }
+
+ for (var childName in element.children) {
+ var childDomNode = this.findErrorDomNode(element.children[childName]);
+ if (childDomNode) {
+ return childDomNode;
+ }
+ }
+
+ return null;
+ };
+
/**
* Applies customizing for the specified elements
*
@@ -1051,5 +1140,6 @@ var FpJsFormValidator = new function () {
}();
window.FpJsBaseConstraint = FpJsBaseConstraint;
+window.FpJsFormError = FpJsFormError;
window.FpJsFormValidator = FpJsFormValidator;
window.FpJsFormElement = FpJsFormElement;
diff --git a/src/Resources/public/js/FpJsFormValidator.test.js b/src/Resources/public/js/FpJsFormValidator.test.js
index d626abf..36c2280 100644
--- a/src/Resources/public/js/FpJsFormValidator.test.js
+++ b/src/Resources/public/js/FpJsFormValidator.test.js
@@ -53,3 +53,139 @@ describe('FpJsFormValidator prototypes', () => {
expect(parent.children[0].parent).toBe(parent);
});
});
+
+describe('FpJsFormValidator error mapping', () => {
+ function createElement(id, name) {
+ const element = new window.FpJsFormElement();
+ element.id = id;
+ element.name = name || id;
+ element.domNode = document.createElement('input');
+ element.showErrors = jest.fn();
+
+ return element;
+ }
+
+ function addChild(parent, name, child) {
+ parent.children[name] = child;
+ child.parent = parent;
+
+ return child;
+ }
+
+ function addConstraint(element, validate) {
+ element.data.form = {
+ constraints: [{
+ groups: ['Default'],
+ validate,
+ }],
+ getters: {},
+ groups: ['Default'],
+ };
+ }
+
+ test('keeps plain string errors on the validated element', () => {
+ const element = createElement('user_email');
+ addConstraint(element, () => ['Invalid email.']);
+
+ expect(element.validate()).toBe(false);
+
+ expect(element.errors['form-error-user-email']).toEqual(['Invalid email.']);
+ expect(element.showErrors).toHaveBeenLastCalledWith(
+ ['Invalid email.'],
+ 'form-error-user-email'
+ );
+ });
+
+ test('routes structured errors to a direct child path', () => {
+ const form = createElement('user');
+ const email = addChild(form, 'email', createElement('user_email'));
+ addConstraint(form, () => {
+ const error = new window.FpJsFormError('Email is already used.');
+ error.atPath = 'email';
+
+ return [error];
+ });
+
+ expect(form.validate()).toBe(false);
+
+ expect(form.errors['form-error-user']).toEqual([]);
+ expect(email.errors['form-error-user']).toEqual(['Email is already used.']);
+ expect(email.showErrors).toHaveBeenLastCalledWith(
+ ['Email is already used.'],
+ 'form-error-user'
+ );
+ });
+
+ test('routes structured errors to a nested child path', () => {
+ const form = createElement('user');
+ const address = addChild(form, 'address', createElement('user_address'));
+ const street = addChild(address, 'street', createElement('user_address_street'));
+ addConstraint(form, () => {
+ const error = new window.FpJsFormError('Street is required.');
+ error.atPath = 'address.street';
+
+ return [error];
+ });
+
+ expect(form.validate()).toBe(false);
+
+ expect(street.errors['form-error-user']).toEqual(['Street is required.']);
+ expect(street.showErrors).toHaveBeenLastCalledWith(
+ ['Street is required.'],
+ 'form-error-user'
+ );
+ });
+
+ test('falls back to the validated element when a child path cannot be resolved', () => {
+ const form = createElement('user');
+ addConstraint(form, () => {
+ const error = new window.FpJsFormError('Payment method is invalid.');
+ error.atPath = 'payment';
+
+ return [error];
+ });
+
+ expect(form.validate()).toBe(false);
+
+ expect(form.errors['form-error-user']).toEqual(['Payment method is invalid.']);
+ expect(form.showErrors).toHaveBeenLastCalledWith(
+ ['Payment method is invalid.'],
+ 'form-error-user'
+ );
+ });
+
+ test('clears previous routed errors before revalidating', () => {
+ const form = createElement('user');
+ const email = addChild(form, 'email', createElement('user_email'));
+ let shouldFail = true;
+ addConstraint(form, () => {
+ if (!shouldFail) {
+ return [];
+ }
+
+ const error = new window.FpJsFormError('Email is already used.');
+ error.atPath = 'email';
+
+ return [error];
+ });
+
+ expect(form.validate()).toBe(false);
+
+ shouldFail = false;
+ expect(form.validate()).toBe(true);
+
+ expect(email.errors['form-error-user']).toEqual([]);
+ expect(email.showErrors).toHaveBeenLastCalledWith([], 'form-error-user');
+ });
+
+ test('stores errors for model-only elements without requiring a DOM node', () => {
+ const element = createElement('model_only');
+ element.domNode = null;
+ addConstraint(element, () => ['Model error.']);
+
+ expect(element.validate()).toBe(false);
+
+ expect(element.errors['form-error-model-only']).toEqual(['Model error.']);
+ expect(element.showErrors).not.toHaveBeenCalled();
+ });
+});