From ebae211add37700858adeb8fc5d87bf503a59721 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sat, 17 Jan 2026 20:32:29 -0800 Subject: [PATCH] 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`. --- goldens/public-api/forms/signals/index.api.md | 101 +++++---- .../core/src/render3/instructions/control.ts | 51 +++-- .../core/src/render3/interfaces/control.ts | 7 + .../compat/src/compat_validation_state.ts | 6 +- packages/forms/signals/src/api/control.ts | 33 ++- .../signals/src/api/form_field_directive.ts | 34 ++- .../api/rules/validation/validation_errors.ts | 90 +++++--- packages/forms/signals/src/api/structure.ts | 4 +- packages/forms/signals/src/api/types.ts | 8 +- packages/forms/signals/src/field/node.ts | 8 +- packages/forms/signals/src/field/submit.ts | 4 +- .../forms/signals/src/field/validation.ts | 35 +-- packages/forms/signals/src/schema/logic.ts | 13 +- .../forms/signals/src/schema/logic_node.ts | 14 +- packages/forms/signals/test/node/BUILD.bazel | 4 +- .../signals/test/node/field_node.spec.ts | 4 +- .../signals/test/node/parse_errors.spec.ts | 201 ++++++++++++++++++ .../test/node/validation_status.spec.ts | 2 +- .../test/web/form_field_directive.spec.ts | 32 +-- .../forms/signals/test/web/interop.spec.ts | 7 +- 20 files changed, 497 insertions(+), 161 deletions(-) create mode 100644 packages/forms/signals/test/node/parse_errors.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 72a4dcd52d9..cd46b2eecad 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -109,10 +109,10 @@ export interface DisabledReason { export function email(path: SchemaPath, config?: BaseValidatorConfig): void; // @public -export function emailError(options: WithField): EmailValidationError; +export function emailError(options: WithFieldTree): EmailValidationError; // @public -export function emailError(options?: ValidationErrorOptions): WithoutField; +export function emailError(options?: ValidationErrorOptions): WithoutFieldTree; // @public export class EmailValidationError extends _NgValidationError { @@ -129,8 +129,8 @@ export interface FieldState; // (undocumented) - readonly errors: Signal; - readonly errorSummary: Signal; + readonly errors: Signal; + readonly errorSummary: Signal; focusBoundControl(options?: FocusOptions): void; readonly formFieldBindings: Signal[]>; readonly hidden: Signal; @@ -147,7 +147,7 @@ export interface FieldState = (() => [TModel] extends [AbstractControl] ? CompatFieldState : FieldState) & ([TModel] extends [AbstractControl] ? object : [TModel] extends [ReadonlyArray] ? ReadonlyArrayLike> : TModel extends Record ? Subfields : object); // @public -export type FieldValidator = LogicFn, TPathKind>; +export type FieldValidator = LogicFn, TPathKind>; // @public export function form(model: WritableSignal): FieldTree; @@ -162,7 +162,7 @@ export function form(model: WritableSignal, schema: SchemaOrSche export const FORM_FIELD: InjectionToken>; // @public -export interface FormCheckboxControl extends FormUiControl { +export interface FormCheckboxControl extends FormUiControl { readonly checked: ModelSignal; readonly value?: undefined; } @@ -176,24 +176,27 @@ export class FormField { }; // (undocumented) readonly element: HTMLElement; - focus(options?: FocusOptions): void; + readonly errors: Signal; // (undocumented) - readonly formField: i0.InputSignal>; + readonly fieldTree: i0.InputSignal>; + focus(options?: FocusOptions): void; protected getOrCreateNgControl(): InteropNgControl; // (undocumented) readonly injector: Injector; - registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; + registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; // (undocumented) - readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl] ? CompatFieldState : FieldState>; + readonly state: Signal<[T] extends [_angular_forms.AbstractControl] ? CompatFieldState : FieldState>; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration, "[formField]", never, { "formField": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>; + static ɵdir: i0.ɵɵDirectiveDeclaration, "[formField]", ["formField"], { "fieldTree": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration, never>; } // @public (undocumented) -export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { +export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { focus?(options?: FocusOptions): void; + // (undocumented) + readonly parseErrors?: Signal; } // @public @@ -205,11 +208,11 @@ export interface FormOptions { } // @public -export interface FormUiControl { +export interface FormUiControl { readonly dirty?: InputSignal | InputSignalWithTransform; readonly disabled?: InputSignal | InputSignalWithTransform; - readonly disabledReasons?: InputSignal[]> | InputSignalWithTransform[], unknown>; - readonly errors?: InputSignal[]> | InputSignalWithTransform[], unknown>; + readonly disabledReasons?: InputSignal[]> | InputSignalWithTransform[], unknown>; + readonly errors?: InputSignal | InputSignalWithTransform; focus?(options?: FocusOptions): void; readonly hidden?: InputSignal | InputSignalWithTransform; readonly invalid?: InputSignal | InputSignalWithTransform; @@ -218,6 +221,7 @@ export interface FormUiControl { readonly min?: InputSignal | InputSignalWithTransform; readonly minLength?: InputSignal | InputSignalWithTransform; readonly name?: InputSignal | InputSignalWithTransform; + readonly parseErrors?: Signal; readonly pattern?: InputSignal | InputSignalWithTransform; readonly pending?: InputSignal | InputSignalWithTransform; readonly readonly?: InputSignal | InputSignalWithTransform; @@ -226,7 +230,7 @@ export interface FormUiControl { } // @public -export interface FormValueControl extends FormUiControl { +export interface FormValueControl extends FormUiControl { readonly checked?: undefined; readonly value: ModelSignal; } @@ -271,19 +275,19 @@ export function max(path: SchemaPath export const MAX_LENGTH: MetadataKey, number | undefined, number | undefined>; // @public -export function maxError(max: number, options: WithField): MaxValidationError; +export function maxError(max: number, options: WithFieldTree): MaxValidationError; // @public -export function maxError(max: number, options?: ValidationErrorOptions): WithoutField; +export function maxError(max: number, options?: ValidationErrorOptions): WithoutFieldTree; // @public export function maxLength(path: SchemaPath, maxLength: number | LogicFn, config?: BaseValidatorConfig): void; // @public -export function maxLengthError(maxLength: number, options: WithField): MaxLengthValidationError; +export function maxLengthError(maxLength: number, options: WithFieldTree): MaxLengthValidationError; // @public -export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField; +export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutFieldTree; // @public export class MaxLengthValidationError extends _NgValidationError { @@ -350,19 +354,19 @@ export function min, number | undefined, number | undefined>; // @public -export function minError(min: number, options: WithField): MinValidationError; +export function minError(min: number, options: WithFieldTree): MinValidationError; // @public -export function minError(min: number, options?: ValidationErrorOptions): WithoutField; +export function minError(min: number, options?: ValidationErrorOptions): WithoutFieldTree; // @public export function minLength(path: SchemaPath, minLength: number | LogicFn, config?: BaseValidatorConfig): void; // @public -export function minLengthError(minLength: number, options: WithField): MinLengthValidationError; +export function minLengthError(minLength: number, options: WithFieldTree): MinLengthValidationError; // @public -export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField; +export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutFieldTree; // @public export class MinLengthValidationError extends _NgValidationError { @@ -416,10 +420,10 @@ export const PATTERN: MetadataKey, RegExp | undefined, RegExp[] export function pattern(path: SchemaPath, pattern: RegExp | LogicFn, config?: BaseValidatorConfig): void; // @public -export function patternError(pattern: RegExp, options: WithField): PatternValidationError; +export function patternError(pattern: RegExp, options: WithFieldTree): PatternValidationError; // @public -export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField; +export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutFieldTree; // @public export class PatternValidationError extends _NgValidationError { @@ -451,10 +455,10 @@ export function required(pat }): void; // @public -export function requiredError(options: WithField): RequiredValidationError; +export function requiredError(options: WithFieldTree): RequiredValidationError; // @public -export function requiredError(options?: ValidationErrorOptions): WithoutField; +export function requiredError(options?: ValidationErrorOptions): WithoutFieldTree; // @public export class RequiredValidationError extends _NgValidationError { @@ -520,10 +524,10 @@ export interface SignalFormsConfig { } // @public -export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField): StandardSchemaValidationError; +export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithFieldTree): StandardSchemaValidationError; // @public -export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField; +export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutFieldTree; // @public export class StandardSchemaValidationError extends _NgValidationError { @@ -545,7 +549,7 @@ export type Subfields = { export function submit(form: FieldTree, action: (form: FieldTree) => Promise): Promise; // @public -export type TreeValidationResult = ValidationSuccess | OneOrMany; +export type TreeValidationResult = ValidationSuccess | OneOrMany; // @public export type TreeValidator = LogicFn; @@ -573,14 +577,28 @@ export interface ValidationError { // @public (undocumented) export namespace ValidationError { - export interface WithField extends ValidationError { + // @deprecated (undocumented) + export type WithField = WithFieldTree; + export interface WithFieldTree extends ValidationError { readonly fieldTree: FieldTree; + // (undocumented) + readonly formField?: FormField; } - export interface WithOptionalField extends ValidationError { + export interface WithFormField extends WithFieldTree { + // (undocumented) + readonly formField: FormField; + } + // @deprecated (undocumented) + export type WithOptionalField = WithOptionalFieldTree; + export interface WithOptionalFieldTree extends ValidationError { readonly fieldTree?: FieldTree; } - export interface WithoutField extends ValidationError { + // @deprecated (undocumented) + export type WithoutField = WithoutFieldTree; + export interface WithoutFieldTree extends ValidationError { readonly fieldTree?: never; + // (undocumented) + readonly formField?: never; } } @@ -593,18 +611,27 @@ export type ValidationSuccess = null | undefined | void; // @public export type Validator = LogicFn; +// @public @deprecated (undocumented) +export type WithField = WithFieldTree; + // @public -export type WithField = T & { +export type WithFieldTree = T & { fieldTree: FieldTree; }; +// @public @deprecated (undocumented) +export type WithOptionalField = WithOptionalFieldTree; + // @public -export type WithOptionalField = Omit & { +export type WithOptionalFieldTree = Omit & { fieldTree?: FieldTree; }; +// @public @deprecated (undocumented) +export type WithoutField = WithoutFieldTree; + // @public -export type WithoutField = T & { +export type WithoutFieldTree = T & { fieldTree: never; }; diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index a5472509667..c1a2268a03d 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -568,19 +568,19 @@ function updateControlClasses(lView: LView, tNode: TNode, control: ɵFormFieldDi * @param lView The `LView` that contains the custom form control. * @param componentIndex The index of the custom form control component in the `LView`. * @param modelName The name of the model property on the custom form control. - * @param control The `ɵFormFieldDirective` instance. + * @param fieldDirective The `ɵFormFieldDirective` instance. */ function updateCustomControl( tNode: TNode, lView: LView, - control: ɵFormFieldDirective, + fieldDirective: ɵFormFieldDirective, modelName: string, ) { const tView = getTView(); const directiveIndex = tNode.customControlIndex; const directive = lView[directiveIndex]; const directiveDef = tView.data[directiveIndex] as DirectiveDef<{}>; - const state = control.state(); + const state = fieldDirective.state(); const bindings = getControlBindings(lView); // Bind custom form control model ('value' or 'checked'). @@ -595,9 +595,9 @@ function updateCustomControl( // Bind remaining field state properties. for (const key of CONTROL_BINDING_KEYS) { - const value = state[key]?.(); + const inputName = CONTROL_BINDING_NAMES[key]; + const value = getValue(fieldDirective, state, key, inputName); if (controlBindingUpdated(bindings, key, value)) { - const inputName = CONTROL_BINDING_NAMES[key]; updateDirectiveInputs(tNode, lView, inputName, value); // If the host node is a native control, we can bind field state properties to native @@ -614,16 +614,16 @@ function updateCustomControl( * * @param tNode The `TNode` of the form control. * @param lView The `LView` that contains the native form control. - * @param control The `ɵFormFieldDirective` instance. + * @param fieldDirective The `ɵFormFieldDirective` instance. */ function updateInteropControl( tNode: TNode, lView: LView, - control: ɵFormFieldDirective, + fieldDirective: ɵFormFieldDirective, ): void { - const interopControl = control.ɵinteropControl!; + const interopControl = fieldDirective.ɵinteropControl!; const bindings = getControlBindings(lView); - const state = control.state(); + const state = fieldDirective.state(); const isNative = (tNode.flags & TNodeFlags.isNativeControl) !== 0; const element = isNative ? (getNativeByTNode(tNode, lView) as NativeControlElement) : null; @@ -637,9 +637,9 @@ function updateInteropControl( } for (const key of CONTROL_BINDING_KEYS) { - const value = state[key]?.(); + const inputName = CONTROL_BINDING_NAMES[key]; + const value = getValue(fieldDirective, state, key, inputName); if (controlBindingUpdated(bindings, key, value)) { - const inputName = CONTROL_BINDING_NAMES[key]; const didUpdateInput = updateDirectiveInputs(tNode, lView, inputName, value); // We never fallback to the native property for `disabled` since it's handled directly by @@ -662,16 +662,16 @@ function updateInteropControl( * * @param tNode The `TNode` of the native form control. * @param lView The `LView` that contains the native form control. - * @param control The `ɵFormFieldDirective` instance. + * @param fieldDirective The `ɵFormFieldDirective` instance. */ function updateNativeControl( tNode: TNode, lView: LView, - control: ɵFormFieldDirective, + fieldDirective: ɵFormFieldDirective, ): void { const element = getNativeByTNode(tNode, lView) as NativeControlElement; const renderer = lView[RENDERER]; - const state = control.state(); + const state = fieldDirective.state(); const bindings = getControlBindings(lView); const controlValue = state.controlValue(); @@ -680,15 +680,34 @@ function updateNativeControl( } for (const key of CONTROL_BINDING_KEYS) { - const value = state[key]?.(); + const inputName = CONTROL_BINDING_NAMES[key]; + const value = getValue(fieldDirective, state, key, inputName); if (controlBindingUpdated(bindings, key, value)) { - const inputName = CONTROL_BINDING_NAMES[key]; updateNativeProperty(tNode, renderer, element, key, value, inputName); updateDirectiveInputs(tNode, lView, inputName, value); } } } +/** + * Gets the value of the given field state key to bind to the form UI control associated with the + * given form field directive. In most cases this value is obtained by reading it off the field state. + * However, in the case of the `errors` property, we only want to report parse errors that are + * relevant for this particular UI control, so we read from the directive instead, which contains + * only the filtered errors that pertain to this binding. + */ +function getValue( + fieldDirective: ɵFormFieldDirective, + state: ɵFieldState, + fieldStateKey: ControlBindingKeys, + inputName: ControlBindingKeys, +): unknown { + if (inputName === 'errors') { + return fieldDirective[fieldStateKey as 'errors'](); + } + return state[fieldStateKey]?.(); +} + /** * Updates all directive inputs with the given name on the given node. * diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index dbee9a86a8a..b6b258f1679 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -39,6 +39,13 @@ export interface ɵFormFieldDirective { /** Options for the control. */ readonly classes: ReadonlyArray]>; + /** + * A subset of the field state errors that apply specifically to this binding directive. + * While standard validation errors produced by the schema apply to all binding directives that + * bind that particular field, parse errors belong to a specific binding directive. + */ + readonly errors: Signal; + /** A reference to the interoperable control, if one is present. */ readonly ɵinteropControl: ɵInteropControl | undefined; diff --git a/packages/forms/signals/compat/src/compat_validation_state.ts b/packages/forms/signals/compat/src/compat_validation_state.ts index e17982cd44c..6ca800ebc20 100644 --- a/packages/forms/signals/compat/src/compat_validation_state.ts +++ b/packages/forms/signals/compat/src/compat_validation_state.ts @@ -32,6 +32,8 @@ export class CompatValidationState implements ValidationState { readonly invalid: Signal; readonly valid: Signal; + readonly parseErrors: Signal = computed(() => []); + constructor(options: CompatFieldNodeOptions) { this.syncValid = getControlStatusSignal(options, (c: AbstractControl) => c.status === 'VALID'); this.errors = getControlStatusSignal(options, extractNestedReactiveErrors); @@ -46,8 +48,8 @@ export class CompatValidationState implements ValidationState { }); } - asyncErrors: Signal<(ValidationError.WithField | 'pending')[]> = EMPTY_ARRAY_SIGNAL; - errorSummary: Signal = EMPTY_ARRAY_SIGNAL; + asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = EMPTY_ARRAY_SIGNAL; + errorSummary: Signal = EMPTY_ARRAY_SIGNAL; // Those are irrelevant for compat mode, as it has no children rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL; diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts index a9edb75e7f7..c94922ea9a5 100644 --- a/packages/forms/signals/src/api/control.ts +++ b/packages/forms/signals/src/api/control.ts @@ -6,9 +6,15 @@ * found in the LICENSE file at https://angular.dev/license */ -import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core'; +import { + InputSignal, + InputSignalWithTransform, + ModelSignal, + OutputRef, + type Signal, +} from '@angular/core'; import type {FormFieldBindingOptions} from './form_field_directive'; -import {ValidationError, type WithOptionalField} from './rules/validation/validation_errors'; +import {ValidationError, type WithOptionalFieldTree} from './rules/validation/validation_errors'; import type {DisabledReason} from './types'; /** @@ -17,14 +23,14 @@ import type {DisabledReason} from './types'; * @category control * @experimental 21.0.0 */ -export interface FormUiControl { +export interface FormUiControl { /** * An input to receive the errors for the field. If implemented, the `Field` directive will * automatically bind errors from the bound field to this input. */ readonly errors?: - | InputSignal[]> - | InputSignalWithTransform[], unknown>; + | InputSignal + | InputSignalWithTransform; /** * An input to receive the disabled status for the field. If implemented, the `Field` directive * will automatically bind the disabled status from the bound field to this input. @@ -35,8 +41,8 @@ export interface FormUiControl { * directive will automatically bind the disabled reason from the bound field to this input. */ readonly disabledReasons?: - | InputSignal[]> - | InputSignalWithTransform[], unknown>; + | InputSignal[]> + | InputSignalWithTransform[], unknown>; /** * An input to receive the readonly status for the field. If implemented, the `Field` directive * will automatically bind the readonly status from the bound field to this input. @@ -116,6 +122,12 @@ export interface FormUiControl { readonly pattern?: | InputSignal | InputSignalWithTransform; + /** + * A signal containing the current parse errors for the control. + * This allows the control to communicate to the form that there are additional validation errors + * beyond those produced by the schema, due to being unable to parse the user's input. + */ + readonly parseErrors?: Signal; /** * Focuses the UI control. * @@ -130,7 +142,7 @@ export interface FormUiControl { // However, we don't want to add it as an actual `extends` clause to avoid confusing users. type Check = T; type FormUiControlImplementsFormFieldBindingOptions = Check< - FormUiControl extends FormFieldBindingOptions ? true : false + FormUiControl extends FormFieldBindingOptions ? true : false >; /** @@ -146,7 +158,7 @@ type FormUiControlImplementsFormFieldBindingOptions = Check< * @category control * @experimental 21.0.0 */ -export interface FormValueControl extends FormUiControl { +export interface FormValueControl extends FormUiControl { /** * The value is the only required property in this contract. A component that wants to integrate * with the `Field` directive via this contract, *must* provide a `model()` that will be kept in @@ -175,7 +187,8 @@ export interface FormValueControl extends FormUiControl { * @category control * @experimental 21.0.0 */ -export interface FormCheckboxControl extends FormUiControl { +// TODO: should we make this generic extends `boolean | null` so people can use `null` for parse error? +export interface FormCheckboxControl extends FormUiControl { /** * The checked is the only required property in this contract. A component that wants to integrate * with the `Field` directive, *must* provide a `model()` that will be kept in sync with the diff --git a/packages/forms/signals/src/api/form_field_directive.ts b/packages/forms/signals/src/api/form_field_directive.ts index 94e568624f0..3f51942df30 100644 --- a/packages/forms/signals/src/api/form_field_directive.ts +++ b/packages/forms/signals/src/api/form_field_directive.ts @@ -22,6 +22,7 @@ import { ɵcontrolUpdate as updateControlBinding, ɵCONTROL, ɵInteropControl, + type Signal, type ɵFormFieldBindingOptions, type ɵFormFieldDirective, } from '@angular/core'; @@ -30,9 +31,10 @@ import {InteropNgControl} from '../controls/interop_ng_control'; import {SignalFormsErrorCode} from '../errors'; import {SIGNAL_FORMS_CONFIG} from '../field/di'; import type {FieldNode} from '../field/node'; +import type {ValidationError} from './rules'; import type {FieldTree} from './types'; -export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { +export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { /** * Focuses the binding. * @@ -40,6 +42,8 @@ export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { * asked to focus this binding. */ focus?(options?: FocusOptions): void; + + readonly parseErrors?: Signal; } /** @@ -81,6 +85,7 @@ const controlInstructions = { */ @Directive({ selector: '[formField]', + exportAs: 'formField', providers: [ {provide: FORM_FIELD, useExisting: FormField}, {provide: NgControl, useFactory: () => inject(FormField).getOrCreateNgControl()}, @@ -92,9 +97,28 @@ const controlInstructions = { export class FormField { readonly element = inject>(ElementRef).nativeElement; readonly injector = inject(Injector); - readonly formField = input.required>(); - readonly state = computed(() => this.formField()()); - private readonly bindingOptions = signal(undefined); + readonly fieldTree = input.required>({alias: 'formField'}); + readonly state = computed(() => this.fieldTree()()); + private readonly bindingOptions = signal | undefined>(undefined); + + /** @internal */ + readonly parseErrors = computed( + () => + this.bindingOptions() + ?.parseErrors?.() + .map((err) => ({ + ...err, + fieldTree: this.fieldTree(), + formField: this as FormField, + })) ?? [], + ); + + /** Errors associated with this form field. */ + readonly errors = computed(() => + this.state() + .errors() + .filter((err) => !err.formField || err.formField === this), + ); readonly [ɵCONTROL] = controlInstructions; @@ -131,7 +155,7 @@ export class FormField { * This method should be called at most once for a given `FormField`. A `FormField` placed on a * custom control (`FormUiControl`) automatically registers that custom control as a binding. */ - registerAsBinding(bindingOptions?: FormFieldBindingOptions) { + registerAsBinding(bindingOptions?: FormFieldBindingOptions) { if (untracked(this.bindingOptions)) { throw new RuntimeError( SignalFormsErrorCode.BINDING_ALREADY_REGISTERED, diff --git a/packages/forms/signals/src/api/rules/validation/validation_errors.ts b/packages/forms/signals/src/api/rules/validation/validation_errors.ts index 7033fbe134d..c5188ed5890 100644 --- a/packages/forms/signals/src/api/rules/validation/validation_errors.ts +++ b/packages/forms/signals/src/api/rules/validation/validation_errors.ts @@ -7,7 +7,8 @@ */ import type {StandardSchemaV1} from '@standard-schema/spec'; -import {FieldTree} from '../../types'; +import type {FormField} from '../../form_field_directive'; +import type {FieldTree} from '../../types'; /** * Options used to create a `ValidationError`. @@ -23,7 +24,9 @@ interface ValidationErrorOptions { * * @experimental 21.0.0 */ -export type WithField = T & {fieldTree: FieldTree}; +export type WithFieldTree = T & {fieldTree: FieldTree}; +/** @deprecated Use `WithFieldTree` instead */ +export type WithField = WithFieldTree; /** * A type that allows the given type `T` to optionally have a `field` property. @@ -31,7 +34,9 @@ export type WithField = T & {fieldTree: FieldTree}; * * @experimental 21.0.0 */ -export type WithOptionalField = Omit & {fieldTree?: FieldTree}; +export type WithOptionalFieldTree = Omit & {fieldTree?: FieldTree}; +/** @deprecated Use `WithOptionalFieldTree` instead */ +export type WithOptionalField = WithOptionalFieldTree; /** * A type that ensures the given type `T` does not have a `field` property. @@ -39,7 +44,9 @@ export type WithOptionalField = Omit & {fieldTree?: FieldTree * * @experimental 21.0.0 */ -export type WithoutField = T & {fieldTree: never}; +export type WithoutFieldTree = T & {fieldTree: never}; +/** @deprecated Use `WithoutFieldTree` instead */ +export type WithoutField = WithoutFieldTree; /** * Create a required error associated with the target field @@ -47,7 +54,9 @@ export type WithoutField = T & {fieldTree: never}; * * @experimental 21.0.0 */ -export function requiredError(options: WithField): RequiredValidationError; +export function requiredError( + options: WithFieldTree, +): RequiredValidationError; /** * Create a required error * @param options The optional validation error options @@ -57,10 +66,10 @@ export function requiredError(options: WithField): Requi */ export function requiredError( options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function requiredError( options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new RequiredValidationError(options); } @@ -74,7 +83,7 @@ export function requiredError( */ export function minError( min: number, - options: WithField, + options: WithFieldTree, ): MinValidationError; /** * Create a min value error @@ -87,11 +96,11 @@ export function minError( export function minError( min: number, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function minError( min: number, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new MinValidationError(min, options); } @@ -105,7 +114,7 @@ export function minError( */ export function maxError( max: number, - options: WithField, + options: WithFieldTree, ): MaxValidationError; /** * Create a max value error @@ -118,11 +127,11 @@ export function maxError( export function maxError( max: number, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function maxError( max: number, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new MaxValidationError(max, options); } @@ -136,7 +145,7 @@ export function maxError( */ export function minLengthError( minLength: number, - options: WithField, + options: WithFieldTree, ): MinLengthValidationError; /** * Create a minLength error @@ -149,11 +158,11 @@ export function minLengthError( export function minLengthError( minLength: number, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function minLengthError( minLength: number, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new MinLengthValidationError(minLength, options); } @@ -167,7 +176,7 @@ export function minLengthError( */ export function maxLengthError( maxLength: number, - options: WithField, + options: WithFieldTree, ): MaxLengthValidationError; /** * Create a maxLength error @@ -180,11 +189,11 @@ export function maxLengthError( export function maxLengthError( maxLength: number, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function maxLengthError( maxLength: number, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new MaxLengthValidationError(maxLength, options); } @@ -198,7 +207,7 @@ export function maxLengthError( */ export function patternError( pattern: RegExp, - options: WithField, + options: WithFieldTree, ): PatternValidationError; /** * Create a pattern matching error @@ -211,11 +220,11 @@ export function patternError( export function patternError( pattern: RegExp, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function patternError( pattern: RegExp, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new PatternValidationError(pattern, options); } @@ -226,7 +235,7 @@ export function patternError( * @category validation * @experimental 21.0.0 */ -export function emailError(options: WithField): EmailValidationError; +export function emailError(options: WithFieldTree): EmailValidationError; /** * Create an email format error * @param options The optional validation error options @@ -234,10 +243,12 @@ export function emailError(options: WithField): EmailVal * @category validation * @experimental 21.0.0 */ -export function emailError(options?: ValidationErrorOptions): WithoutField; export function emailError( options?: ValidationErrorOptions, -): WithOptionalField { +): WithoutFieldTree; +export function emailError( + options?: ValidationErrorOptions, +): WithOptionalFieldTree { return new EmailValidationError(options); } @@ -251,7 +262,7 @@ export function emailError( */ export function standardSchemaError( issue: StandardSchemaV1.Issue, - options: WithField, + options: WithFieldTree, ): StandardSchemaValidationError; /** * Create a standard schema issue error @@ -264,11 +275,11 @@ export function standardSchemaError( export function standardSchemaError( issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions, -): WithoutField; +): WithoutFieldTree; export function standardSchemaError( issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions, -): WithOptionalField { +): WithOptionalFieldTree { return new StandardSchemaValidationError(issue, options); } @@ -294,14 +305,24 @@ export interface ValidationError { export declare namespace ValidationError { /** - * Validation error with a field. + * Validation error with an associated field tree. * * This is returned from field state, e.g., catField.errors() would be of a list of errors with * `field: catField` bound to state. */ - export interface WithField extends ValidationError { + export interface WithFieldTree extends ValidationError { /** The field associated with this error. */ readonly fieldTree: FieldTree; + readonly formField?: FormField; + } + /** @deprecated Use `ValidationError.WithFieldTree` instead */ + export type WithField = WithFieldTree; + + /** + * Validation error with an associated field tree and specific form field binding. + */ + export interface WithFormField extends WithFieldTree { + readonly formField: FormField; } /** @@ -310,20 +331,25 @@ export declare namespace ValidationError { * This is generally used in places where the result might have a field. * e.g., as a result of a `validateTree`, or when handling form submission. */ - export interface WithOptionalField extends ValidationError { + export interface WithOptionalFieldTree extends ValidationError { /** The field associated with this error. */ readonly fieldTree?: FieldTree; } + /** @deprecated Use `ValidationError.WithOptionalFieldTree` instead */ + export type WithOptionalField = WithOptionalFieldTree; /** * Validation error with no field. * * This is used to strongly enforce that fields are not allowed in validation result. */ - export interface WithoutField extends ValidationError { + export interface WithoutFieldTree extends ValidationError { /** The field associated with this error. */ readonly fieldTree?: never; + readonly formField?: never; } + /** @deprecated Use `ValidationError.WithoutFieldTree` instead */ + export type WithoutField = WithoutFieldTree; } /** diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index 8cf03ff903d..f0943863d09 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -393,12 +393,12 @@ export async function submit( */ function setSubmissionErrors( submittedField: FieldNode, - errors: OneOrMany, + errors: OneOrMany, ) { if (!isArray(errors)) { errors = [errors]; } - const errorsByField = new Map(); + const errorsByField = new Map(); for (const error of errors) { const errorWithField = addDefaultField(error, submittedField.fieldProxy); const field = errorWithField.fieldTree() as FieldNode; diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index 5bc2089e4c1..27a62b9a8a0 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -94,7 +94,7 @@ export type ValidationSuccess = null | undefined | void; * @experimental 21.0.0 */ export type TreeValidationResult< - E extends ValidationError.WithOptionalField = ValidationError.WithOptionalField, + E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree, > = ValidationSuccess | OneOrMany; /** @@ -240,12 +240,12 @@ export interface FieldState< */ readonly hidden: Signal; readonly disabledReasons: Signal; - readonly errors: Signal; + readonly errors: Signal; /** * A signal containing the {@link errors} of the field and its descendants. */ - readonly errorSummary: Signal; + readonly errorSummary: Signal; /** * A signal indicating whether the field's value is currently valid. @@ -542,7 +542,7 @@ export type LogicFn */ export type FieldValidator = LogicFn< TValue, - ValidationResult, + ValidationResult, TPathKind >; diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index b906ce04dc2..5b6c8bc9724 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -145,11 +145,15 @@ export class FieldNode implements FieldState { return this.structure.keyInParent; } - get errors(): Signal { + get errors(): Signal { return this.validationState.errors; } - get errorSummary(): Signal { + get parseErrors(): Signal { + return this.validationState.parseErrors; + } + + get errorSummary(): Signal { return this.validationState.errorSummary; } diff --git a/packages/forms/signals/src/field/submit.ts b/packages/forms/signals/src/field/submit.ts index bbe7a515381..64adb254911 100644 --- a/packages/forms/signals/src/field/submit.ts +++ b/packages/forms/signals/src/field/submit.ts @@ -21,12 +21,12 @@ export class FieldSubmitState { readonly selfSubmitting = signal(false); /** Submission errors that are associated with this field. */ - readonly submissionErrors: WritableSignal; + readonly submissionErrors: WritableSignal; constructor(private readonly node: FieldNode) { this.submissionErrors = linkedSignal({ source: this.node.structure.value, - computation: () => [] as readonly ValidationError.WithField[], + computation: () => [] as readonly ValidationError.WithFieldTree[], }); } diff --git a/packages/forms/signals/src/field/validation.ts b/packages/forms/signals/src/field/validation.ts index 936e471c6e1..90238599926 100644 --- a/packages/forms/signals/src/field/validation.ts +++ b/packages/forms/signals/src/field/validation.ts @@ -35,7 +35,7 @@ export interface ValidationState { * The full set of synchronous tree errors visible to this field. This includes ones that are * targeted at a descendant field rather than at this field. */ - rawSyncTreeErrors: Signal; + rawSyncTreeErrors: Signal; /** * The full set of synchronous errors for this field, including synchronous tree errors and submission @@ -43,7 +43,7 @@ export interface ValidationState { * the perspective of the field state they are either there or not, they are never in a pending * state. */ - syncErrors: Signal; + syncErrors: Signal; /** * Whether the field is considered valid according solely to its synchronous validators. @@ -56,24 +56,26 @@ export interface ValidationState { * targeted at a descendant field rather than at this field, as well as sentinel 'pending' values * indicating that the validator is still running and an error could still occur. */ - rawAsyncErrors: Signal<(ValidationError.WithField | 'pending')[]>; + rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; /** * The asynchronous tree errors visible to this field that are specifically targeted at this field * rather than a descendant. This also includes all 'pending' sentinel values, since those could * theoretically result in errors for this field. */ - asyncErrors: Signal<(ValidationError.WithField | 'pending')[]>; + asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]>; /** * The combined set of all errors that currently apply to this field. */ - errors: Signal; + errors: Signal; + + parseErrors: Signal; /** * The combined set of all errors that currently apply to this field and its descendants. */ - errorSummary: Signal; + errorSummary: Signal; /** * Whether this field has any asynchronous validators still pending. @@ -151,7 +153,7 @@ export class FieldValidationState implements ValidationState { * The full set of synchronous tree errors visible to this field. This includes ones that are * targeted at a descendant field rather than at this field. */ - readonly rawSyncTreeErrors: Signal = computed(() => { + readonly rawSyncTreeErrors: Signal = computed(() => { if (this.shouldSkipValidation()) { return []; } @@ -168,7 +170,7 @@ export class FieldValidationState implements ValidationState { * added. From the perspective of the field state they are either there or not, they are never in a * pending state. */ - readonly syncErrors: Signal = computed(() => { + readonly syncErrors: Signal = computed(() => { // Short-circuit running validators if validation doesn't apply to this field. if (this.shouldSkipValidation()) { return []; @@ -202,7 +204,7 @@ export class FieldValidationState implements ValidationState { * The synchronous tree errors visible to this field that are specifically targeted at this field * rather than a descendant. */ - readonly syncTreeErrors: Signal = computed(() => + readonly syncTreeErrors: Signal = computed(() => this.rawSyncTreeErrors().filter((err) => err.fieldTree === this.node.fieldProxy), ); @@ -211,7 +213,7 @@ export class FieldValidationState implements ValidationState { * targeted at a descendant field rather than at this field, as well as sentinel 'pending' values * indicating that the validator is still running and an error could still occur. */ - readonly rawAsyncErrors: Signal<(ValidationError.WithField | 'pending')[]> = computed(() => { + readonly rawAsyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(() => { // Short-circuit running validators if validation doesn't apply to this field. if (this.shouldSkipValidation()) { return []; @@ -230,7 +232,7 @@ export class FieldValidationState implements ValidationState { * rather than a descendant. This also includes all 'pending' sentinel values, since those could * theoretically result in errors for this field. */ - readonly asyncErrors: Signal<(ValidationError.WithField | 'pending')[]> = computed(() => { + readonly asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = computed(() => { if (this.shouldSkipValidation()) { return []; } @@ -239,10 +241,15 @@ export class FieldValidationState implements ValidationState { ); }); + readonly parseErrors: Signal = computed(() => + this.node.formFieldBindings().flatMap((field) => field.parseErrors()), + ); + /** * The combined set of all errors that currently apply to this field. */ readonly errors = computed(() => [ + ...this.parseErrors(), ...this.syncErrors(), ...this.asyncErrors().filter((err) => err !== 'pending'), ]); @@ -354,7 +361,7 @@ function normalizeErrors(error: T | readonly T[]): r * @param fieldTree The default field to add * @returns The passed in error(s), with its field set. */ -export function addDefaultField( +export function addDefaultField( error: E, fieldTree: FieldTree, ): E & {fieldTree: FieldTree}; @@ -368,10 +375,10 @@ export function addDefaultField( ): ValidationResult}> { if (isArray(errors)) { for (const error of errors) { - (error as ɵWritable).fieldTree ??= fieldTree; + (error as ɵWritable).fieldTree ??= fieldTree; } } else if (errors) { - (errors as ɵWritable).fieldTree ??= fieldTree; + (errors as ɵWritable).fieldTree ??= fieldTree; } return errors as ValidationResult}>; } diff --git a/packages/forms/signals/src/schema/logic.ts b/packages/forms/signals/src/schema/logic.ts index c1bf877e1df..c9d8813c0df 100644 --- a/packages/forms/signals/src/schema/logic.ts +++ b/packages/forms/signals/src/schema/logic.ts @@ -254,11 +254,11 @@ export class LogicContainer { /** Logic that determines if the field is read-only. */ readonly readonly: BooleanOrLogic; /** Logic that produces synchronous validation errors for the field. */ - readonly syncErrors: ArrayMergeIgnoreLogic; + readonly syncErrors: ArrayMergeIgnoreLogic; /** Logic that produces synchronous validation errors for the field's subtree. */ - readonly syncTreeErrors: ArrayMergeIgnoreLogic; + readonly syncTreeErrors: ArrayMergeIgnoreLogic; /** Logic that produces asynchronous validation results (errors or 'pending'). */ - readonly asyncErrors: ArrayMergeIgnoreLogic; + readonly asyncErrors: ArrayMergeIgnoreLogic; /** A map of metadata keys to the `AbstractLogic` instances that compute their values. */ private readonly metadata = new Map< MetadataKey, @@ -274,9 +274,10 @@ export class LogicContainer { this.hidden = new BooleanOrLogic(predicates); this.disabledReasons = new ArrayMergeLogic(predicates); this.readonly = new BooleanOrLogic(predicates); - this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); - this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); - this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull( + this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull(predicates); + this.syncTreeErrors = + ArrayMergeIgnoreLogic.ignoreNull(predicates); + this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull( predicates, ); } diff --git a/packages/forms/signals/src/schema/logic_node.ts b/packages/forms/signals/src/schema/logic_node.ts index b1d2d4767a0..c06469eb468 100644 --- a/packages/forms/signals/src/schema/logic_node.ts +++ b/packages/forms/signals/src/schema/logic_node.ts @@ -9,7 +9,7 @@ import {ɵRuntimeError as RuntimeError} from '@angular/core'; import {SignalFormsErrorCode} from '../errors'; -import type {ValidationError, MetadataKey} from '../api/rules'; +import type {MetadataKey, ValidationError} from '../api/rules'; import type {AsyncValidationResult, DisabledReason, LogicFn, ValidationResult} from '../api/types'; import {setBoundPathDepthForResolution} from '../field/resolution'; import {type BoundPredicate, DYNAMIC, LogicContainer, type Predicate} from './logic'; @@ -105,19 +105,19 @@ export class LogicNodeBuilder extends AbstractLogicNodeBuilder { } override addSyncErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.getCurrent().addSyncErrorRule(logic); } override addSyncTreeErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.getCurrent().addSyncTreeErrorRule(logic); } override addAsyncErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.getCurrent().addAsyncErrorRule(logic); } @@ -235,19 +235,19 @@ class NonMergeableLogicNodeBuilder extends AbstractLogicNodeBuilder { } override addSyncErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } override addSyncTreeErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } override addAsyncErrorRule( - logic: LogicFn>, + logic: LogicFn>, ): void { this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth)); } diff --git a/packages/forms/signals/test/node/BUILD.bazel b/packages/forms/signals/test/node/BUILD.bazel index 57dad12c25b..73cb527a676 100644 --- a/packages/forms/signals/test/node/BUILD.bazel +++ b/packages/forms/signals/test/node/BUILD.bazel @@ -1,6 +1,6 @@ -load("//tools:defaults.bzl", "ts_project", "zoneless_jasmine_test") +load("//tools:defaults.bzl", "ng_project", "zoneless_jasmine_test") -ts_project( +ng_project( name = "test_lib", testonly = True, srcs = glob(["**/*.spec.ts"]), diff --git a/packages/forms/signals/test/node/field_node.spec.ts b/packages/forms/signals/test/node/field_node.spec.ts index da605b9417f..6b1dd389071 100644 --- a/packages/forms/signals/test/node/field_node.spec.ts +++ b/packages/forms/signals/test/node/field_node.spec.ts @@ -1120,7 +1120,7 @@ describe('FieldNode', () => { cat, (p) => { validateTree(p, ({value, fieldTreeOf}) => { - const errors: ValidationError.WithOptionalField[] = []; + const errors: ValidationError.WithOptionalFieldTree[] = []; if (value().name.length > 8) { errors.push({kind: 'long_name', fieldTree: fieldTreeOf(p.name)}); } @@ -1152,7 +1152,7 @@ describe('FieldNode', () => { cat, (p) => { validateTree(p, ({value, fieldTreeOf}) => { - const errors: ValidationError.WithOptionalField[] = []; + const errors: ValidationError.WithOptionalFieldTree[] = []; if (value().name.length > 8) { errors.push({kind: 'long_name', fieldTree: fieldTreeOf(p.name)}); } diff --git a/packages/forms/signals/test/node/parse_errors.spec.ts b/packages/forms/signals/test/node/parse_errors.spec.ts new file mode 100644 index 00000000000..4849d297320 --- /dev/null +++ b/packages/forms/signals/test/node/parse_errors.spec.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ApplicationRef, + Component, + computed, + inject, + input, + linkedSignal, + model, + signal, +} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import { + form, + FormField, + type FieldTree, + type FormValueControl, + type ValidationError, +} from '../../public_api'; + +describe('parse errors', () => { + it('should show parse error', async () => { + @Component({ + selector: 'custom-control', + template: ``, + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly parseErrors = computed(() => (this.value() === 'ERROR' ? [{kind: 'parse'}] : [])); + } + + @Component({ + imports: [CustomControl, FormField], + template: ``, + }) + class TestCmp { + state = signal(''); + f = form(this.state); + } + + const cmp = await act(() => TestBed.createComponent(TestCmp).componentInstance); + expect(cmp.f().errors().length).toBe(0); + + await act(() => cmp.state.set('ERROR')); + expect(cmp.f().errors().length).toBe(1); + expect(cmp.f().errors()[0]).toEqual(jasmine.objectContaining({kind: 'parse'})); + }); + + it('should only pass parse errors through to the originating custom control', async () => { + @Component({ + imports: [TestNumberInput, FormField], + template: ` + + + `, + }) + class TestCmp { + state = signal(5); + f = form(this.state); + } + + const testEl = (await act(() => TestBed.createComponent(TestCmp))).nativeElement as HTMLElement; + const input1: HTMLInputElement = testEl.querySelector('#input1 input')!; + const input2: HTMLInputElement = testEl.querySelector('#input2 input')!; + + input1.value = 'joe'; + await act(() => input1.dispatchEvent(new Event('input'))); + let errors1 = [...testEl.querySelectorAll('#input1 .error')].map((el) => el.textContent); + let errors2 = [...testEl.querySelectorAll('#input2 .error')].map((el) => el.textContent); + expect(errors1).toEqual(['joe is not numeric']); + expect(errors2).toEqual([]); + + input2.value = 'bob'; + await act(() => input2.dispatchEvent(new Event('input'))); + errors1 = [...testEl.querySelectorAll('#input1 .error')].map((el) => el.textContent); + errors2 = [...testEl.querySelectorAll('#input2 .error')].map((el) => el.textContent); + expect(errors1).toEqual(['joe is not numeric']); + expect(errors2).toEqual(['bob is not numeric']); + }); + + it('should have all errors on field state', async () => { + @Component({ + imports: [TestNumberInput, FormField], + template: ` + + + `, + }) + class TestCmp { + state = signal(5); + f = form(this.state); + } + + const fix = await act(() => TestBed.createComponent(TestCmp)); + const comp = fix.componentInstance; + const input1: HTMLInputElement = fix.nativeElement.querySelector('#input1 input')!; + const input2: HTMLInputElement = fix.nativeElement.querySelector('#input2 input')!; + + input1.value = 'joe'; + await act(() => input1.dispatchEvent(new Event('input'))); + input2.value = 'bob'; + await act(() => input2.dispatchEvent(new Event('input'))); + expect(comp.f().errors()).toEqual([ + jasmine.objectContaining({message: 'joe is not numeric'}), + jasmine.objectContaining({message: 'bob is not numeric'}), + ]); + expect(comp.f().errorSummary()).toEqual([ + jasmine.objectContaining({message: 'joe is not numeric'}), + jasmine.objectContaining({message: 'bob is not numeric'}), + ]); + }); + + it('should allow pass-through style control to register parse errors', async () => { + @Component({ + selector: 'custom-control', + template: ``, + }) + class CustomControl { + readonly fieldTree = input.required>({alias: 'formField'}); + readonly formField = inject(FormField, {optional: true, self: true}); + + constructor() { + this.formField?.registerAsBinding({ + parseErrors: computed(() => + this.fieldTree()().value() === 'ERROR' ? [{kind: 'parse'}] : [], + ), + }); + } + } + + @Component({ + imports: [CustomControl, FormField], + template: ``, + }) + class TestCmp { + state = signal(''); + f = form(this.state); + } + + const cmp = await act(() => TestBed.createComponent(TestCmp).componentInstance); + expect(cmp.f().errors().length).toBe(0); + + await act(() => cmp.state.set('ERROR')); + expect(cmp.f().errors().length).toBe(1); + expect(cmp.f().errors()[0]).toEqual(jasmine.objectContaining({kind: 'parse'})); + }); +}); + +@Component({ + selector: 'test-number-input', + template: ` + + @for (e of errors(); track $index) { +

{{ e.message }}

+ } + `, +}) +class TestNumberInput implements FormValueControl { + readonly value = model.required(); + readonly errors = input([]); + readonly parseErrors = computed(() => this.parsedResult().errors ?? []); + + protected rawValue = linkedSignal(() => this.format(this.value())); + private parsedResult = computed(() => this.parse(this.rawValue())); + + private format(value: number | null) { + if (value === null || Number.isNaN(value)) return ''; + return value.toString(); + } + + private parse( + rawValue: string, + ): {value: number | null; errors?: never} | {value?: never; errors: ValidationError[]} { + if (rawValue === '') return {value: null}; + const value = Number(rawValue); + if (Number.isNaN(value)) { + return {errors: [{kind: 'parse', message: `${rawValue} is not numeric`}]}; + } + return {value}; + } + + protected write(rawValue: string) { + this.rawValue.set(rawValue); + const result = this.parsedResult(); + this.value.set(result.value === undefined ? NaN : result.value); + this.rawValue.set(rawValue); + } +} + +async function act(fn: () => T): Promise { + const result = fn(); + await TestBed.inject(ApplicationRef).whenStable(); + return result; +} diff --git a/packages/forms/signals/test/node/validation_status.spec.ts b/packages/forms/signals/test/node/validation_status.spec.ts index c6800250690..c5658658138 100644 --- a/packages/forms/signals/test/node/validation_status.spec.ts +++ b/packages/forms/signals/test/node/validation_status.spec.ts @@ -27,7 +27,7 @@ function validateValue(value: string): ValidationError[] { function validateValueForChild( value: string, fieldTree: FieldTree | undefined, -): ValidationError.WithOptionalField[] { +): ValidationError.WithOptionalFieldTree[] { return value === 'INVALID' ? [{kind: 'custom', fieldTree: fieldTree}] : []; } diff --git a/packages/forms/signals/test/web/form_field_directive.spec.ts b/packages/forms/signals/test/web/form_field_directive.spec.ts index f8c52bb67ca..6fd401d803a 100644 --- a/packages/forms/signals/test/web/form_field_directive.spec.ts +++ b/packages/forms/signals/test/web/form_field_directive.spec.ts @@ -51,7 +51,7 @@ import { type FormCheckboxControl, type FormValueControl, type ValidationError, - type WithOptionalField, + type WithOptionalFieldTree, } from '../../public_api'; @Component({ @@ -414,7 +414,8 @@ describe('field directive', () => { }) class CustomControl implements FormValueControl { readonly value = model.required(); - readonly disabledReasons = input.required[]>(); + readonly disabledReasons = + input.required[]>(); } @Component({ @@ -439,7 +440,8 @@ describe('field directive', () => { it('should bind to directive input on native control', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly disabledReasons = input.required[]>(); + readonly disabledReasons = + input.required[]>(); } @Component({ @@ -470,7 +472,8 @@ describe('field directive', () => { it('should bind to directive input on custom control', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly disabledReasons = input.required[]>(); + readonly disabledReasons = + input.required[]>(); } @Component({ @@ -510,7 +513,8 @@ describe('field directive', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { readonly value = model.required(); - readonly disabledReasons = input.required[]>(); + readonly disabledReasons = + input.required[]>(); } @Component({ @@ -545,7 +549,7 @@ describe('field directive', () => { }) class CustomControl implements FormValueControl { readonly value = model.required(); - readonly errors = input.required[]>(); + readonly errors = input.required[]>(); } @Component({ @@ -570,7 +574,7 @@ describe('field directive', () => { it('should bind to directive input on native control', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly errors = input.required[]>(); + readonly errors = input.required[]>(); } @Component({ @@ -597,7 +601,7 @@ describe('field directive', () => { it('should bind to directive input on custom control', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly errors = input.required[]>(); + readonly errors = input.required[]>(); } @Component({ @@ -633,7 +637,7 @@ describe('field directive', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { readonly value = model.required(); - readonly errors = input.required[]>(); + readonly errors = input.required[]>(); } @Component({ @@ -2541,10 +2545,10 @@ describe('field directive', () => { readonly pattern = input([], { transform: (v: unknown) => (Array.isArray(v) ? v : []), }); - readonly errors = input[], unknown>([], { + readonly errors = input[], unknown>([], { transform: (v: unknown) => (Array.isArray(v) ? v : []), }); - readonly disabledReasons = input[], unknown>( + readonly disabledReasons = input[], unknown>( [], { transform: (v: unknown) => (Array.isArray(v) ? v : []), @@ -3346,7 +3350,7 @@ describe('field directive', () => { }) class CustomInput implements FormValueControl { value = model(''); - disabledReasons = input[]>([]); + disabledReasons = input[]>([]); } @Component({ @@ -3571,8 +3575,8 @@ describe('field directive', () => { }) class CustomInput implements FormValueControl { value = model(''); - disabledReasons = input[]>([]); - errors = input[]>([]); + disabledReasons = input[]>([]); + errors = input[]>([]); } @Component({ diff --git a/packages/forms/signals/test/web/interop.spec.ts b/packages/forms/signals/test/web/interop.spec.ts index 2d9f326864c..3a26909a290 100644 --- a/packages/forms/signals/test/web/interop.spec.ts +++ b/packages/forms/signals/test/web/interop.spec.ts @@ -35,7 +35,7 @@ import { requiredError, validateAsync, ValidationError, - WithOptionalField, + WithOptionalFieldTree, } from '@angular/forms/signals'; describe('ControlValueAccessor', () => { @@ -451,7 +451,8 @@ describe('ControlValueAccessor', () => { it('should bind to directive input', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly disabledReasons = input.required[]>(); + readonly disabledReasons = + input.required[]>(); } @Component({ @@ -484,7 +485,7 @@ describe('ControlValueAccessor', () => { it('should bind to directive input', () => { @Directive({selector: '[testDir]'}) class TestDir { - readonly errors = input.required[]>(); + readonly errors = input.required[]>(); } @Component({