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`.
This commit is contained in:
Miles Malerba
2026-01-17 20:32:29 -08:00
committed by Andrew Scott
parent 085784e4f1
commit ebae211add
20 changed files with 497 additions and 161 deletions
+64 -37
View File
@@ -109,10 +109,10 @@ export interface DisabledReason {
export function email<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
// @public
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
export function emailError(options: WithFieldTree<ValidationErrorOptions>): EmailValidationError;
// @public
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
export function emailError(options?: ValidationErrorOptions): WithoutFieldTree<EmailValidationError>;
// @public
export class EmailValidationError extends _NgValidationError {
@@ -129,8 +129,8 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
// (undocumented)
readonly disabledReasons: Signal<readonly DisabledReason[]>;
// (undocumented)
readonly errors: Signal<ValidationError.WithField[]>;
readonly errorSummary: Signal<ValidationError.WithField[]>;
readonly errors: Signal<ValidationError.WithFieldTree[]>;
readonly errorSummary: Signal<ValidationError.WithFieldTree[]>;
focusBoundControl(options?: FocusOptions): void;
readonly formFieldBindings: Signal<readonly FormField<unknown>[]>;
readonly hidden: Signal<boolean>;
@@ -147,7 +147,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
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);
// @public
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutField>, TPathKind>;
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutFieldTree>, TPathKind>;
// @public
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
@@ -162,7 +162,7 @@ export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSche
export const FORM_FIELD: InjectionToken<FormField<unknown>>;
// @public
export interface FormCheckboxControl extends FormUiControl {
export interface FormCheckboxControl extends FormUiControl<boolean> {
readonly checked: ModelSignal<boolean>;
readonly value?: undefined;
}
@@ -176,24 +176,27 @@ export class FormField<T> {
};
// (undocumented)
readonly element: HTMLElement;
focus(options?: FocusOptions): void;
readonly errors: Signal<ValidationError.WithFieldTree[]>;
// (undocumented)
readonly formField: i0.InputSignal<FieldTree<T>>;
readonly fieldTree: i0.InputSignal<FieldTree<T>>;
focus(options?: FocusOptions): void;
protected getOrCreateNgControl(): InteropNgControl;
// (undocumented)
readonly injector: Injector;
registerAsBinding(bindingOptions?: FormFieldBindingOptions): void;
registerAsBinding(bindingOptions?: FormFieldBindingOptions<T>): void;
// (undocumented)
readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
readonly state: Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", never, { "formField": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", ["formField"], { "fieldTree": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<FormField<any>, never>;
}
// @public (undocumented)
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
export interface FormFieldBindingOptions<TValue> extends ɵFormFieldBindingOptions {
focus?(options?: FocusOptions): void;
// (undocumented)
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
}
// @public
@@ -205,11 +208,11 @@ export interface FormOptions {
}
// @public
export interface FormUiControl {
export interface FormUiControl<TValue> {
readonly dirty?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalFieldTree<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalFieldTree<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly ValidationError.WithOptionalFieldTree[]> | InputSignalWithTransform<readonly ValidationError.WithOptionalFieldTree[], unknown>;
focus?(options?: FocusOptions): void;
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
@@ -218,6 +221,7 @@ export interface FormUiControl {
readonly min?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly minLength?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
readonly name?: InputSignal<string> | InputSignalWithTransform<string, unknown>;
readonly parseErrors?: Signal<ValidationError.WithoutFieldTree[]>;
readonly pattern?: InputSignal<readonly RegExp[]> | InputSignalWithTransform<readonly RegExp[], unknown>;
readonly pending?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly readonly?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
@@ -226,7 +230,7 @@ export interface FormUiControl {
}
// @public
export interface FormValueControl<TValue> extends FormUiControl {
export interface FormValueControl<TValue> extends FormUiControl<TValue> {
readonly checked?: undefined;
readonly value: ModelSignal<TValue>;
}
@@ -271,19 +275,19 @@ export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath
export const MAX_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
// @public
export function maxError(max: number, options: WithField<ValidationErrorOptions>): MaxValidationError;
export function maxError(max: number, options: WithFieldTree<ValidationErrorOptions>): MaxValidationError;
// @public
export function maxError(max: number, options?: ValidationErrorOptions): WithoutField<MaxValidationError>;
export function maxError(max: number, options?: ValidationErrorOptions): WithoutFieldTree<MaxValidationError>;
// @public
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;
// @public
export function maxLengthError(maxLength: number, options: WithField<ValidationErrorOptions>): MaxLengthValidationError;
export function maxLengthError(maxLength: number, options: WithFieldTree<ValidationErrorOptions>): MaxLengthValidationError;
// @public
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField<MaxLengthValidationError>;
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutFieldTree<MaxLengthValidationError>;
// @public
export class MaxLengthValidationError extends _NgValidationError {
@@ -350,19 +354,19 @@ export function min<TValue extends number | string | null, TPathKind extends Pat
export const MIN_LENGTH: MetadataKey<Signal<number | undefined>, number | undefined, number | undefined>;
// @public
export function minError(min: number, options: WithField<ValidationErrorOptions>): MinValidationError;
export function minError(min: number, options: WithFieldTree<ValidationErrorOptions>): MinValidationError;
// @public
export function minError(min: number, options?: ValidationErrorOptions): WithoutField<MinValidationError>;
export function minError(min: number, options?: ValidationErrorOptions): WithoutFieldTree<MinValidationError>;
// @public
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;
// @public
export function minLengthError(minLength: number, options: WithField<ValidationErrorOptions>): MinLengthValidationError;
export function minLengthError(minLength: number, options: WithFieldTree<ValidationErrorOptions>): MinLengthValidationError;
// @public
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField<MinLengthValidationError>;
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutFieldTree<MinLengthValidationError>;
// @public
export class MinLengthValidationError extends _NgValidationError {
@@ -416,10 +420,10 @@ export const PATTERN: MetadataKey<Signal<RegExp[]>, RegExp | undefined, RegExp[]
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;
// @public
export function patternError(pattern: RegExp, options: WithField<ValidationErrorOptions>): PatternValidationError;
export function patternError(pattern: RegExp, options: WithFieldTree<ValidationErrorOptions>): PatternValidationError;
// @public
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField<PatternValidationError>;
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutFieldTree<PatternValidationError>;
// @public
export class PatternValidationError extends _NgValidationError {
@@ -451,10 +455,10 @@ export function required<TValue, TPathKind extends PathKind = PathKind.Root>(pat
}): void;
// @public
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
export function requiredError(options: WithFieldTree<ValidationErrorOptions>): RequiredValidationError;
// @public
export function requiredError(options?: ValidationErrorOptions): WithoutField<RequiredValidationError>;
export function requiredError(options?: ValidationErrorOptions): WithoutFieldTree<RequiredValidationError>;
// @public
export class RequiredValidationError extends _NgValidationError {
@@ -520,10 +524,10 @@ export interface SignalFormsConfig {
}
// @public
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithFieldTree<ValidationErrorOptions>): StandardSchemaValidationError;
// @public
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField<StandardSchemaValidationError>;
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutFieldTree<StandardSchemaValidationError>;
// @public
export class StandardSchemaValidationError extends _NgValidationError {
@@ -545,7 +549,7 @@ export type Subfields<TModel> = {
export function submit<TModel>(form: FieldTree<TModel>, action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>): Promise<void>;
// @public
export type TreeValidationResult<E extends ValidationError.WithOptionalField = ValidationError.WithOptionalField> = ValidationSuccess | OneOrMany<E>;
export type TreeValidationResult<E extends ValidationError.WithOptionalFieldTree = ValidationError.WithOptionalFieldTree> = ValidationSuccess | OneOrMany<E>;
// @public
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
@@ -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<unknown>;
// (undocumented)
readonly formField?: FormField<unknown>;
}
export interface WithOptionalField extends ValidationError {
export interface WithFormField extends WithFieldTree {
// (undocumented)
readonly formField: FormField<unknown>;
}
// @deprecated (undocumented)
export type WithOptionalField = WithOptionalFieldTree;
export interface WithOptionalFieldTree extends ValidationError {
readonly fieldTree?: FieldTree<unknown>;
}
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<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult, TPathKind>;
// @public @deprecated (undocumented)
export type WithField<T> = WithFieldTree<T>;
// @public
export type WithField<T> = T & {
export type WithFieldTree<T> = T & {
fieldTree: FieldTree<unknown>;
};
// @public @deprecated (undocumented)
export type WithOptionalField<T> = WithOptionalFieldTree<T>;
// @public
export type WithOptionalField<T> = Omit<T, 'fieldTree'> & {
export type WithOptionalFieldTree<T> = Omit<T, 'fieldTree'> & {
fieldTree?: FieldTree<unknown>;
};
// @public @deprecated (undocumented)
export type WithoutField<T> = WithoutFieldTree<T>;
// @public
export type WithoutField<T> = T & {
export type WithoutFieldTree<T> = T & {
fieldTree: never;
};
@@ -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<unknown>,
fieldDirective: ɵFormFieldDirective<unknown>,
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<unknown>,
fieldDirective: ɵFormFieldDirective<unknown>,
): 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<unknown>,
fieldDirective: ɵFormFieldDirective<unknown>,
): 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<unknown>,
state: ɵFieldState<unknown>,
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.
*
@@ -39,6 +39,13 @@ export interface ɵFormFieldDirective<T> {
/** Options for the control. */
readonly classes: ReadonlyArray<readonly [string, Signal<boolean>]>;
/**
* 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<unknown[]>;
/** A reference to the interoperable control, if one is present. */
readonly ɵinteropControl: ɵInteropControl | undefined;
@@ -32,6 +32,8 @@ export class CompatValidationState implements ValidationState {
readonly invalid: Signal<boolean>;
readonly valid: Signal<boolean>;
readonly parseErrors: Signal<ValidationError.WithFormField[]> = 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<ValidationError.WithField[]> = EMPTY_ARRAY_SIGNAL;
asyncErrors: Signal<(ValidationError.WithFieldTree | 'pending')[]> = EMPTY_ARRAY_SIGNAL;
errorSummary: Signal<ValidationError.WithFieldTree[]> = EMPTY_ARRAY_SIGNAL;
// Those are irrelevant for compat mode, as it has no children
rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL;
+23 -10
View File
@@ -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<TValue> {
/**
* 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<readonly WithOptionalField<ValidationError>[]>
| InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
| InputSignal<readonly ValidationError.WithOptionalFieldTree[]>
| InputSignalWithTransform<readonly ValidationError.WithOptionalFieldTree[], unknown>;
/**
* 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<readonly WithOptionalField<DisabledReason>[]>
| InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
| InputSignal<readonly WithOptionalFieldTree<DisabledReason>[]>
| InputSignalWithTransform<readonly WithOptionalFieldTree<DisabledReason>[], 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<readonly RegExp[]>
| InputSignalWithTransform<readonly RegExp[], unknown>;
/**
* 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<ValidationError.WithoutFieldTree[]>;
/**
* 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 extends true> = T;
type FormUiControlImplementsFormFieldBindingOptions = Check<
FormUiControl extends FormFieldBindingOptions ? true : false
FormUiControl<unknown> extends FormFieldBindingOptions<unknown> ? true : false
>;
/**
@@ -146,7 +158,7 @@ type FormUiControlImplementsFormFieldBindingOptions = Check<
* @category control
* @experimental 21.0.0
*/
export interface FormValueControl<TValue> extends FormUiControl {
export interface FormValueControl<TValue> extends FormUiControl<TValue> {
/**
* 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<TValue> 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<boolean> {
/**
* 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
@@ -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<TValue> 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<ValidationError.WithoutFieldTree[]>;
}
/**
@@ -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<T> {
readonly element = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
readonly injector = inject(Injector);
readonly formField = input.required<FieldTree<T>>();
readonly state = computed(() => this.formField()());
private readonly bindingOptions = signal<FormFieldBindingOptions | undefined>(undefined);
readonly fieldTree = input.required<FieldTree<T>>({alias: 'formField'});
readonly state = computed(() => this.fieldTree()());
private readonly bindingOptions = signal<FormFieldBindingOptions<T> | undefined>(undefined);
/** @internal */
readonly parseErrors = computed<ValidationError.WithFormField[]>(
() =>
this.bindingOptions()
?.parseErrors?.()
.map((err) => ({
...err,
fieldTree: this.fieldTree(),
formField: this as FormField<unknown>,
})) ?? [],
);
/** 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<T> {
* 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<T>) {
if (untracked(this.bindingOptions)) {
throw new RuntimeError(
SignalFormsErrorCode.BINDING_ALREADY_REGISTERED,
@@ -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> = T & {fieldTree: FieldTree<unknown>};
export type WithFieldTree<T> = T & {fieldTree: FieldTree<unknown>};
/** @deprecated Use `WithFieldTree` instead */
export type WithField<T> = WithFieldTree<T>;
/**
* A type that allows the given type `T` to optionally have a `field` property.
@@ -31,7 +34,9 @@ export type WithField<T> = T & {fieldTree: FieldTree<unknown>};
*
* @experimental 21.0.0
*/
export type WithOptionalField<T> = Omit<T, 'fieldTree'> & {fieldTree?: FieldTree<unknown>};
export type WithOptionalFieldTree<T> = Omit<T, 'fieldTree'> & {fieldTree?: FieldTree<unknown>};
/** @deprecated Use `WithOptionalFieldTree` instead */
export type WithOptionalField<T> = WithOptionalFieldTree<T>;
/**
* A type that ensures the given type `T` does not have a `field` property.
@@ -39,7 +44,9 @@ export type WithOptionalField<T> = Omit<T, 'fieldTree'> & {fieldTree?: FieldTree
*
* @experimental 21.0.0
*/
export type WithoutField<T> = T & {fieldTree: never};
export type WithoutFieldTree<T> = T & {fieldTree: never};
/** @deprecated Use `WithoutFieldTree` instead */
export type WithoutField<T> = WithoutFieldTree<T>;
/**
* Create a required error associated with the target field
@@ -47,7 +54,9 @@ export type WithoutField<T> = T & {fieldTree: never};
*
* @experimental 21.0.0
*/
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
export function requiredError(
options: WithFieldTree<ValidationErrorOptions>,
): RequiredValidationError;
/**
* Create a required error
* @param options The optional validation error options
@@ -57,10 +66,10 @@ export function requiredError(options: WithField<ValidationErrorOptions>): Requi
*/
export function requiredError(
options?: ValidationErrorOptions,
): WithoutField<RequiredValidationError>;
): WithoutFieldTree<RequiredValidationError>;
export function requiredError(
options?: ValidationErrorOptions,
): WithOptionalField<RequiredValidationError> {
): WithOptionalFieldTree<RequiredValidationError> {
return new RequiredValidationError(options);
}
@@ -74,7 +83,7 @@ export function requiredError(
*/
export function minError(
min: number,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): MinValidationError;
/**
* Create a min value error
@@ -87,11 +96,11 @@ export function minError(
export function minError(
min: number,
options?: ValidationErrorOptions,
): WithoutField<MinValidationError>;
): WithoutFieldTree<MinValidationError>;
export function minError(
min: number,
options?: ValidationErrorOptions,
): WithOptionalField<MinValidationError> {
): WithOptionalFieldTree<MinValidationError> {
return new MinValidationError(min, options);
}
@@ -105,7 +114,7 @@ export function minError(
*/
export function maxError(
max: number,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): MaxValidationError;
/**
* Create a max value error
@@ -118,11 +127,11 @@ export function maxError(
export function maxError(
max: number,
options?: ValidationErrorOptions,
): WithoutField<MaxValidationError>;
): WithoutFieldTree<MaxValidationError>;
export function maxError(
max: number,
options?: ValidationErrorOptions,
): WithOptionalField<MaxValidationError> {
): WithOptionalFieldTree<MaxValidationError> {
return new MaxValidationError(max, options);
}
@@ -136,7 +145,7 @@ export function maxError(
*/
export function minLengthError(
minLength: number,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): MinLengthValidationError;
/**
* Create a minLength error
@@ -149,11 +158,11 @@ export function minLengthError(
export function minLengthError(
minLength: number,
options?: ValidationErrorOptions,
): WithoutField<MinLengthValidationError>;
): WithoutFieldTree<MinLengthValidationError>;
export function minLengthError(
minLength: number,
options?: ValidationErrorOptions,
): WithOptionalField<MinLengthValidationError> {
): WithOptionalFieldTree<MinLengthValidationError> {
return new MinLengthValidationError(minLength, options);
}
@@ -167,7 +176,7 @@ export function minLengthError(
*/
export function maxLengthError(
maxLength: number,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): MaxLengthValidationError;
/**
* Create a maxLength error
@@ -180,11 +189,11 @@ export function maxLengthError(
export function maxLengthError(
maxLength: number,
options?: ValidationErrorOptions,
): WithoutField<MaxLengthValidationError>;
): WithoutFieldTree<MaxLengthValidationError>;
export function maxLengthError(
maxLength: number,
options?: ValidationErrorOptions,
): WithOptionalField<MaxLengthValidationError> {
): WithOptionalFieldTree<MaxLengthValidationError> {
return new MaxLengthValidationError(maxLength, options);
}
@@ -198,7 +207,7 @@ export function maxLengthError(
*/
export function patternError(
pattern: RegExp,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): PatternValidationError;
/**
* Create a pattern matching error
@@ -211,11 +220,11 @@ export function patternError(
export function patternError(
pattern: RegExp,
options?: ValidationErrorOptions,
): WithoutField<PatternValidationError>;
): WithoutFieldTree<PatternValidationError>;
export function patternError(
pattern: RegExp,
options?: ValidationErrorOptions,
): WithOptionalField<PatternValidationError> {
): WithOptionalFieldTree<PatternValidationError> {
return new PatternValidationError(pattern, options);
}
@@ -226,7 +235,7 @@ export function patternError(
* @category validation
* @experimental 21.0.0
*/
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
export function emailError(options: WithFieldTree<ValidationErrorOptions>): EmailValidationError;
/**
* Create an email format error
* @param options The optional validation error options
@@ -234,10 +243,12 @@ export function emailError(options: WithField<ValidationErrorOptions>): EmailVal
* @category validation
* @experimental 21.0.0
*/
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
export function emailError(
options?: ValidationErrorOptions,
): WithOptionalField<EmailValidationError> {
): WithoutFieldTree<EmailValidationError>;
export function emailError(
options?: ValidationErrorOptions,
): WithOptionalFieldTree<EmailValidationError> {
return new EmailValidationError(options);
}
@@ -251,7 +262,7 @@ export function emailError(
*/
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options: WithField<ValidationErrorOptions>,
options: WithFieldTree<ValidationErrorOptions>,
): StandardSchemaValidationError;
/**
* Create a standard schema issue error
@@ -264,11 +275,11 @@ export function standardSchemaError(
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options?: ValidationErrorOptions,
): WithoutField<StandardSchemaValidationError>;
): WithoutFieldTree<StandardSchemaValidationError>;
export function standardSchemaError(
issue: StandardSchemaV1.Issue,
options?: ValidationErrorOptions,
): WithOptionalField<StandardSchemaValidationError> {
): WithOptionalFieldTree<StandardSchemaValidationError> {
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<unknown>;
readonly formField?: FormField<unknown>;
}
/** @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<unknown>;
}
/**
@@ -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<unknown>;
}
/** @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;
}
/**
+2 -2
View File
@@ -393,12 +393,12 @@ export async function submit<TModel>(
*/
function setSubmissionErrors(
submittedField: FieldNode,
errors: OneOrMany<ValidationError.WithOptionalField>,
errors: OneOrMany<ValidationError.WithOptionalFieldTree>,
) {
if (!isArray(errors)) {
errors = [errors];
}
const errorsByField = new Map<FieldNode, ValidationError.WithField[]>();
const errorsByField = new Map<FieldNode, ValidationError.WithFieldTree[]>();
for (const error of errors) {
const errorWithField = addDefaultField(error, submittedField.fieldProxy);
const field = errorWithField.fieldTree() as FieldNode;
+4 -4
View File
@@ -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<E>;
/**
@@ -240,12 +240,12 @@ export interface FieldState<
*/
readonly hidden: Signal<boolean>;
readonly disabledReasons: Signal<readonly DisabledReason[]>;
readonly errors: Signal<ValidationError.WithField[]>;
readonly errors: Signal<ValidationError.WithFieldTree[]>;
/**
* A signal containing the {@link errors} of the field and its descendants.
*/
readonly errorSummary: Signal<ValidationError.WithField[]>;
readonly errorSummary: Signal<ValidationError.WithFieldTree[]>;
/**
* A signal indicating whether the field's value is currently valid.
@@ -542,7 +542,7 @@ export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root>
*/
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
TValue,
ValidationResult<ValidationError.WithoutField>,
ValidationResult<ValidationError.WithoutFieldTree>,
TPathKind
>;
+6 -2
View File
@@ -145,11 +145,15 @@ export class FieldNode implements FieldState<unknown> {
return this.structure.keyInParent;
}
get errors(): Signal<ValidationError.WithField[]> {
get errors(): Signal<ValidationError.WithFieldTree[]> {
return this.validationState.errors;
}
get errorSummary(): Signal<ValidationError.WithField[]> {
get parseErrors(): Signal<ValidationError.WithFormField[]> {
return this.validationState.parseErrors;
}
get errorSummary(): Signal<ValidationError.WithFieldTree[]> {
return this.validationState.errorSummary;
}
+2 -2
View File
@@ -21,12 +21,12 @@ export class FieldSubmitState {
readonly selfSubmitting = signal<boolean>(false);
/** Submission errors that are associated with this field. */
readonly submissionErrors: WritableSignal<readonly ValidationError.WithField[]>;
readonly submissionErrors: WritableSignal<readonly ValidationError.WithFieldTree[]>;
constructor(private readonly node: FieldNode) {
this.submissionErrors = linkedSignal({
source: this.node.structure.value,
computation: () => [] as readonly ValidationError.WithField[],
computation: () => [] as readonly ValidationError.WithFieldTree[],
});
}
+21 -14
View File
@@ -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<ValidationError.WithField[]>;
rawSyncTreeErrors: Signal<ValidationError.WithFieldTree[]>;
/**
* 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<ValidationError.WithField[]>;
syncErrors: Signal<ValidationError.WithFieldTree[]>;
/**
* 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<ValidationError.WithField[]>;
errors: Signal<ValidationError.WithFieldTree[]>;
parseErrors: Signal<ValidationError.WithFormField[]>;
/**
* The combined set of all errors that currently apply to this field and its descendants.
*/
errorSummary: Signal<ValidationError.WithField[]>;
errorSummary: Signal<ValidationError.WithFieldTree[]>;
/**
* 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<ValidationError.WithField[]> = computed(() => {
readonly rawSyncTreeErrors: Signal<ValidationError.WithFieldTree[]> = 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<ValidationError.WithField[]> = computed(() => {
readonly syncErrors: Signal<ValidationError.WithFieldTree[]> = 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<ValidationError.WithField[]> = computed(() =>
readonly syncTreeErrors: Signal<ValidationError.WithFieldTree[]> = 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<ValidationError.WithFormField[]> = 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<T extends ValidationResult>(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<E extends ValidationError.WithOptionalField>(
export function addDefaultField<E extends ValidationError.WithOptionalFieldTree>(
error: E,
fieldTree: FieldTree<unknown>,
): E & {fieldTree: FieldTree<unknown>};
@@ -368,10 +375,10 @@ export function addDefaultField<E extends ValidationError>(
): ValidationResult<E & {fieldTree: FieldTree<unknown>}> {
if (isArray(errors)) {
for (const error of errors) {
(error as ɵWritable<ValidationError.WithOptionalField>).fieldTree ??= fieldTree;
(error as ɵWritable<ValidationError.WithOptionalFieldTree>).fieldTree ??= fieldTree;
}
} else if (errors) {
(errors as ɵWritable<ValidationError.WithOptionalField>).fieldTree ??= fieldTree;
(errors as ɵWritable<ValidationError.WithOptionalFieldTree>).fieldTree ??= fieldTree;
}
return errors as ValidationResult<E & {fieldTree: FieldTree<unknown>}>;
}
+7 -6
View File
@@ -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<ValidationError.WithField, null>;
readonly syncErrors: ArrayMergeIgnoreLogic<ValidationError.WithFieldTree, null>;
/** Logic that produces synchronous validation errors for the field's subtree. */
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationError.WithField, null>;
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationError.WithFieldTree, null>;
/** Logic that produces asynchronous validation results (errors or 'pending'). */
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationError.WithField | 'pending', null>;
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationError.WithFieldTree | 'pending', null>;
/** A map of metadata keys to the `AbstractLogic` instances that compute their values. */
private readonly metadata = new Map<
MetadataKey<unknown, unknown, unknown>,
@@ -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<ValidationError.WithField>(predicates);
this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError.WithField>(predicates);
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError.WithField | 'pending'>(
this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError.WithFieldTree>(predicates);
this.syncTreeErrors =
ArrayMergeIgnoreLogic.ignoreNull<ValidationError.WithFieldTree>(predicates);
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError.WithFieldTree | 'pending'>(
predicates,
);
}
@@ -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<any, ValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, ValidationResult<ValidationError.WithFieldTree>>,
): void {
this.getCurrent().addSyncErrorRule(logic);
}
override addSyncTreeErrorRule(
logic: LogicFn<any, ValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, ValidationResult<ValidationError.WithFieldTree>>,
): void {
this.getCurrent().addSyncTreeErrorRule(logic);
}
override addAsyncErrorRule(
logic: LogicFn<any, AsyncValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, AsyncValidationResult<ValidationError.WithFieldTree>>,
): void {
this.getCurrent().addAsyncErrorRule(logic);
}
@@ -235,19 +235,19 @@ class NonMergeableLogicNodeBuilder extends AbstractLogicNodeBuilder {
}
override addSyncErrorRule(
logic: LogicFn<any, ValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, ValidationResult<ValidationError.WithFieldTree>>,
): void {
this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addSyncTreeErrorRule(
logic: LogicFn<any, ValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, ValidationResult<ValidationError.WithFieldTree>>,
): void {
this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
override addAsyncErrorRule(
logic: LogicFn<any, AsyncValidationResult<ValidationError.WithField>>,
logic: LogicFn<any, AsyncValidationResult<ValidationError.WithFieldTree>>,
): void {
this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
}
+2 -2
View File
@@ -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"]),
@@ -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)});
}
@@ -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<string> {
readonly value = model.required<string>();
readonly parseErrors = computed(() => (this.value() === 'ERROR' ? [{kind: 'parse'}] : []));
}
@Component({
imports: [CustomControl, FormField],
template: `<custom-control [formField]="f" />`,
})
class TestCmp {
state = signal<string>('');
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: `
<test-number-input id="input1" [formField]="f" />
<test-number-input id="input2" [formField]="f" />
`,
})
class TestCmp {
state = signal<number | null>(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: `
<test-number-input id="input1" [formField]="f" />
<test-number-input id="input2" [formField]="f" />
`,
})
class TestCmp {
state = signal<number | null>(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<FieldTree<string>>({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: `<custom-control [formField]="f" />`,
})
class TestCmp {
state = signal<string>('');
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: `
<input type="text" [value]="rawValue()" (input)="write($event.target.value)" />
@for (e of errors(); track $index) {
<p class="error">{{ e.message }}</p>
}
`,
})
class TestNumberInput implements FormValueControl<number | null> {
readonly value = model.required<number | null>();
readonly errors = input<readonly ValidationError[]>([]);
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<T>(fn: () => T): Promise<T> {
const result = fn();
await TestBed.inject(ApplicationRef).whenStable();
return result;
}
@@ -27,7 +27,7 @@ function validateValue(value: string): ValidationError[] {
function validateValueForChild(
value: string,
fieldTree: FieldTree<unknown> | undefined,
): ValidationError.WithOptionalField[] {
): ValidationError.WithOptionalFieldTree[] {
return value === 'INVALID' ? [{kind: 'custom', fieldTree: fieldTree}] : [];
}
@@ -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<string> {
readonly value = model.required<string>();
readonly disabledReasons = input.required<readonly WithOptionalField<DisabledReason>[]>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@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 WithOptionalField<DisabledReason>[]>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@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 WithOptionalField<DisabledReason>[]>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@Component({
@@ -510,7 +513,8 @@ describe('field directive', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly disabledReasons = input.required<readonly WithOptionalField<DisabledReason>[]>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@Component({
@@ -545,7 +549,7 @@ describe('field directive', () => {
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly errors = input.required<readonly WithOptionalField<ValidationError>[]>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@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 WithOptionalField<ValidationError>[]>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@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 WithOptionalField<ValidationError>[]>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@Component({
@@ -633,7 +637,7 @@ describe('field directive', () => {
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly errors = input.required<readonly WithOptionalField<ValidationError>[]>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@Component({
@@ -2541,10 +2545,10 @@ describe('field directive', () => {
readonly pattern = input<readonly RegExp[], unknown>([], {
transform: (v: unknown) => (Array.isArray(v) ? v : []),
});
readonly errors = input<readonly WithOptionalField<ValidationError>[], unknown>([], {
readonly errors = input<readonly WithOptionalFieldTree<ValidationError>[], unknown>([], {
transform: (v: unknown) => (Array.isArray(v) ? v : []),
});
readonly disabledReasons = input<readonly WithOptionalField<DisabledReason>[], unknown>(
readonly disabledReasons = input<readonly WithOptionalFieldTree<DisabledReason>[], unknown>(
[],
{
transform: (v: unknown) => (Array.isArray(v) ? v : []),
@@ -3346,7 +3350,7 @@ describe('field directive', () => {
})
class CustomInput implements FormValueControl<string> {
value = model('');
disabledReasons = input<readonly WithOptionalField<DisabledReason>[]>([]);
disabledReasons = input<readonly WithOptionalFieldTree<DisabledReason>[]>([]);
}
@Component({
@@ -3571,8 +3575,8 @@ describe('field directive', () => {
})
class CustomInput implements FormValueControl<string> {
value = model('');
disabledReasons = input<readonly WithOptionalField<DisabledReason>[]>([]);
errors = input<readonly WithOptionalField<ValidationError>[]>([]);
disabledReasons = input<readonly WithOptionalFieldTree<DisabledReason>[]>([]);
errors = input<readonly WithOptionalFieldTree<ValidationError>[]>([]);
}
@Component({
@@ -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 WithOptionalField<DisabledReason>[]>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@Component({
@@ -484,7 +485,7 @@ describe('ControlValueAccessor', () => {
it('should bind to directive input', () => {
@Directive({selector: '[testDir]'})
class TestDir {
readonly errors = input.required<readonly WithOptionalField<ValidationError>[]>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@Component({