Skip to content

Commit fb05fc8

Browse files
authored
fix(forms): sort error summary by DOM order
This will allow users to rely on the `errorSummary` order to implement features like "focus next error"
1 parent 1df564b commit fb05fc8

File tree

3 files changed

+176
-6
lines changed

3 files changed

+176
-6
lines changed

packages/forms/signals/src/field/validation.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, Signal, ɵWritable} from '@angular/core';
9+
import {computed, Signal, untracked, ɵWritable} from '@angular/core';
1010
import type {ValidationError} from '../api/rules/validation/validation_errors';
1111
import type {FieldTree, TreeValidationResult, ValidationResult} from '../api/types';
1212
import {isArray} from '../util/type_guards';
@@ -254,12 +254,17 @@ export class FieldValidationState implements ValidationState {
254254
...this.asyncErrors().filter((err) => err !== 'pending'),
255255
]);
256256

257-
readonly errorSummary = computed(() =>
258-
this.node.structure.reduceChildren(this.errors(), (child, result) => [
257+
readonly errorSummary = computed(() => {
258+
const errors = this.node.structure.reduceChildren(this.errors(), (child, result) => [
259259
...result,
260260
...child.errorSummary(),
261-
]),
262-
);
261+
]);
262+
// Sort by DOM order on client-side only.
263+
if (typeof ngServerMode === 'undefined' || !ngServerMode) {
264+
untracked(() => errors.sort(compareErrorPosition));
265+
}
266+
return errors;
267+
});
263268

264269
/**
265270
* Whether this field has any asynchronous validators still pending.
@@ -382,3 +387,33 @@ export function addDefaultField<E extends ValidationError>(
382387
}
383388
return errors as ValidationResult<E & {fieldTree: FieldTree<unknown>}>;
384389
}
390+
391+
function getFirstBoundElement(error: ValidationError.WithFieldTree) {
392+
if (error.formField) return error.formField.element;
393+
return error
394+
.fieldTree()
395+
.formFieldBindings()
396+
.reduce<HTMLElement | undefined>((el: HTMLElement | undefined, binding) => {
397+
if (!el || !binding.element) return el ?? binding.element;
398+
return el.compareDocumentPosition(binding.element) & Node.DOCUMENT_POSITION_PRECEDING
399+
? binding.element
400+
: el;
401+
}, undefined);
402+
}
403+
404+
/**
405+
* Compares the position of two validation errors by the position of their corresponding field
406+
* binding directive in the DOM.
407+
* - For errors with multiple field bindings, the earliest one in the DOM will be used for comparison.
408+
* - For errors that have no field bindings, they will be considered to come after all other errors.
409+
*/
410+
function compareErrorPosition(
411+
a: ValidationError.WithFieldTree,
412+
b: ValidationError.WithFieldTree,
413+
): number {
414+
const aEl = getFirstBoundElement(a);
415+
const bEl = getFirstBoundElement(b);
416+
if (aEl === bEl) return 0;
417+
if (aEl === undefined || bEl === undefined) return aEl === undefined ? 1 : -1;
418+
return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1;
419+
}

packages/forms/signals/test/node/field_node.spec.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, effect, Injector, signal, WritableSignal} from '@angular/core';
9+
import {Component, computed, effect, Injector, signal, WritableSignal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {
1212
apply,
1313
applyEach,
1414
debounce,
1515
disabled,
1616
form,
17+
FormField,
1718
hidden,
1819
readonly,
1920
required,
@@ -1247,6 +1248,78 @@ describe('FieldNode', () => {
12471248
{kind: 'grandchild', fieldTree: f.child.child},
12481249
]);
12491250
});
1251+
1252+
it('should sort errors by DOM position', async () => {
1253+
@Component({
1254+
template: `
1255+
<input [formField]="f.b" />
1256+
<input [formField]="f.a" />
1257+
`,
1258+
imports: [FormField],
1259+
})
1260+
class TestCmp {
1261+
f = form(signal({a: '', b: ''}), (p) => {
1262+
validate(p.a, () => ({kind: 'error-a'}));
1263+
validate(p.b, () => ({kind: 'error-b'}));
1264+
});
1265+
}
1266+
1267+
const fixture = TestBed.createComponent(TestCmp);
1268+
const cmp = fixture.componentInstance;
1269+
fixture.detectChanges();
1270+
1271+
expect(cmp.f().errorSummary()).toEqual([
1272+
jasmine.objectContaining({kind: 'error-b'}),
1273+
jasmine.objectContaining({kind: 'error-a'}),
1274+
]);
1275+
});
1276+
1277+
it('should sort bound errors before unbound errors', () => {
1278+
@Component({
1279+
template: ` <input [formField]="f.a" /> `,
1280+
imports: [FormField],
1281+
})
1282+
class TestCmp {
1283+
f = form(signal({a: '', b: ''}), (p) => {
1284+
validate(p.a, () => ({kind: 'error-a'}));
1285+
validate(p.b, () => ({kind: 'error-b'}));
1286+
});
1287+
}
1288+
1289+
const fixture = TestBed.createComponent(TestCmp);
1290+
const cmp = fixture.componentInstance;
1291+
fixture.detectChanges();
1292+
1293+
expect(cmp.f().errorSummary()).toEqual([
1294+
jasmine.objectContaining({kind: 'error-a'}),
1295+
jasmine.objectContaining({kind: 'error-b'}),
1296+
]);
1297+
});
1298+
1299+
it('should sort errors from nested fields by DOM position', () => {
1300+
@Component({
1301+
template: `
1302+
<input [formField]="f.group.child" />
1303+
<input [formField]="f.other" />
1304+
`,
1305+
imports: [FormField],
1306+
})
1307+
class TestCmp {
1308+
f = form(signal({group: {child: ''}, other: ''}), (p) => {
1309+
validate(p.group.child, () => ({kind: 'child'}));
1310+
validate(p.other, () => ({kind: 'other'}));
1311+
});
1312+
}
1313+
1314+
const fixture = TestBed.createComponent(TestCmp);
1315+
const cmp = fixture.componentInstance;
1316+
fixture.detectChanges();
1317+
1318+
expect(cmp.f().errorSummary()).toEqual([
1319+
jasmine.objectContaining({kind: 'child'}),
1320+
jasmine.objectContaining({kind: 'other'}),
1321+
]);
1322+
});
12501323
});
12511324

