Skip to content

Commit a6a0347

Browse files
mmalerbamattrbeck
authored andcommitted
refactor(forms): extract common parser logic (#66917)
native controls and custom controls (via transformedValue) use similar parsing logic but it needs to be hooked up differently. This commit extracts the common bits into a shared piece. PR Close #66917
1 parent 30f0914 commit a6a0347

File tree

5 files changed

+100
-41
lines changed

5 files changed

+100
-41
lines changed

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,12 @@ export type NgValidationError = RequiredValidationError | MinValidationError | M
433433
// @public
434434
export type OneOrMany<T> = T | readonly T[];
435435

436+
// @public
437+
export interface ParseResult<TValue> {
438+
readonly errors?: readonly ValidationError.WithoutFieldTree[];
439+
readonly value?: TValue;
440+
}
441+
436442
// @public
437443
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;
438444

@@ -595,10 +601,7 @@ export function transformedValue<TValue, TRaw>(value: ModelSignal<TValue>, optio
595601
// @public
596602
export interface TransformedValueOptions<TValue, TRaw> {
597603
format: (value: TValue) => TRaw;
598-
parse: (rawValue: TRaw) => {
599-
value?: TValue;
600-
errors?: readonly ValidationError.WithoutFieldTree[];
601-
};
604+
parse: (rawValue: TRaw) => ParseResult<TValue>;
602605
}
603606

604607
// @public

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

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,23 @@ import {
1414
type WritableSignal,
1515
} from '@angular/core';
1616
import {FORM_FIELD_PARSE_ERRORS} from '../directive/parse_errors';
17+
import {createParser} from '../util/parser';
1718
import type {ValidationError} from './rules';
1819

20+
/**
21+
* Result of parsing a raw value into a model value.
22+
*/
23+
export interface ParseResult<TValue> {
24+
/**
25+
* The parsed value, if parsing was successful.
26+
*/
27+
readonly value?: TValue;
28+
/**
29+
* Errors encountered during parsing, if any.
30+
*/
31+
readonly errors?: readonly ValidationError.WithoutFieldTree[];
32+
}
33+
1934
/**
2035
* Options for `transformedValue`.
2136
*
@@ -29,7 +44,7 @@ export interface TransformedValueOptions<TValue, TRaw> {
2944
* - `value`: The parsed model value. If `undefined`, the model will not be updated.
3045
* - `errors`: Any parse errors encountered. If `undefined`, no errors are reported.
3146
*/
32-
parse: (rawValue: TRaw) => {value?: TValue; errors?: readonly ValidationError.WithoutFieldTree[]};
47+
parse: (rawValue: TRaw) => ParseResult<TValue>;
3348

3449
/**
3550
* Format the model value into the raw value.
@@ -92,38 +107,30 @@ export function transformedValue<TValue, TRaw>(
92107
options: TransformedValueOptions<TValue, TRaw>,
93108
): TransformedValueSignal<TRaw> {
94109
const {parse, format} = options;
110+
const parser = createParser(value, value.set, parse);
95111

96-
const parseErrors = linkedSignal({
97-
source: value,
98-
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
99-
});
100-
const rawValue = linkedSignal(() => format(value()));
101-
112+
// Wire up the parse errors from the parser to the form field.
102113
const formFieldParseErrors = inject(FORM_FIELD_PARSE_ERRORS, {self: true, optional: true});
103114
if (formFieldParseErrors) {
104-
formFieldParseErrors.set(parseErrors);
115+
formFieldParseErrors.set(parser.errors);
105116
}
106117

107118
// Create the result signal with overridden set/update and a `parseErrors` property.
119+
const rawValue = linkedSignal(() => format(value()));
108120
const result = rawValue as WritableSignal<TRaw> & {
109121
parseErrors: Signal<readonly ValidationError.WithoutFieldTree[]>;
110122
};
123+
result.parseErrors = parser.errors;
111124
const originalSet = result.set.bind(result);
112125

126+
// Notify the parser when `set` or `update` is called on the raw value
113127
result.set = (newRawValue: TRaw) => {
114-
const result = parse(newRawValue);
115-
parseErrors.set(result.errors ?? []);
116-
if (result.value !== undefined) {
117-
value.set(result.value);
118-
}
128+
parser.setRawValue(newRawValue);
119129
originalSet(newRawValue);
120130
};
121-
122131
result.update = (updateFn: (value: TRaw) => TRaw) => {
123132
result.set(updateFn(rawValue()));
124133
};
125134

126-
result.parseErrors = parseErrors.asReadonly();
127-
128135
return result;
129136
}

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

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88
import {
9-
linkedSignal,
109
type ɵControlDirectiveHost as ControlDirectiveHost,
1110
type Signal,
1211
type WritableSignal,
1312
} from '@angular/core';
1413
import type {ValidationError} from '../api/rules';
14+
import {createParser} from '../util/parser';
1515
import {
1616
bindingUpdated,
1717
CONTROL_BINDING_NAMES,
@@ -32,22 +32,21 @@ export function nativeControlCreate(
3232
): () => void {
3333
let updateMode = false;
3434
const input = parent.nativeFormElement;
35-
// TODO: (perf) ok to always create this?
36-
const parseErrors = linkedSignal({
37-
source: () => parent.state().value(),
38-
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
39-
});
40-
parseErrorsSource.set(parseErrors);
4135

42-
host.listenToDom('input', () => {
43-
const state = parent.state();
44-
const {value, errors} = getNativeControlValue(input, state.value);
45-
parseErrors.set(errors ?? []);
46-
if (value !== undefined) {
47-
state.controlValue.set(value);
48-
}
49-
});
36+
// TODO: (perf) ok to always create this?
37+
const parser = createParser(
38+
// Read from the model value
39+
() => parent.state().value(),
40+
// Write to the buffered "control value"
41+
(rawValue: unknown) => parent.state().controlValue.set(rawValue),
42+
// Our parse function doesn't care about the raw value that gets passed in,
43+
// It just reads the newly parsed value directly off the input element.
44+
() => getNativeControlValue(input, parent.state().value),
45+
);
5046

47+
parseErrorsSource.set(parser.errors);
48+
// Pass undefined as the raw value since the parse function doesn't care about it.
49+
host.listenToDom('input', () => parser.setRawValue(undefined));
5150
host.listenToDom('blur', () => parent.state().markAsTouched());
5251

5352
parent.registerAsBinding();

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {type Renderer2, untracked} from '@angular/core';
1010
import {NativeInputParseError, WithoutFieldTree} from '../api/rules';
11+
import type {ParseResult} from '../api/transformed_value';
1112

1213
/**
1314
* Supported native control element types.
@@ -49,11 +50,6 @@ export function isTextualFormElement(element: HTMLElement): boolean {
4950
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
5051
}
5152

52-
export interface NativeControlValue {
53-
value?: unknown;
54-
errors?: readonly WithoutFieldTree<NativeInputParseError>[];
55-
}
56-
5753
/**
5854
* Returns the value from a native control element.
5955
*
@@ -69,7 +65,7 @@ export interface NativeControlValue {
6965
export function getNativeControlValue(
7066
element: NativeFormControl,
7167
currentValue: () => unknown,
72-
): NativeControlValue {
68+
): ParseResult<unknown> {
7369
let modelValue: unknown;
7470

7571
if (element.validity.badInput) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 {type Signal, linkedSignal} from '@angular/core';
10+
import type {ValidationError} from '../api/rules';
11+
import type {ParseResult} from '../api/transformed_value';
12+
13+
/**
14+
* An object that handles parsing raw UI values into model values.
15+
*/
16+
export interface Parser<TRaw> {
17+
/**
18+
* Errors encountered during the last parse attempt.
19+
*/
20+
errors: Signal<readonly ValidationError.WithoutFieldTree[]>;
21+
/**
22+
* Parses the given raw value and updates the underlying model value if successful.
23+
*/
24+
setRawValue: (rawValue: TRaw) => void;
25+
}
26+
27+
/**
28+
* Creates a {@link Parser} that synchronizes a raw value with an underlying model value.
29+
*
30+
* @param getValue Function to get the current model value.
31+
* @param setValue Function to update the model value.
32+
* @param parse Function to parse the raw value into a {@link ParseResult}.
33+
* @returns A {@link Parser} instance.
34+
*/
35+
export function createParser<TValue, TRaw>(
36+
getValue: () => TValue,
37+
setValue: (value: TValue) => void,
38+
parse: (raw: TRaw) => ParseResult<TValue>,
39+
): Parser<TRaw> {
40+
const errors = linkedSignal({
41+
source: getValue,
42+
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
43+
});
44+
45+
const setRawValue = (rawValue: TRaw) => {
46+
const result = parse(rawValue);
47+
errors.set(result.errors ?? []);
48+
if (result.value !== undefined) {
49+
setValue(result.value);
50+
}
51+
};
52+
53+
return {errors: errors.asReadonly(), setRawValue};
54+
}

0 commit comments

Comments
 (0)