Skip to content

Commit adfb831

Browse files
authored
fix(forms): simplify design of parse errors
Reoves the `parseErrors` property on `FormUiControl` and instead introduces a new utility `transformedValue` that automatically handles synchronizing the raw value and model value using the given `parse` and `format` functions. It also automates the reporting of `parseErrors` to the `FormField`, simplifying the API surface
1 parent f50ec42 commit adfb831

File tree

7 files changed

+331
-129
lines changed

7 files changed

+331
-129
lines changed

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,6 @@ export class FormField<T> {
203203
// @public (undocumented)
204204
export interface FormFieldBindingOptions {
205205
readonly focus?: (focusOptions?: FocusOptions) => void;
206-
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
207206
}
208207

209208
// @public
@@ -234,7 +233,6 @@ export interface FormUiControl<TValue> {
234233
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
235234
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
236235
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
237-
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
238236
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
239237
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
240238
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
@@ -564,6 +562,23 @@ export function submit<TModel>(form: FieldTree<TModel>, options?: FormSubmitOpti
564562
// @public (undocumented)
565563
export function submit<TModel>(form: FieldTree<TModel>, action: FormSubmitOptions<TModel>['action']): Promise<boolean>;
566564

565+
// @public
566+
export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, options: TransformedValueOptions<TValue, TRaw>): TransformedValueSignal<TRaw>;
567+
568+
// @public
569+
export interface TransformedValueOptions<TValue, TRaw> {
570+
format: (value: TValue) => TRaw;
571+
parse: (rawValue: TRaw) => {
572+
value?: TValue;
573+
errors?: readonly ValidationError.WithoutFieldTree[];
574+
};
575+
}
576+
577+
// @public
578+
export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> {
579+
readonly parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
580+
}
581+
567582
// @public
568583
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;
569584

packages/forms/signals/public_api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
*/
1414
export * from './src/api/control';
1515
export * from './src/api/di';
16-
export * from './src/directive/form_field_directive';
1716
export * from './src/api/rules';
1817
export * from './src/api/rules/debounce';
1918
export * from './src/api/rules/metadata';
2019
export * from './src/api/rules/validation/validation_errors';
2120
export * from './src/api/structure';
21+
export * from './src/api/transformed_value';
2222
export * from './src/api/types';
23+
export * from './src/directive/form_field_directive';

packages/forms/signals/src/api/control.ts

Lines changed: 1 addition & 7 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 {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef, Signal} from '@angular/core';
9+
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
1010
import type {FormFieldBindingOptions} from '../directive/form_field_directive';
1111
import type {ValidationError, WithOptionalFieldTree} from './rules/validation/validation_errors';
1212
import type {DisabledReason} from './types';
@@ -117,12 +117,6 @@ export interface FormUiControl<TValue> {
117117
readonly pattern?:
118118
| InputSignal<readonly RegExp[]>
119119
| InputSignalWithTransform<readonly RegExp[], unknown>;
120-
/**
121-
* A signal containing the current parse errors for the control.
122-
* This allows the control to communicate to the form that there are additional validation errors
123-
* beyond those produced by the schema, due to being unable to parse the user's input.
124-
*/
125-
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
126120
/**
127121
* Focuses the UI control.
128122
*
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
inject,
11+
linkedSignal,
12+
type ModelSignal,
13+
type Signal,
14+
signal,
15+
type WritableSignal,
16+
} from '@angular/core';
17+
import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors';
18+
import type {ValidationError} from './rules';
19+
20+
/**
21+
* Options for `transformedValue`.
22+
*
23+
* @experimental 21.2.0
24+
*/
25+
export interface TransformedValueOptions<TValue, TRaw> {
26+
/**
27+
* Parse the raw value into the model value.
28+
*
29+
* Should return an object containing the parsed result, which may contain:
30+
* - `value`: The parsed model value. If `undefined`, the model will not be updated.
31+
* - `errors`: Any parse errors encountered. If `undefined`, no errors are reported.
32+
*/
33+
parse: (rawValue: TRaw) => {value?: TValue; errors?: readonly ValidationError.WithoutFieldTree[]};
34+
35+
/**
36+
* Format the model value into the raw value.
37+
*/
38+
format: (value: TValue) => TRaw;
39+
}
40+
41+
/**
42+
* A writable signal representing a "raw" UI value that is synchronized with a model signal
43+
* via parse/format transformations.
44+
*
45+
* @category control
46+
* @experimental 21.2.0
47+
*/
48+
export interface TransformedValueSignal<TRaw> extends WritableSignal<TRaw> {
49+
/**
50+
* The current parse errors resulting from the last transformation.
51+
*/
52+
readonly parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
53+
}
54+
55+
/**
56+
* Creates a writable signal representing a "raw" UI value that is transformed to/from a model
57+
* value via `parse` and `format` functions.
58+
*
59+
* This utility simplifies the creation of custom form controls that parse a user-facing value
60+
* representation into an underlying model value. For example, a numeric input that displays and
61+
* accepts string values but stores a number.
62+
*
63+
* @param value The model signal to synchronize with.
64+
* @param options Configuration including `parse` and `format` functions.
65+
* @returns A `TransformedValueSignal` representing the raw value with parse error tracking.
66+
* @experimental 21.2.0
67+
*
68+
* @example
69+
* ```ts
70+
* @Component({
71+
* selector: 'number-input',
72+
* template: `<input [value]="rawValue()" (input)="rawValue.set($event.target.value)" />`,
73+
* })
74+
* export class NumberInput implements FormValueControl<number | null> {
75+
* readonly value = model.required<number | null>();
76+
*
77+
* protected readonly rawValue = transformedValue(this.value, {
78+
* parse: (val) => {
79+
* if (val === '') return {value: null};
80+
* const num = Number(val);
81+
* if (Number.isNaN(num)) {
82+
* return {errors: [{kind: 'parse', message: `${val} is not numeric`}]};
83+
* }
84+
* return {value: num};
85+
* },
86+
* format: (val) => val?.toString() ?? '',
87+
* });
88+
* }
89+
* ```
90+
*/
91+
export function transformedValue<TValue, TRaw>(
92+
value: ModelSignal<TValue>,
93+
options: TransformedValueOptions<TValue, TRaw>,
94+
): TransformedValueSignal<TRaw> {
95+
const {parse, format} = options;
96+
97+
const parseErrors = signal<readonly ValidationError.WithoutFieldTree[]>([]);
98+
const rawValue = linkedSignal(() => format(value()));
99+
100+
const formFieldParseErrors = inject(FORM_FIELD_PARSE_ERRORS, {self: true, optional: true});
101+
if (formFieldParseErrors) {
102+
formFieldParseErrors.set(parseErrors);
103+
}
104+
105+
// Create the result signal with overridden set/update and a `parseErrors` property.
106+
const result = rawValue as WritableSignal<TRaw> & {
107+
parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
108+
};
109+
const originalSet = result.set.bind(result);
110+
111+
result.set = (newRawValue: TRaw) => {
112+
const result = parse(newRawValue);
113+
parseErrors.set(result.errors ?? []);
114+
if (result.value !== undefined) {
115+
value.set(result.value);
116+
}
117+
originalSet(newRawValue);
118+
};
119+
120+
result.update = (updateFn: (value: TRaw) => TRaw) => {
121+
result.set(updateFn(rawValue()));
122+
};
123+
124+
result.parseErrors = parseErrors.asReadonly();
125+
126+
return result;
127+
}

packages/forms/signals/src/directive/form_field_directive.ts

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
*/
88

99
import {
10+
afterRenderEffect,
1011
computed,
12+
type ɵControlDirectiveHost as ControlDirectiveHost,
1113
DestroyRef,
1214
Directive,
1315
effect,
@@ -17,30 +19,29 @@ import {
1719
Injector,
1820
input,
1921
Renderer2,
20-
type ɵControlDirectiveHost as ControlDirectiveHost,
21-
afterRenderEffect,
22-
type Signal,
2322
ɵRuntimeError as RuntimeError,
23+
type Signal,
2424
signal,
2525
untracked,
2626
} from '@angular/core';
2727
import {type ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
28+
import {type ValidationError} from '../api/rules';
29+
import type {FieldTree} from '../api/types';
2830
import {InteropNgControl} from '../controls/interop_ng_control';
2931
import {RuntimeErrorCode} from '../errors';
3032
import {SIGNAL_FORMS_CONFIG} from '../field/di';
3133
import type {FieldNode} from '../field/node';
32-
import type {FieldTree} from '../api/types';
34+
import {bindingUpdated, type ControlBindingKey, createBindings} from './bindings';
35+
import {customControlCreate} from './control_custom';
36+
import {cvaControlCreate} from './control_cva';
37+
import {nativeControlCreate} from './control_native';
3338
import {
3439
isNativeFormElement,
3540
isNumericFormElement,
3641
isTextualFormElement,
3742
type NativeFormControl,
3843
} from './native';
39-
import {bindingUpdated, type ControlBindingKey, createBindings} from './bindings';
40-
import {cvaControlCreate} from './control_cva';
41-
import {customControlCreate} from './control_custom';
42-
import {nativeControlCreate} from './control_native';
43-
import {type ValidationError} from '../api/rules';
44+
import {FORM_FIELD_PARSE_ERRORS} from './parse_errors';
4445

4546
export const ɵNgFieldDirective: unique symbol = Symbol();
4647

@@ -52,11 +53,6 @@ export interface FormFieldBindingOptions {
5253
* asked to focus this binding.
5354
*/
5455
readonly focus?: (focusOptions?: FocusOptions) => void;
55-
56-
/**
57-
* Source of parse errors for this binding.
58-
*/
59-
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
6056
}
6157

6258
/**
@@ -94,6 +90,10 @@ export const FORM_FIELD = new InjectionToken<FormField<unknown>>(
9490
providers: [
9591
{provide: FORM_FIELD, useExisting: FormField},
9692
{provide: NgControl, useFactory: () => inject(FormField).interopNgControl},
93+
{
94+
provide: FORM_FIELD_PARSE_ERRORS,
95+
useFactory: () => inject(FormField).parseErrorsSource,
96+
},
9797
],
9898
})
9999
export class FormField<T> {
@@ -251,10 +251,6 @@ export class FormField<T> {
251251
this.focuser = bindingOptions.focus;
252252
}
253253

254-
if (bindingOptions?.parseErrors) {
255-
this.parseErrorsSource.set(bindingOptions.parseErrors);
256-
}
257-
258254
// Register this control on the field state it is currently bound to. We do this at the end of
259255
// initialization so that it only runs if we are actually syncing with this control
260256
// (as opposed to just passing the field state through to its `formField` input).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken, type Signal, type WritableSignal} from '@angular/core';
10+
import type {ValidationError} from '../api/rules';
11+
12+
/**
13+
* DI token that provides a writable signal that controls can use to set the signal of parse errors
14+
* for the `FormField` directive. Used internally by `transformedValue`.
15+
*
16+
* @experimental 21.2.0
17+
*/
18+
export const FORM_FIELD_PARSE_ERRORS = new InjectionToken<
19+
WritableSignal<Signal<readonly ValidationError.WithoutFieldTree[]> | undefined>
20+
>(typeof ngDevMode !== 'undefined' && ngDevMode ? 'FORM_FIELD_PARSE_ERRORS' : '');

0 commit comments

Comments
 (0)