mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Andrew Scott
parent
085784e4f1
commit
ebae211add
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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[],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>}>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user