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