Skip to content

Commit e749cb3

Browse files
66Ton99codex
andcommitted
Support error path mapping
Introduce FpJsFormError so custom constraints can return structured errors with an atPath value. Resolve the path from the validated element and route the error message to the matching child element, with fallback to the validated element when the path is unknown. Keep existing string-returning constraints backward-compatible by converting strings to FpJsFormError during validation. Clear the current validation source recursively before each validation pass so routed errors disappear after the constraint becomes valid. Document the custom constraint API and cover direct, nested, fallback, string-error, revalidation, and model-only DOM cases in Jest. Co-authored-by: Codex <codex@openai.com>
1 parent 58ee745 commit e749cb3

3 files changed

Lines changed: 266 additions & 15 deletions

File tree

src/Resources/doc/3_2.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ field's own errors and errors coming from other sources. For example,
1010
By default, `sourceId` is used as a class name on error `<li>` elements so those
1111
errors can be removed independently.
1212

13+
#### Error path mapping
14+
15+
Custom constraints may return `FpJsFormError` objects when an error should be
16+
displayed on a child element instead of the element currently being validated.
17+
Set `atPath` to a child path from the validated element. Dot notation may be
18+
used for nested children.
19+
20+
```js
21+
function AppPaymentConstraint() {
22+
this.message = 'Choose a payment method.';
23+
this.groups = ['Default'];
24+
25+
this.validate = function(value, element) {
26+
var error = new FpJsFormError(this.message);
27+
error.atPath = 'payment';
28+
29+
return [error];
30+
};
31+
}
32+
```
33+
34+
If the path cannot be resolved, the error is displayed on the validated element.
35+
Constraints that return plain strings continue to display errors on the
36+
validated element.
37+
1338
```js
1439
$('#user_email').jsFormValidator({
1540
showErrors: function(errors, sourceId) {

src/Resources/public/js/FpJsFormValidator.js

Lines changed: 105 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,35 @@
11
import './constraints';
22
import './transformers';
33

4+
export function FpJsFormError(message) {
5+
this.message = message;
6+
this.atPath = null;
7+
8+
this.getTarget = function(rootElement) {
9+
if (!this.atPath) {
10+
return rootElement;
11+
}
12+
13+
var path = String(this.atPath).split('.');
14+
var targetElement = rootElement;
15+
16+
for (var index = 0; index < path.length; index++) {
17+
var pathSegment = path[index];
18+
if (!pathSegment) {
19+
continue;
20+
}
21+
22+
if (!targetElement.children || !targetElement.children[pathSegment]) {
23+
return rootElement;
24+
}
25+
26+
targetElement = targetElement.children[pathSegment];
27+
}
28+
29+
return targetElement || rootElement;
30+
};
31+
}
32+
433
export function FpJsFormElement() {
534
this.id = '';
635
this.name = '';
@@ -22,28 +51,46 @@ export function FpJsFormElement() {
2251
};
2352

2453
this.validate = function () {
54+
var self = this;
55+
var sourceId = 'form-error-' + String(this.id).replace(/_/g, '-');
56+
self.clearErrorsRecursively(sourceId);
57+
2558
if (this.disabled) {
2659
return true;
2760
}
2861

29-
var self = this;
30-
var sourceId = 'form-error-' + String(this.id).replace(/_/g, '-');
31-
self.errors[sourceId] = FpJsFormValidator.validateElement(self);
32-
33-
var errorPath = FpJsFormValidator.getErrorPathElement(self);
34-
var domNode = errorPath.domNode;
35-
if (!domNode) {
36-
for (var childName in errorPath.children) {
37-
var childDomNode = errorPath.children[childName].domNode;
38-
if (childDomNode) {
39-
domNode = childDomNode;
40-
break;
41-
}
62+
var validationErrors = FpJsFormValidator.validateElement(self);
63+
var invalidTargets = [];
64+
for (var index = 0; index < validationErrors.length; index++) {
65+
var validationError = validationErrors[index];
66+
var errorTarget = validationError.getTarget
67+
? validationError.getTarget(self)
68+
: self;
69+
if (!errorTarget) {
70+
errorTarget = self;
71+
}
72+
73+
if (-1 === invalidTargets.indexOf(errorTarget)) {
74+
invalidTargets.push(errorTarget);
75+
}
76+
77+
if (!errorTarget.errors[sourceId]) {
78+
errorTarget.errors[sourceId] = [];
79+
}
80+
81+
errorTarget.errors[sourceId].push(validationError.message);
82+
}
83+
84+
for (var i = 0; i < invalidTargets.length; i++) {
85+
var target = invalidTargets[i];
86+
var errorPath = FpJsFormValidator.getErrorPathElement(target);
87+
var domNode = FpJsFormValidator.findErrorDomNode(errorPath);
88+
if (domNode) {
89+
errorPath.showErrors.apply(domNode, [target.errors[sourceId], sourceId]);
4290
}
4391
}
44-
errorPath.showErrors.apply(domNode, [self.errors[sourceId], sourceId]);
4592

46-
return self.errors[sourceId].length == 0;
93+
return validationErrors.length === 0;
4794
};
4895

4996
this.validateRecursively = function () {
@@ -70,6 +117,27 @@ export function FpJsFormElement() {
70117
return true;
71118
};
72119

120+
this.clearErrors = function(sourceId) {
121+
if (!sourceId) {
122+
for (sourceId in this.errors) {
123+
this.clearErrors(sourceId);
124+
}
125+
} else {
126+
this.errors[sourceId] = [];
127+
var domNode = FpJsFormValidator.findErrorDomNode(this);
128+
if (domNode) {
129+
this.showErrors.apply(domNode, [this.errors[sourceId], sourceId]);
130+
}
131+
}
132+
};
133+
134+
this.clearErrorsRecursively = function (sourceId) {
135+
this.clearErrors(sourceId);
136+
for (var childName in this.children) {
137+
this.children[childName].clearErrorsRecursively(sourceId);
138+
}
139+
};
140+
73141
this.showErrors = function (errors, sourceId) {
74142
if (!(this instanceof HTMLElement)) {
75143
return;
@@ -551,6 +619,12 @@ var FpJsFormValidator = new function () {
551619
}
552620
}
553621

622+
for (var index = 0; index < errors.length; index++) {
623+
if (typeof errors[index] === 'string') {
624+
errors[index] = new FpJsFormError(errors[index]);
625+
}
626+
}
627+
554628
return errors;
555629
};
556630

@@ -843,6 +917,21 @@ var FpJsFormValidator = new function () {
843917
}
844918
};
845919

920+
this.findErrorDomNode = function (element) {
921+
if (element.domNode) {
922+
return element.domNode;
923+
}
924+
925+
for (var childName in element.children) {
926+
var childDomNode = this.findErrorDomNode(element.children[childName]);
927+
if (childDomNode) {
928+
return childDomNode;
929+
}
930+
}
931+
932+
return null;
933+
};
934+
846935
/**
847936
* Applies customizing for the specified elements
848937
*
@@ -1051,5 +1140,6 @@ var FpJsFormValidator = new function () {
10511140
}();
10521141

10531142
window.FpJsBaseConstraint = FpJsBaseConstraint;
1143+
window.FpJsFormError = FpJsFormError;
10541144
window.FpJsFormValidator = FpJsFormValidator;
10551145
window.FpJsFormElement = FpJsFormElement;

src/Resources/public/js/FpJsFormValidator.test.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,139 @@ describe('FpJsFormValidator prototypes', () => {
5353
expect(parent.children[0].parent).toBe(parent);
5454
});
5555
});
56+
57+
describe('FpJsFormValidator error mapping', () => {
58+
function createElement(id, name) {
59+
const element = new window.FpJsFormElement();
60+
element.id = id;
61+
element.name = name || id;
62+
element.domNode = document.createElement('input');
63+
element.showErrors = jest.fn();
64+
65+
return element;
66+
}
67+
68+
function addChild(parent, name, child) {
69+
parent.children[name] = child;
70+
child.parent = parent;
71+
72+
return child;
73+
}
74+
75+
function addConstraint(element, validate) {
76+
element.data.form = {
77+
constraints: [{
78+
groups: ['Default'],
79+
validate,
80+
}],
81+
getters: {},
82+
groups: ['Default'],
83+
};
84+
}
85+
86+
test('keeps plain string errors on the validated element', () => {
87+
const element = createElement('user_email');
88+
addConstraint(element, () => ['Invalid email.']);
89+
90+
expect(element.validate()).toBe(false);
91+
92+
expect(element.errors['form-error-user-email']).toEqual(['Invalid email.']);
93+
expect(element.showErrors).toHaveBeenLastCalledWith(
94+
['Invalid email.'],
95+
'form-error-user-email'
96+
);
97+
});
98+
99+
test('routes structured errors to a direct child path', () => {
100+
const form = createElement('user');
101+
const email = addChild(form, 'email', createElement('user_email'));
102+
addConstraint(form, () => {
103+
const error = new window.FpJsFormError('Email is already used.');
104+
error.atPath = 'email';
105+
106+
return [error];
107+
});
108+
109+
expect(form.validate()).toBe(false);
110+
111+
expect(form.errors['form-error-user']).toEqual([]);
112+
expect(email.errors['form-error-user']).toEqual(['Email is already used.']);
113+
expect(email.showErrors).toHaveBeenLastCalledWith(
114+
['Email is already used.'],
115+
'form-error-user'
116+
);
117+
});
118+
119+
test('routes structured errors to a nested child path', () => {
120+
const form = createElement('user');
121+
const address = addChild(form, 'address', createElement('user_address'));
122+
const street = addChild(address, 'street', createElement('user_address_street'));
123+
addConstraint(form, () => {
124+
const error = new window.FpJsFormError('Street is required.');
125+
error.atPath = 'address.street';
126+
127+
return [error];
128+
});
129+
130+
expect(form.validate()).toBe(false);
131+
132+
expect(street.errors['form-error-user']).toEqual(['Street is required.']);
133+
expect(street.showErrors).toHaveBeenLastCalledWith(
134+
['Street is required.'],
135+
'form-error-user'
136+
);
137+
});
138+
139+
test('falls back to the validated element when a child path cannot be resolved', () => {
140+
const form = createElement('user');
141+
addConstraint(form, () => {
142+
const error = new window.FpJsFormError('Payment method is invalid.');
143+
error.atPath = 'payment';
144+
145+
return [error];
146+
});
147+
148+
expect(form.validate()).toBe(false);
149+
150+
expect(form.errors['form-error-user']).toEqual(['Payment method is invalid.']);
151+
expect(form.showErrors).toHaveBeenLastCalledWith(
152+
['Payment method is invalid.'],
153+
'form-error-user'
154+
);
155+
});
156+
157+
test('clears previous routed errors before revalidating', () => {
158+
const form = createElement('user');
159+
const email = addChild(form, 'email', createElement('user_email'));
160+
let shouldFail = true;
161+
addConstraint(form, () => {
162+
if (!shouldFail) {
163+
return [];
164+
}
165+
166+
const error = new window.FpJsFormError('Email is already used.');
167+
error.atPath = 'email';
168+
169+
return [error];
170+
});
171+
172+
expect(form.validate()).toBe(false);
173+
174+
shouldFail = false;
175+
expect(form.validate()).toBe(true);
176+
177+
expect(email.errors['form-error-user']).toEqual([]);
178+
expect(email.showErrors).toHaveBeenLastCalledWith([], 'form-error-user');
179+
});
180+
181+
test('stores errors for model-only elements without requiring a DOM node', () => {
182+
const element = createElement('model_only');
183+
element.domNode = null;
184+
addConstraint(element, () => ['Model error.']);
185+
186+
expect(element.validate()).toBe(false);
187+
188+
expect(element.errors['form-error-model-only']).toEqual(['Model error.']);
189+
expect(element.showErrors).not.toHaveBeenCalled();
190+
});
191+
});

0 commit comments

Comments
 (0)