Skip to content

Commit 3d9a897

Browse files
committed
feat(forms): add error summaries to fields
Add an `errorSummary` signal to `FieldState` which contains all of the validation errors of a field and its descendants. This can be used to conveniently collect all form validation errors by reading it from the root field.
1 parent 2183850 commit 3d9a897

File tree

4 files changed

+81
-2
lines changed

4 files changed

+81
-2
lines changed

goldens/public-api/forms/experimental/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
151151
readonly disabled: Signal<boolean>;
152152
readonly disabledReasons: Signal<readonly DisabledReason[]>;
153153
readonly errors: Signal<ValidationError[]>;
154+
readonly errorSummary: Signal<ValidationError[]>;
154155
readonly hidden: Signal<boolean>;
155156
readonly invalid: Signal<boolean>;
156157
readonly keyInParent: Signal<TKey>;

packages/forms/experimental/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
237237
* A signal containing the current errors for the field.
238238
*/
239239
readonly errors: Signal<ValidationError[]>;
240+
/**
241+
* A signal containing the {@link errors} of the field and its descendants.
242+
*/
243+
readonly errorSummary: Signal<ValidationError[]>;
240244
/**
241245
* A signal containing the current errors for the field.
242246
*/

packages/forms/experimental/src/field/node.ts

Lines changed: 8 additions & 1 deletion
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 type {Signal, WritableSignal} from '@angular/core';
9+
import {computed, type Signal, type WritableSignal} from '@angular/core';
1010
import {AggregateProperty, Property} from '../api/property';
1111
import type {DisabledReason, Field, FieldContext, FieldState} from '../api/types';
1212
import type {ValidationError} from '../api/validation_errors';
@@ -26,6 +26,7 @@ import {
2626
} from './structure';
2727
import {FieldSubmitState} from './submit';
2828
import {FieldValidationState} from './validation';
29+
import {reduceChildren} from './util';
2930

3031
/**
3132
* Internal node in the form tree for a given field.
@@ -109,6 +110,12 @@ export class FieldNode implements FieldState<unknown> {
109110
return this.validationState.errors;
110111
}
111112

113+
get errorSummary(): Signal<ValidationError[]> {
114+
return computed(() =>
115+
reduceChildren(this, this.errors(), (child, result) => [...result, ...child.errorSummary()]),
116+
);
117+
}
118+
112119
get pending(): Signal<boolean> {
113120
return this.validationState.pending;
114121
}

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

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

9-
import {computed, Injector, signal} from '@angular/core';
9+
import {computed, inject, InjectionToken, Injector, signal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {
1212
apply,
1313
applyEach,
1414
applyWhen,
15+
applyWhenValue,
1516
disabled,
1617
FieldPath,
1718
form,
@@ -761,6 +762,72 @@ describe('FieldNode', () => {
761762
});
762763
});
763764

765+
describe('errorSummary', () => {
766+
it('should be empty', () => {
767+
const data = signal({});
768+
const f = form(data, {injector: TestBed.inject(Injector)});
769+
770+
expect(f().errorSummary()).toEqual([]);
771+
});
772+
773+
it('should contain errors from current field', () => {
774+
const data = signal('');
775+
const f = form(
776+
data,
777+
(p) => {
778+
required(p);
779+
},
780+
{injector: TestBed.inject(Injector)},
781+
);
782+
783+
expect(f().errorSummary()).toEqual([ValidationError.required()]);
784+
});
785+
786+
it('should contain errors from child fields', () => {
787+
const name = signal({first: '', last: ''});
788+
const f = form(
789+
name,
790+
(p) => {
791+
required(p.first);
792+
required(p.last);
793+
},
794+
{injector: TestBed.inject(Injector)},
795+
);
796+
797+
expect(f().errorSummary()).toEqual([ValidationError.required(), ValidationError.required()]);
798+
});
799+
800+
it('should accumulate errors of all descendants', () => {
801+
const data = signal({
802+
child: {
803+
child: {},
804+
},
805+
});
806+
const f = form(
807+
data,
808+
(p) => {
809+
validate(p, () => ValidationError.custom({kind: 'root'}));
810+
validate(p.child, () => ValidationError.custom({kind: 'child'}));
811+
validate(p.child.child, () => ValidationError.custom({kind: 'grandchild'}));
812+
},
813+
{injector: TestBed.inject(Injector)},
814+
);
815+
816+
expect(f.child.child().errorSummary()).toEqual([
817+
ValidationError.custom({kind: 'grandchild'}),
818+
]);
819+
expect(f.child().errorSummary()).toEqual([
820+
ValidationError.custom({kind: 'child'}),
821+
ValidationError.custom({kind: 'grandchild'}),
822+
]);
823+
expect(f().errorSummary()).toEqual([
824+
ValidationError.custom({kind: 'root'}),
825+
ValidationError.custom({kind: 'child'}),
826+
ValidationError.custom({kind: 'grandchild'}),
827+
]);
828+
});
829+
});
830+
764831
describe('composition', () => {
765832
it('should apply schema to field', () => {
766833
interface Address {

0 commit comments

Comments
 (0)