12521325
describe('composition', () => {

packages/forms/signals/test/node/parse_errors.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {TestBed} from '@angular/core/testing';
2020
import {
2121
form,
2222
FormField,
23+
validate,
2324
type FieldTree,
2425
type FormValueControl,
2526
type ValidationError,
@@ -151,6 +152,67 @@ describe('parse errors', () => {
151152
expect(cmp.f().errors().length).toBe(1);
152153
expect(cmp.f().errors()[0]).toEqual(jasmine.objectContaining({kind: 'parse'}));
153154
});
155+
156+
it('should sort parse errors mixed with validation errors by DOM position', async () => {
157+
@Component({
158+
imports: [TestNumberInput, FormField],
159+
template: `
160+
<test-number-input id="input1" [formField]="f.a" />
161+
<test-number-input id="input2" [formField]="f.b" />
162+
`,
163+
})
164+
class TestCmp {
165+
state = signal({a: 5, b: 5});
166+
f = form(this.state, (p) => {
167+
validate(p.b, () => ({kind: 'val-error', message: 'validation error on b'}));
168+
});
169+
}
170+
171+
const fix = await act(() => TestBed.createComponent(TestCmp));
172+
const comp = fix.componentInstance;
173+
const input1: HTMLInputElement = fix.nativeElement.querySelector('#input1 input')!;
174+
175+
input1.value = 'joe';
176+
await act(() => input1.dispatchEvent(new Event('input')));
177+
178+
// a has parse error "joe is not numeric" (from TestNumberInput logic)
179+
// b has validation error "validation error on b"
180+
// a is before b in DOM
181+
expect(comp.f().errorSummary()).toEqual([
182+
jasmine.objectContaining({message: 'joe is not numeric'}),
183+
jasmine.objectContaining({message: 'validation error on b'}),
184+
]);
185+
});
186+
187+
it('should sort parse errors mixed with validation errors by DOM position (swapped)', async () => {
188+
@Component({
189+
imports: [TestNumberInput, FormField],
190+
template: `
191+
<test-number-input id="input2" [formField]="f.b" />
192+
<test-number-input id="input1" [formField]="f.a" />
193+
`,
194+
})
195+
class TestCmp {
196+
state = signal({a: 5, b: 5});
197+
f = form(this.state, (p) => {
198+
validate(p.b, () => ({kind: 'val-error', message: 'validation error on b'}));
199+
});
200+
}
201+
202+
const fix = await act(() => TestBed.createComponent(TestCmp));
203+
const comp = fix.componentInstance;
204+
const input1: HTMLInputElement = fix.nativeElement.querySelector('#input1 input')!;
205+
206+
input1.value = 'joe';
207+
await act(() => input1.dispatchEvent(new Event('input')));
208+
209+
// b (validation error) is first in DOM
210+
// a (parse error) is second
211+
expect(comp.f().errorSummary()).toEqual([
212+
jasmine.objectContaining({message: 'validation error on b'}),
213+
jasmine.objectContaining({message: 'joe is not numeric'}),
214+
]);
215+
});
154216
});
155217

156218
@Component({

0 commit comments

Comments
 (0)