Skip to content

Commit ebae211

Browse files
mmalerbaatscott
authored andcommitted
feat(forms): introduce parse errors in signal forms
Parse errors allow a custom control to communicate that it is currently unable to produce a valid value. Parse errors are reported by implementing the optional `parseErrors` property on the `FormUiControl`. The property should be a signal of the current parse errors. Also renames several `*Field` types to `*FieldTree`. This aligns with the new naming of the concept after `Field` was renamed to `FieldTree`.
1 parent 085784e commit ebae211

File tree

20 files changed

+497
-161
lines changed

20 files changed

+497
-161
lines changed

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

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,10 @@ export interface DisabledReason {
109109
export function email<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
110110

111111
// @public
112-
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
112+
export function emailError(options: WithFieldTree<ValidationErrorOptions>): EmailValidationError;
113113

114114
// @public
115-
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
115+
export function emailError(options?: ValidationErrorOptions): WithoutFieldTree<EmailValidationError>;
116116

117117
// @public
118118
export class EmailValidationError extends _NgValidationError {
@@ -129,8 +129,8 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
129129
// (undocumented)
130130
readonly disabledReasons: Signal<readonly DisabledReason[]>;
131131
// (undocumented)
132-
readonly errors: Signal<ValidationError.WithField[]>;
133-
readonly errorSummary: Signal<ValidationError.WithField[]>;
132+
readonly errors: Signal<ValidationError.WithFieldTree[]>;
133+
readonly errorSummary: Signal<ValidationError.WithFieldTree[]>;
134134
focusBoundControl(options?: FocusOptions): void;
135135
readonly formFieldBindings: Signal<readonly FormField<unknown>[]>;
136136
readonly hidden: Signal<boolean>;
@@ -147,7 +147,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
147147
export type FieldTree<TModel, TKey extends string | number = string | number> = (() => [TModel] extends [AbstractControl] ? CompatFieldState<TModel, TKey> : FieldState<TModel, TKey>) & ([TModel] extends [AbstractControl] ? object : [TModel] extends [ReadonlyArray<infer U>] ? ReadonlyArrayLike<MaybeFieldTree<U, number>> : TModel extends Record<string, any> ? Subfields<TModel> : object);
148148

149149
// @public
150-
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutField>, TPathKind>;
150+
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutFieldTree>, TPathKind>;
151151

152152
// @public
153153
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
@@ -162,7 +162,7 @@ export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSche
162162
export const FORM_FIELD: InjectionToken<FormField<unknown>>;
163163

164164
// @public
165-
export interface FormCheckboxControl extends FormUiControl {
165+
export interface FormCheckboxControl extends FormUiControl<boolean> {
166166
readonly checked: ModelSignal<boolean>;
167167
readonly value?: undefined;
168168
}
@@ -176,24 +176,27 @@ export class FormField<T> {
176176
};
177177
// (undocumented)
178178
readonly element: HTMLElement;
179-
focus(options?: FocusOptions): void;
179+
readonly errors: Signal<ValidationError.WithFieldTree[]>;
180180
// (undocumented)
181-
readonly formField: i0.InputSignal<FieldTree<T>>;
181+
readonly fieldTree: i0.InputSignal<FieldTree<T>>;
182+
focus(options?: FocusOptions): void;
182183
protected getOrCreateNgControl(): InteropNgControl;
183184
// (undocumented)
184185
readonly injector: Injector;
185-
registerAsBinding(bindingOptions?: FormFieldBindingOptions): void;
186+
registerAsBinding(bindingOptions?: FormFieldBindingOptions<T>): void;
186187
// (undocumented)
187-
readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
188+
readonly state: Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
188189
// (undocumented)
189-
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", never, { "formField": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
190+
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", ["formField"], { "fieldTree": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
190191
// (undocumented)
191192
static ɵfac: i0.ɵɵFactoryDeclaration<FormField<any>, never>;
192193
}
193194

194195
// @public (undocumented)
195-
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
196+
export interface FormFieldBindingOptions<TValue> extends ɵFormFieldBindingOptions {
196197
focus?(options?: FocusOptions): void;
198+
// (undocumented)
199+
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
197200
}
198201

199202
// @public
@@ -205,11 +208,11 @@ export interface FormOptions {
205208
}
206209

207210
// @public
208-
export interface FormUiControl {
211+
export interface FormUiControl<TValue> {
209212
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
210213
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
211-
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
212-
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
214+
readonly disabledReasons?: InputSignal<readonly WithOptionalFieldTree<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalFieldTree<DisabledReason>[], unknown>;
215+
readonly errors?: InputSignal<readonly ValidationError.WithOptionalFieldTree[]> | InputSignalWithTransform<readonly ValidationError.WithOptionalFieldTree[], unknown>;
213216
focus?(options?: FocusOptions): void;
214217
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
215218
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
@@ -218,6 +221,7 @@ export interface FormUiControl {
218221
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
219222
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
220223
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
224+
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
221225
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
222226
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
223227
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
@@ -226,7 +230,7 @@ export interface FormUiControl {
226230
}
227231

228232
// @public
229-
export interface FormValueControl<TValue> extends FormUiControl {
233+
export interface FormValueControl<TValue> extends FormUiControl<TValue> {
230234
readonly checked?: undefined;
231235
readonly value: ModelSignal<TValue>;
232236
}
@@ -271,19 +275,19 @@ export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath
271275
export const MAX_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
272276

273277
// @public
274-
export function maxError(max: number, options: WithField<ValidationErrorOptions>): MaxValidationError;
278+
export function maxError(max: number, options: WithFieldTree<ValidationErrorOptions>): MaxValidationError;
275279

276280
// @public
277-
export function maxError(max: number, options?: ValidationErrorOptions): WithoutField<MaxValidationError>;
281+
export function maxError(max: number, options?: ValidationErrorOptions): WithoutFieldTree<MaxValidationError>;
278282

279283
// @public
280284
export function maxLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, maxLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
281285

282286
// @public
283-
export function maxLengthError(maxLength: number, options: WithField<ValidationErrorOptions>): MaxLengthValidationError;
287+
export function maxLengthError(maxLength: number, options: WithFieldTree<ValidationErrorOptions>): MaxLengthValidationError;
284288

285289
// @public
286-
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField<MaxLengthValidationError>;
290+
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutFieldTree<MaxLengthValidationError>;
287291

288292
// @public
289293
export class MaxLengthValidationError extends _NgValidationError {
@@ -350,19 +354,19 @@ export function min<TValue extends number | string | null, TPathKind extends Pat
350354
export const MIN_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
351355

352356
// @public
353-
export function minError(min: number, options: WithField<ValidationErrorOptions>): MinValidationError;
357+
export function minError(min: number, options: WithFieldTree<ValidationErrorOptions>): MinValidationError;
354358

355359
// @public
356-
export function minError(min: number, options?: ValidationErrorOptions): WithoutField<MinValidationError>;
360+
export function minError(min: number, options?: ValidationErrorOptions): WithoutFieldTree<MinValidationError>;
357361

358362
// @public
359363
export function minLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, minLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
360364

361365
// @public
362-
export function minLengthError(minLength: number, options: WithField<ValidationErrorOptions>): MinLengthValidationError;
366+
export function minLengthError(minLength: number, options: WithFieldTree<ValidationErrorOptions>): MinLengthValidationError;
363367

364368
// @public
365-
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField<MinLengthValidationError>;
369+
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutFieldTree<MinLengthValidationError>;
366370

367371
// @public
368372
export class MinLengthValidationError extends _NgValidationError {
@@ -416,10 +420,10 @@ export const PATTERN: MetadataKey<Signal<RegExp[]>, RegExp | undefined, RegExp[]
416420
export function pattern<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>, pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
417421

418422
// @public
419-
export function patternError(pattern: RegExp, options: WithField<ValidationErrorOptions>): PatternValidationError;
423+
export function patternError(pattern: RegExp, options: WithFieldTree<ValidationErrorOptions>): PatternValidationError;
420424

421425
// @public
422-
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField<PatternValidationError>;
426+
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutFieldTree<PatternValidationError>;
423427

424428
// @public
425429
export class PatternValidationError extends _NgValidationError {
@@ -451,10 +455,10 @@ export function required<TValue, TPathKind extends PathKind = PathKind.Root>(pat
451455
}): void;
452456

453457
// @public
454-
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
458+
export function requiredError(options: WithFieldTree<ValidationErrorOptions>): RequiredValidationError;
455459

456460
// @public
457-
export function requiredError(options?: ValidationErrorOptions): WithoutField<RequiredValidationError>;
461+
export function requiredError(options?: ValidationErrorOptions): WithoutFieldTree<RequiredValidationError>;
458462

459463
// @public
460464
export class RequiredValidationError extends _NgValidationError {
@@ -520,10 +524,10 @@ export interface SignalFormsConfig {
520524
}
521525

522526
// @public
523-
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
527+
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithFieldTree<ValidationErrorOptions>): StandardSchemaValidationError;
524528

525529
// @public
526-
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField<StandardSchemaValidationError>;
530+
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutFieldTree<StandardSchemaValidationError>;
527531

528532
// @public
529533
export class StandardSchemaValidationError extends _NgValidationError {
@@ -545,7 +549,7 @@ export type Subfields<TModel> = {
545549
export function submit<TModel>(form: FieldTree<TModel>, action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>): Promise<void>;
546550

547551
// @public
548-
export type TreeValidationResult<E extends ValidationError.WithOptionalField = ValidationError.WithOptionalField> = ValidationSuccess | OneOrMany<E>;
552+
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;
549553

550554
// @public
551555
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
@@ -573,14 +577,28 @@ export interface ValidationError {
573577

574578
// @public (undocumented)
575579
export namespace ValidationError {
576-
export interface WithField extends ValidationError {
580+
// @deprecated (undocumented)
581+
export type WithField = WithFieldTree;
582+
export interface WithFieldTree extends ValidationError {
577583
readonly fieldTree: FieldTree<unknown>;
584+
// (undocumented)
585+
readonly formField?: FormField<unknown>;
586+
}
587+
export interface WithFormField extends WithFieldTree {
588+
// (undocumented)
589+
readonly formField: FormField<unknown>;
578590
}
579-
export interface WithOptionalField extends ValidationError {
591+
// @deprecated (undocumented)
592+
export type WithOptionalField = WithOptionalFieldTree;
593+
export interface WithOptionalFieldTree extends ValidationError {
580594
readonly fieldTree?: FieldTree<unknown>;
581595
}
582-
export interface WithoutField extends ValidationError {
596+
// @deprecated (undocumented)
597+
export type WithoutField = WithoutFieldTree;
598+
export interface WithoutFieldTree extends ValidationError {
583599
readonly fieldTree?: never;
600+
// (undocumented)
601+
readonly formField?: never;
584602
}
585603
}
586604

@@ -593,18 +611,27 @@ export type ValidationSuccess = null | undefined | void;
593611
// @public
594612
export type Validator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult, TPathKind>;
595613

614+
// @public @deprecated (undocumented)
615+
export type WithField<T> = WithFieldTree<T>;
616+
596617
// @public
597-
export type WithField<T> = T & {
618+
export type WithFieldTree<T> = T & {
598619
fieldTree: FieldTree<unknown>;
599620
};
600621

622+
// @public @deprecated (undocumented)
623+
export type WithOptionalField<T> = WithOptionalFieldTree<T>;
624+
601625
// @public
602-
export type WithOptionalField<T> = Omit<T, 'fieldTree'> & {
626+
export type WithOptionalFieldTree<T> = Omit<T, 'fieldTree'> & {
603627
fieldTree?: FieldTree<unknown>;
604628
};
605629

630+
// @public @deprecated (undocumented)
631+
export type WithoutField<T> = WithoutFieldTree<T>;
632+
606633
// @public
607-
export type WithoutField<T> = T & {
634+
export type WithoutFieldTree<T> = T & {
608635
fieldTree: never;
609636
};
610637

packages/core/src/render3/instructions/control.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -568,19 +568,19 @@ function updateControlClasses(lView: LView, tNode: TNode, control: ɵFormFieldDi
568568
* @param lView The `LView` that contains the custom form control.
569569
* @param componentIndex The index of the custom form control component in the `LView`.
570570
* @param modelName The name of the model property on the custom form control.
571-
* @param control The `ɵFormFieldDirective` instance.
571+
* @param fieldDirective The `ɵFormFieldDirective` instance.
572572
*/
573573
function updateCustomControl(
574574
tNode: TNode,
575575
lView: LView,
576-
control: ɵFormFieldDirective<unknown>,
576+
fieldDirective: ɵFormFieldDirective<unknown>,
577577
modelName: string,
578578
) {
579579
const tView = getTView();
580580
const directiveIndex = tNode.customControlIndex;
581581
const directive = lView[directiveIndex];
582582
const directiveDef = tView.data[directiveIndex] as DirectiveDef<{}>;
583-
const state = control.state();
583+
const state = fieldDirective.state();
584584
const bindings = getControlBindings(lView);
585585

586586
// Bind custom form control model ('value' or 'checked').
@@ -595,9 +595,9 @@ function updateCustomControl(
595595

596596
// Bind remaining field state properties.
597597
for (const key of CONTROL_BINDING_KEYS) {
598-
const value = state[key]?.();
598+
const inputName = CONTROL_BINDING_NAMES[key];
599+
const value = getValue(fieldDirective, state, key, inputName);
599600
if (controlBindingUpdated(bindings, key, value)) {
600-
const inputName = CONTROL_BINDING_NAMES[key];
601601
updateDirectiveInputs(tNode, lView, inputName, value);
602602

603603
// If the host node is a native control, we can bind field state properties to native
@@ -614,16 +614,16 @@ function updateCustomControl(
614614
*
615615
* @param tNode The `TNode` of the form control.
616616
* @param lView The `LView` that contains the native form control.
617-
* @param control The `ɵFormFieldDirective` instance.
617+
* @param fieldDirective The `ɵFormFieldDirective` instance.
618618
*/
619619
function updateInteropControl(
620620
tNode: TNode,
621621
lView: LView,
622-
control: ɵFormFieldDirective<unknown>,
622+
fieldDirective: ɵFormFieldDirective<unknown>,
623623
): void {
624-
const interopControl = control.ɵinteropControl!;
624+
const interopControl = fieldDirective.ɵinteropControl!;
625625
const bindings = getControlBindings(lView);
626-
const state = control.state();
626+
const state = fieldDirective.state();
627627

628628
const isNative = (tNode.flags & TNodeFlags.isNativeControl) !== 0;
629629
const element = isNative ? (getNativeByTNode(tNode, lView) as NativeControlElement) : null;
@@ -637,9 +637,9 @@ function updateInteropControl(
637637
}
638638

639639
for (const key of CONTROL_BINDING_KEYS) {
640-
const value = state[key]?.();
640+
const inputName = CONTROL_BINDING_NAMES[key];
641+
const value = getValue(fieldDirective, state, key, inputName);
641642
if (controlBindingUpdated(bindings, key, value)) {
642-
const inputName = CONTROL_BINDING_NAMES[key];
643643
const didUpdateInput = updateDirectiveInputs(tNode, lView, inputName, value);
644644

645645
// We never fallback to the native property for `disabled` since it's handled directly by
@@ -662,16 +662,16 @@ function updateInteropControl(
662662
*
663663
* @param tNode The `TNode` of the native form control.
664664
* @param lView The `LView` that contains the native form control.
665-
* @param control The `ɵFormFieldDirective` instance.
665+
* @param fieldDirective The `ɵFormFieldDirective` instance.
666666
*/
667667
function updateNativeControl(
668668
tNode: TNode,
669669
lView: LView,
670-
control: ɵFormFieldDirective<unknown>,
670+
fieldDirective: ɵFormFieldDirective<unknown>,
671671
): void {
672672
const element = getNativeByTNode(tNode, lView) as NativeControlElement;
673673
const renderer = lView[RENDERER];
674-
const state = control.state();
674+
const state = fieldDirective.state();
675675
const bindings = getControlBindings(lView);
676676

677677
const controlValue = state.controlValue();
@@ -680,15 +680,34 @@ function updateNativeControl(
680680
}
681681

682682
for (const key of CONTROL_BINDING_KEYS) {
683-
const value = state[key]?.();
683+
const inputName = CONTROL_BINDING_NAMES[key];
684+
const value = getValue(fieldDirective, state, key, inputName);
684685
if (controlBindingUpdated(bindings, key, value)) {
685-
const inputName = CONTROL_BINDING_NAMES[key];
686686
updateNativeProperty(tNode, renderer, element, key, value, inputName);
687687
updateDirectiveInputs(tNode, lView, inputName, value);
688688
}
689689
}
690690
}
691691

692+
/**
693+
* Gets the value of the given field state key to bind to the form UI control associated with the
694+
* given form field directive. In most cases this value is obtained by reading it off the field state.
695+
* However, in the case of the `errors` property, we only want to report parse errors that are
696+
* relevant for this particular UI control, so we read from the directive instead, which contains
697+
* only the filtered errors that pertain to this binding.
698+
*/
699+
function getValue(
700+
fieldDirective: ɵFormFieldDirective<unknown>,
701+
state: ɵFieldState<unknown>,
702+
fieldStateKey: ControlBindingKeys,
703+
inputName: ControlBindingKeys,
704+
): unknown {
705+
if (inputName === 'errors') {
706+
return fieldDirective[fieldStateKey as 'errors']();
707+
}
708+
return state[fieldStateKey]?.();
709+
}
710+
692711
/**
693712
* Updates all directive inputs with the given name on the given node.
694713
*

0 commit comments

Comments
 (0)