mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(forms): add experimental signal-based forms (#63408)
This commit introduces an experimental version of a new signal-based forms API for Angular. This new API aims to explore how signals can be leveraged to create a more declarative, intuitive, and reactive way of handling forms. The primary goals of this new signal-based approach are: * **Signal-centric Design:** Place signals at the core of the forms experience, enabling a truly reactive programming model for form state and logic. * **Declarative Logic:** Allow developers to define form behavior, such as validation and conditional fields, declaratively using TypeScript. This moves logic out of the template and into a typed, testable schema. * **Developer-Owned Data Model:** The library does not maintain a copy of data in a form model, but instead read and write it via a developer-provided `WritableSignal`, eliminating the need for applications to synchronize their data with the form system. * **Interoperability:** A key design goal is seamless interoperability with existing reactive forms, allowing for incremental adoption. * **Bridging Template and Reactive Forms:** This exploration hopes to close the gap between template and reactive forms, offering a unified and more powerful approach that combines the best aspects of both. This initial version of the experimental API includes the core building blocks, such as the `form()` function, `Field` and `FieldState` objects, and a `[control]` directive for binding to UI elements. It also introduces a schema-based system for defining validation, conditional logic, and other form behaviors. Note: This is an early, experimental API. It is not yet complete and is subject to change based on feedback and further exploration. Co-authored-by: Kirill Cherkashin <kirts@google.com> Co-authored-by: Alex Rickabaugh <alxhub@users.noreply.github.com> Co-authored-by: Leon Senft <leonsenft@users.noreply.github.com> Co-authored-by: Dylan Hunn <dylhunn@gmail.com> Co-authored-by: Michael Small <michael-small@users.noreply.github.com> PR Close #63408
This commit is contained in:
parent
a1d1cdf1e3
commit
b8314bd340
70 changed files with 12627 additions and 1 deletions
580
goldens/public-api/forms/signals/index.api.md
Normal file
580
goldens/public-api/forms/signals/index.api.md
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
## API Report File for "forms_signals_api"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
|
||||
import { AbstractControl } from '@angular/forms';
|
||||
import { ControlValueAccessor } from '@angular/forms';
|
||||
import { DestroyableInjector } from '@angular/core';
|
||||
import { ElementRef } from '@angular/core';
|
||||
import { FormControlStatus } from '@angular/forms';
|
||||
import { HttpResourceOptions } from '@angular/common/http';
|
||||
import { HttpResourceRequest } from '@angular/common/http';
|
||||
import * as i0 from '@angular/core';
|
||||
import { Injector } from '@angular/core';
|
||||
import { InputSignal } from '@angular/core';
|
||||
import { ModelSignal } from '@angular/core';
|
||||
import { NgControl } from '@angular/forms';
|
||||
import { OutputRef } from '@angular/core';
|
||||
import { ResourceRef } from '@angular/core';
|
||||
import { Signal } from '@angular/core';
|
||||
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
||||
import { ValidationErrors } from '@angular/forms';
|
||||
import { ValidatorFn } from '@angular/forms';
|
||||
import { WritableSignal } from '@angular/core';
|
||||
|
||||
// @public
|
||||
export class AggregateProperty<TAcc, TItem> {
|
||||
// (undocumented)
|
||||
readonly getInitial: () => TAcc;
|
||||
// (undocumented)
|
||||
readonly reduce: (acc: TAcc, item: TItem) => TAcc;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function aggregateProperty<TValue, TPropItem, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, prop: AggregateProperty<any, TPropItem>, logic: NoInfer<LogicFn<TValue, TPropItem, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export function andProperty(): AggregateProperty<boolean, boolean>;
|
||||
|
||||
// @public
|
||||
export function apply<TValue>(path: FieldPath<TValue>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
|
||||
|
||||
// @public
|
||||
export function applyEach<TValue>(path: FieldPath<TValue[]>, schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>): void;
|
||||
|
||||
// @public
|
||||
export function applyWhen<TValue>(path: FieldPath<TValue>, logic: LogicFn<TValue, boolean>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
|
||||
|
||||
// @public
|
||||
export function applyWhenValue<TValue, TNarrowed extends TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn<TNarrowed>): void;
|
||||
|
||||
// @public
|
||||
export function applyWhenValue<TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => boolean, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
|
||||
|
||||
// @public
|
||||
export type AsyncValidationResult<E extends ValidationError = ValidationError> = ValidationResult<E> | 'pending';
|
||||
|
||||
// @public
|
||||
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
|
||||
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
|
||||
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
|
||||
readonly key: Signal<string>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class Control<T> {
|
||||
get cva(): ControlValueAccessor | undefined;
|
||||
readonly cvaArray: ControlValueAccessor[] | null;
|
||||
readonly el: ElementRef<HTMLElement>;
|
||||
readonly field: WritableSignal<Field<T>>;
|
||||
// (undocumented)
|
||||
set _field(value: Field<T>);
|
||||
get ngControl(): NgControl;
|
||||
readonly state: Signal<FieldState<T, string | number>>;
|
||||
// (undocumented)
|
||||
static ɵdir: i0.ɵɵDirectiveDeclaration<Control<any>, "[control]", never, { "_field": { "alias": "control"; "required": true; }; }, {}, never, never, true, never>;
|
||||
// (undocumented)
|
||||
static ɵfac: i0.ɵɵFactoryDeclaration<Control<any>, never>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function createProperty<TValue>(): Property<TValue>;
|
||||
|
||||
// @public
|
||||
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj: WithField<E>): CustomValidationError;
|
||||
|
||||
// @public
|
||||
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(obj?: E): WithoutField<CustomValidationError>;
|
||||
|
||||
// @public
|
||||
export class CustomValidationError extends ValidationError {
|
||||
[key: PropertyKey]: unknown;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export interface DisabledReason {
|
||||
readonly field: Field<unknown>;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function email<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
|
||||
|
||||
// @public
|
||||
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
|
||||
|
||||
// @public
|
||||
export class EmailValidationError extends _NgValidationError {
|
||||
// (undocumented)
|
||||
readonly kind = "email";
|
||||
}
|
||||
|
||||
// @public
|
||||
export type Field<TValue, TKey extends string | number = string | number> = (() => FieldState<TValue, TKey>) & (TValue extends Array<infer U> ? ReadonlyArrayLike<MaybeField<U, number>> : TValue extends Record<string, any> ? Subfields<TValue> : unknown);
|
||||
|
||||
// @public
|
||||
export type FieldContext<TValue, TPathKind extends PathKind = PathKind.Root> = TPathKind extends PathKind.Item ? ItemFieldContext<TValue> : TPathKind extends PathKind.Child ? ChildFieldContext<TValue> : RootFieldContext<TValue>;
|
||||
|
||||
// @public
|
||||
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
|
||||
[ɵɵTYPE]: [TValue, TPathKind];
|
||||
} & (TValue extends Array<unknown> ? unknown : TValue extends Record<string, any> ? {
|
||||
[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>;
|
||||
} : unknown);
|
||||
|
||||
// @public
|
||||
export interface FieldState<TValue, TKey extends string | number = string | number> {
|
||||
readonly controls: Signal<readonly Control<unknown>[]>;
|
||||
readonly dirty: Signal<boolean>;
|
||||
readonly disabled: Signal<boolean>;
|
||||
readonly disabledReasons: Signal<readonly DisabledReason[]>;
|
||||
readonly errors: Signal<ValidationError[]>;
|
||||
readonly errorSummary: Signal<ValidationError[]>;
|
||||
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
|
||||
readonly hidden: Signal<boolean>;
|
||||
readonly invalid: Signal<boolean>;
|
||||
readonly keyInParent: Signal<TKey>;
|
||||
markAsDirty(): void;
|
||||
markAsTouched(): void;
|
||||
readonly name: Signal<string>;
|
||||
readonly pending: Signal<boolean>;
|
||||
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
|
||||
property<M>(prop: Property<M>): M | undefined;
|
||||
readonly readonly: Signal<boolean>;
|
||||
reset(): void;
|
||||
readonly submitting: Signal<boolean>;
|
||||
readonly touched: Signal<boolean>;
|
||||
readonly valid: Signal<boolean>;
|
||||
readonly value: WritableSignal<TValue>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type FieldValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithoutField<E>>;
|
||||
|
||||
// @public
|
||||
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, FieldValidationResult, TPathKind>;
|
||||
|
||||
// @public
|
||||
export function form<TValue>(model: WritableSignal<TValue>): Field<TValue>;
|
||||
|
||||
// @public
|
||||
export function form<TValue>(model: WritableSignal<TValue>, schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions): Field<TValue>;
|
||||
|
||||
// @public
|
||||
export function form<TValue>(model: WritableSignal<TValue>, schema: SchemaOrSchemaFn<TValue>, options: FormOptions): Field<TValue>;
|
||||
|
||||
// @public
|
||||
export interface FormCheckboxControl extends FormUiControl {
|
||||
readonly checked: ModelSignal<boolean>;
|
||||
readonly value?: undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FormOptions {
|
||||
adapter?: FieldAdapter;
|
||||
injector?: Injector;
|
||||
// (undocumented)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FormUiControl {
|
||||
readonly dirty?: InputSignal<boolean>;
|
||||
readonly disabled?: InputSignal<boolean>;
|
||||
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
|
||||
readonly errors?: InputSignal<readonly ValidationError[]>;
|
||||
readonly hidden?: InputSignal<boolean>;
|
||||
readonly invalid?: InputSignal<boolean>;
|
||||
readonly max?: InputSignal<number | undefined>;
|
||||
readonly maxLength?: InputSignal<number | undefined>;
|
||||
readonly min?: InputSignal<number | undefined>;
|
||||
readonly minLength?: InputSignal<number | undefined>;
|
||||
readonly name?: InputSignal<string>;
|
||||
readonly pattern?: InputSignal<readonly RegExp[]>;
|
||||
readonly pending?: InputSignal<boolean>;
|
||||
readonly readonly?: InputSignal<boolean>;
|
||||
readonly required?: InputSignal<boolean>;
|
||||
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface FormValueControl<TValue> extends FormUiControl {
|
||||
readonly checked?: undefined;
|
||||
readonly value: ModelSignal<TValue>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
|
||||
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
readonly options?: HttpResourceOptions<TResult, unknown>;
|
||||
readonly request: ((ctx: FieldContext<TValue, TPathKind>) => string | undefined) | ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
|
||||
}
|
||||
|
||||
// @public
|
||||
export class InteropNgControl implements Pick<NgControl, InteropSharedKeys | 'control' | 'valueAccessor'>, Pick<AbstractControl<unknown>, InteropSharedKeys | 'hasValidator'> {
|
||||
constructor(field: () => FieldState<unknown>);
|
||||
// (undocumented)
|
||||
readonly control: AbstractControl<any, any>;
|
||||
// (undocumented)
|
||||
get dirty(): boolean;
|
||||
// (undocumented)
|
||||
get disabled(): boolean;
|
||||
// (undocumented)
|
||||
get enabled(): boolean;
|
||||
// (undocumented)
|
||||
get errors(): ValidationErrors | null;
|
||||
// (undocumented)
|
||||
protected field: () => FieldState<unknown>;
|
||||
// (undocumented)
|
||||
hasValidator(validator: ValidatorFn): boolean;
|
||||
// (undocumented)
|
||||
get invalid(): boolean;
|
||||
// (undocumented)
|
||||
get pending(): boolean | null;
|
||||
// (undocumented)
|
||||
get pristine(): boolean;
|
||||
// (undocumented)
|
||||
get status(): FormControlStatus;
|
||||
// (undocumented)
|
||||
get touched(): boolean;
|
||||
// (undocumented)
|
||||
get untouched(): boolean;
|
||||
// (undocumented)
|
||||
updateValueAndValidity(): void;
|
||||
// (undocumented)
|
||||
get valid(): boolean;
|
||||
// (undocumented)
|
||||
get value(): any;
|
||||
// (undocumented)
|
||||
valueAccessor: ControlValueAccessor | null;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type InteropSharedKeys = 'value' | 'valid' | 'invalid' | 'touched' | 'untouched' | 'disabled' | 'enabled' | 'errors' | 'pristine' | 'dirty' | 'status';
|
||||
|
||||
// @public
|
||||
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
|
||||
readonly index: Signal<number>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function listProperty<TItem>(): AggregateProperty<TItem[], TItem | undefined>;
|
||||
|
||||
// @public
|
||||
export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root> = (ctx: FieldContext<TValue, TPathKind>) => TReturn;
|
||||
|
||||
// @public
|
||||
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (result: TResult, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
|
||||
|
||||
// @public
|
||||
export const MAX: AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export function max<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, maxValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export const MAX_LENGTH: AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export function maxError(max: number, options: WithField<ValidationErrorOptions>): MaxValidationError;
|
||||
|
||||
// @public
|
||||
export function maxError(max: number, options?: ValidationErrorOptions): WithoutField<MaxValidationError>;
|
||||
|
||||
// @public
|
||||
export function maxLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, maxLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function maxLengthError(maxLength: number, options: WithField<ValidationErrorOptions>): MaxLengthValidationError;
|
||||
|
||||
// @public
|
||||
export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField<MaxLengthValidationError>;
|
||||
|
||||
// @public
|
||||
export class MaxLengthValidationError extends _NgValidationError {
|
||||
constructor(maxLength: number, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly kind = "maxLength";
|
||||
// (undocumented)
|
||||
readonly maxLength: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function maxProperty(): AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export class MaxValidationError extends _NgValidationError {
|
||||
constructor(max: number, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly kind = "max";
|
||||
// (undocumented)
|
||||
readonly max: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type MaybeField<TValue, TKey extends string | number = string | number> = (TValue & undefined) | Field<Exclude<TValue, undefined>, TKey>;
|
||||
|
||||
// @public
|
||||
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = (TValue & undefined) | FieldPath<Exclude<TValue, undefined>, TPathKind>;
|
||||
|
||||
// @public
|
||||
export const MIN: AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export function min<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, minValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export const MIN_LENGTH: AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export function minError(min: number, options: WithField<ValidationErrorOptions>): MinValidationError;
|
||||
|
||||
// @public
|
||||
export function minError(min: number, options?: ValidationErrorOptions): WithoutField<MinValidationError>;
|
||||
|
||||
// @public
|
||||
export function minLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, minLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function minLengthError(minLength: number, options: WithField<ValidationErrorOptions>): MinLengthValidationError;
|
||||
|
||||
// @public
|
||||
export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField<MinLengthValidationError>;
|
||||
|
||||
// @public
|
||||
export class MinLengthValidationError extends _NgValidationError {
|
||||
constructor(minLength: number, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly kind = "minLength";
|
||||
// (undocumented)
|
||||
readonly minLength: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function minProperty(): AggregateProperty<number | undefined, number | undefined>;
|
||||
|
||||
// @public
|
||||
export class MinValidationError extends _NgValidationError {
|
||||
constructor(min: number, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly kind = "min";
|
||||
// (undocumented)
|
||||
readonly min: number;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
// @public
|
||||
export const NgValidationError: abstract new () => NgValidationError;
|
||||
|
||||
// @public (undocumented)
|
||||
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError;
|
||||
|
||||
// @public
|
||||
export type OneOrMany<T> = T | readonly T[];
|
||||
|
||||
// @public
|
||||
export function orProperty(): AggregateProperty<boolean, boolean>;
|
||||
|
||||
// @public
|
||||
export namespace PathKind {
|
||||
export interface Child extends PathKind.Root {
|
||||
// (undocumented)
|
||||
[ɵɵTYPE]: 'child' | 'item';
|
||||
}
|
||||
export interface Item extends PathKind.Child {
|
||||
// (undocumented)
|
||||
[ɵɵTYPE]: 'item';
|
||||
}
|
||||
export interface Root {
|
||||
[ɵɵTYPE]: 'root' | 'child' | 'item';
|
||||
}
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;
|
||||
|
||||
// @public
|
||||
export const PATTERN: AggregateProperty<RegExp[], RegExp | undefined>;
|
||||
|
||||
// @public
|
||||
export function pattern<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function patternError(pattern: RegExp, options: WithField<ValidationErrorOptions>): PatternValidationError;
|
||||
|
||||
// @public
|
||||
export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField<PatternValidationError>;
|
||||
|
||||
// @public
|
||||
export class PatternValidationError extends _NgValidationError {
|
||||
constructor(pattern: RegExp, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly kind = "pattern";
|
||||
// (undocumented)
|
||||
readonly pattern: RegExp;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class Property<TValue> {
|
||||
}
|
||||
|
||||
// @public
|
||||
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): Property<TData>;
|
||||
|
||||
// @public
|
||||
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, prop: Property<TData>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): Property<TData>;
|
||||
|
||||
// @public
|
||||
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export type ReadonlyArrayLike<T> = Pick<ReadonlyArray<T>, number | 'length' | typeof Symbol.iterator>;
|
||||
|
||||
// @public
|
||||
export function reducedProperty<TAcc, TItem>(reduce: (acc: TAcc, item: TItem) => TAcc, getInitial: () => TAcc): AggregateProperty<TAcc, TItem>;
|
||||
|
||||
// @public
|
||||
export const REQUIRED: AggregateProperty<boolean, boolean>;
|
||||
|
||||
// @public
|
||||
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind> & {
|
||||
emptyPredicate?: (value: TValue) => boolean;
|
||||
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
|
||||
}): void;
|
||||
|
||||
// @public
|
||||
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
|
||||
|
||||
// @public
|
||||
export function requiredError(options?: ValidationErrorOptions): WithoutField<RequiredValidationError>;
|
||||
|
||||
// @public
|
||||
export class RequiredValidationError extends _NgValidationError {
|
||||
// (undocumented)
|
||||
readonly kind = "required";
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface RootFieldContext<TValue> {
|
||||
readonly field: Field<TValue>;
|
||||
readonly fieldOf: <P>(p: FieldPath<P>) => Field<P>;
|
||||
readonly state: FieldState<TValue>;
|
||||
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
|
||||
readonly value: Signal<TValue>;
|
||||
readonly valueOf: <P>(p: FieldPath<P>) => P;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type Schema<in TValue> = {
|
||||
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue>;
|
||||
|
||||
// @public
|
||||
export type SchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = (p: FieldPath<TValue, TPathKind>) => void;
|
||||
|
||||
// @public
|
||||
export type SchemaOrSchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = Schema<TValue> | SchemaFn<TValue, TPathKind>;
|
||||
|
||||
// @public
|
||||
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
|
||||
|
||||
// @public
|
||||
export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField<StandardSchemaValidationError>;
|
||||
|
||||
// @public
|
||||
export class StandardSchemaValidationError extends _NgValidationError {
|
||||
constructor(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions);
|
||||
// (undocumented)
|
||||
readonly issue: StandardSchemaV1.Issue;
|
||||
// (undocumented)
|
||||
readonly kind = "standardSchema";
|
||||
}
|
||||
|
||||
// @public
|
||||
export type Subfields<TValue> = {
|
||||
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeField<TValue[K], string>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function submit<TValue>(form: Field<TValue>, action: (form: Field<TValue>) => Promise<TreeValidationResult>): Promise<void>;
|
||||
|
||||
// @public
|
||||
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
|
||||
|
||||
// @public
|
||||
export type TreeValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<WithOptionalField<E>>;
|
||||
|
||||
// @public
|
||||
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
|
||||
|
||||
// @public
|
||||
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<FieldValidator<TValue, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: HttpValidatorOptions<TValue, TResult, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;
|
||||
|
||||
// @public
|
||||
export abstract class ValidationError {
|
||||
[BRAND]: undefined;
|
||||
constructor(options?: ValidationErrorOptions);
|
||||
readonly field: Field<unknown>;
|
||||
readonly kind: string;
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type ValidationResult<E extends ValidationError = ValidationError> = ValidationSuccess | OneOrMany<E>;
|
||||
|
||||
// @public
|
||||
export type ValidationSuccess = null | undefined | void;
|
||||
|
||||
// @public
|
||||
export type Validator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult, TPathKind>;
|
||||
|
||||
// @public
|
||||
export type WithField<T> = T & {
|
||||
field: Field<unknown>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type WithOptionalField<T> = T & {
|
||||
field?: Field<unknown>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type WithoutField<T> = T & {
|
||||
field: never;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
```
|
||||
|
|
@ -81,6 +81,7 @@
|
|||
"@rollup/plugin-commonjs": "^28.0.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@schematics/angular": "21.0.0-next.1",
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/angular": "^1.6.47",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/babel__generator": "7.27.0",
|
||||
|
|
@ -209,7 +210,8 @@
|
|||
"tslint-no-toplevel-property-access": "0.0.2",
|
||||
"typed-graphqlify": "^3.1.1",
|
||||
"undici": "^7.0.0",
|
||||
"vrsource-tslint-rules": "6.0.0"
|
||||
"vrsource-tslint-rules": "6.0.0",
|
||||
"zod": "^4.0.10"
|
||||
},
|
||||
"resolutions": {
|
||||
"https-proxy-agent": "7.0.6",
|
||||
|
|
|
|||
28
packages/forms/signals/BUILD.bazel
Normal file
28
packages/forms/signals/BUILD.bazel
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
load("//tools:defaults.bzl", "api_golden_test", "ng_project")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
ng_project(
|
||||
name = "signals",
|
||||
srcs = glob(
|
||||
[
|
||||
"*.ts",
|
||||
"src/**/*.ts",
|
||||
],
|
||||
),
|
||||
deps = [
|
||||
"//:node_modules/@standard-schema/spec",
|
||||
"//packages/core",
|
||||
"//packages/forms",
|
||||
],
|
||||
)
|
||||
|
||||
api_golden_test(
|
||||
name = "forms_signals_api",
|
||||
data = [
|
||||
"//goldens:public-api",
|
||||
"//packages/forms/signals",
|
||||
],
|
||||
entry_point = "index.d.ts",
|
||||
golden = "goldens/public-api/forms/signals/index.api.md",
|
||||
)
|
||||
37
packages/forms/signals/README.md
Normal file
37
packages/forms/signals/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# 🚧 Experimental Signal-Based Forms API 🏗️
|
||||
|
||||
This directory contains an experimental new Angular Forms API built on top of
|
||||
[signals](https://angular.dev/guide/signals). We're using this experimental API to explore potential
|
||||
designs for such a system, to play with new ideas, identify challenges, and to demonstrate
|
||||
interoperability with the existing version of `@angular/forms`.
|
||||
|
||||
## Not yet supported
|
||||
|
||||
- Debouncing validation
|
||||
- Dynamic objects
|
||||
- Tuples
|
||||
- Interop with Reactive/Template forms
|
||||
- Strongly-typed binding to UI controls
|
||||
|
||||
## FAQs
|
||||
|
||||
### Why are you working on this?
|
||||
|
||||
We've been exploring ways that we can integrate signals into Angular's forms package. We've looked
|
||||
at several options, including integrating signals into template and reactive forms, and designing a
|
||||
new flavor of forms with signals at the core. Our hope is that we can leverage this work to close
|
||||
the gap between template and reactive forms, which often inspires debate in the Angular ecosystem.
|
||||
|
||||
### What does this mean for the future of template and/or reactive forms?
|
||||
|
||||
Nothing is changing yet with template and reactive forms. This API is early and still highly experimental.
|
||||
|
||||
Even if we achieve our goals, we will roll out any changes to forms incrementally. Like with NgModules
|
||||
and `standalone`, we don't intend to deprecate template or reactive forms without a clear sign from
|
||||
our community that the ecosystem is fully on board.
|
||||
|
||||
### Will I need to rewrite my application code to use the new forms system?
|
||||
|
||||
No - a non-negotiable design goal of a new signal-based forms system is interoperability with
|
||||
existing forms code and applications. It should be possible to incrementally start using the new
|
||||
system in existing applications, and as always we will explore the possibility of automated migrations.
|
||||
14
packages/forms/signals/index.ts
Normal file
14
packages/forms/signals/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
// This file is not used to build this module. It is only used during editing
|
||||
// by the TypeScript language service and during build for verification. `ngc`
|
||||
// replaces this file with production index.ts when it rewrites private symbol
|
||||
// names.
|
||||
|
||||
export * from './public_api';
|
||||
23
packages/forms/signals/public_api.ts
Normal file
23
packages/forms/signals/public_api.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module
|
||||
* @description
|
||||
* Entry point for all public APIs of this package.
|
||||
*/
|
||||
export * from './src/api/async';
|
||||
export * from './src/api/control';
|
||||
export * from './src/api/logic';
|
||||
export * from './src/api/property';
|
||||
export * from './src/api/structure';
|
||||
export * from './src/api/types';
|
||||
export * from './src/api/validators';
|
||||
export * from './src/controls/control';
|
||||
export * from './src/controls/interop_ng_control';
|
||||
export * from './src/api/validation_errors';
|
||||
194
packages/forms/signals/src/api/async.ts
Normal file
194
packages/forms/signals/src/api/async.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* @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 {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http';
|
||||
import {computed, ResourceRef, Signal} from '@angular/core';
|
||||
import {FieldNode} from '../field/node';
|
||||
import {addDefaultField} from '../field/validation';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {assertPathIsCurrent} from '../schema/schema';
|
||||
import {property} from './logic';
|
||||
import {FieldContext, FieldPath, PathKind, TreeValidationResult} from './types';
|
||||
|
||||
/**
|
||||
* A function that takes the result of an async operation and the current field context, and maps it
|
||||
* to a list of validation errors.
|
||||
*
|
||||
* @param result The result of the async operation.
|
||||
* @param ctx The context for the field the validator is attached to.
|
||||
* @return A validation error, or list of validation errors to report based on the result of the async operation.
|
||||
* The returned errors can optionally specify a field that the error should be targeted to.
|
||||
* A targeted error will show up as an error on its target field rather than the field being validated.
|
||||
* If a field is not given, the error is assumed to apply to the field being validated.
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
* @template TResult The type of result returned by the async operation
|
||||
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
|
||||
*/
|
||||
export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind.Root> = (
|
||||
result: TResult,
|
||||
ctx: FieldContext<TValue, TPathKind>,
|
||||
) => TreeValidationResult;
|
||||
|
||||
/**
|
||||
* Options that indicate how to create a resource for async validation for a field,
|
||||
* and map its result to validation errors.
|
||||
*
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
* @template TParams The type of parameters to the resource.
|
||||
* @template TResult The type of result returned by the resource
|
||||
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
|
||||
*/
|
||||
export interface AsyncValidatorOptions<
|
||||
TValue,
|
||||
TParams,
|
||||
TResult,
|
||||
TPathKind extends PathKind = PathKind.Root,
|
||||
> {
|
||||
/**
|
||||
* A function that receives the field context and returns the params for the resource.
|
||||
*
|
||||
* @param ctx The field context for the field being validated.
|
||||
* @returns The params for the resource.
|
||||
*/
|
||||
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
|
||||
|
||||
/**
|
||||
* A function that receives the resource params and returns a resource of the given params.
|
||||
* The given params should be used as is to create the resource.
|
||||
* The forms system will report the params as `undefined` when this validation doesn't need to be run.
|
||||
*
|
||||
* @param params The params to use for constructing the resource
|
||||
* @returns A reference to the constructed resource.
|
||||
*/
|
||||
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
|
||||
|
||||
/**
|
||||
* A function that takes the resource result, and the current field context and maps it to a list
|
||||
* of validation errors.
|
||||
*
|
||||
* @param result The resource result.
|
||||
* @param ctx The context for the field the validator is attached to.
|
||||
* @return A validation error, or list of validation errors to report based on the resource result.
|
||||
* The returned errors can optionally specify a field that the error should be targeted to.
|
||||
* A targeted error will show up as an error on its target field rather than the field being validated.
|
||||
* If a field is not given, the error is assumed to apply to the field being validated.
|
||||
*/
|
||||
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that indicate how to create an httpResource for async validation for a field,
|
||||
* and map its result to validation errors.
|
||||
*
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
* @template TResult The type of result returned by the httpResource
|
||||
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
|
||||
*/
|
||||
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
|
||||
/**
|
||||
* A function that receives the field context and returns the url or request for the httpResource.
|
||||
* If given a URL, the underlying httpResource will perform an HTTP GET on it.
|
||||
*
|
||||
* @param ctx The field context for the field being validated.
|
||||
* @returns The URL or request for creating the httpResource.
|
||||
*/
|
||||
readonly request:
|
||||
| ((ctx: FieldContext<TValue, TPathKind>) => string | undefined)
|
||||
| ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
|
||||
|
||||
/**
|
||||
* A function that takes the httpResource result, and the current field context and maps it to a
|
||||
* list of validation errors.
|
||||
*
|
||||
* @param result The httpResource result.
|
||||
* @param ctx The context for the field the validator is attached to.
|
||||
* @return A validation error, or list of validation errors to report based on the httpResource result.
|
||||
* The returned errors can optionally specify a field that the error should be targeted to.
|
||||
* A targeted error will show up as an error on its target field rather than the field being validated.
|
||||
* If a field is not given, the error is assumed to apply to the field being validated.
|
||||
*/
|
||||
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
|
||||
/**
|
||||
* The options to use when creating the httpResource.
|
||||
*/
|
||||
readonly options?: HttpResourceOptions<TResult, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds async validation to the field corresponding to the given path based on a resource.
|
||||
* Async validation for a field only runs once all synchronous validation is passing.
|
||||
*
|
||||
* @param path A path indicating the field to bind the async validation logic to.
|
||||
* @param opts The async validation options.
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
* @template TParams The type of parameters to the resource.
|
||||
* @template TResult The type of result returned by the resource
|
||||
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
|
||||
const RESOURCE = property(path, (ctx) => {
|
||||
const params = computed(() => {
|
||||
const node = ctx.stateOf(path) as FieldNode;
|
||||
const validationState = node.validationState;
|
||||
if (validationState.shouldSkipValidation() || !validationState.syncValid()) {
|
||||
return undefined;
|
||||
}
|
||||
return opts.params(ctx);
|
||||
});
|
||||
return opts.factory(params);
|
||||
});
|
||||
|
||||
pathNode.logic.addAsyncErrorRule((ctx) => {
|
||||
const res = ctx.state.property(RESOURCE)!;
|
||||
switch (res.status()) {
|
||||
case 'idle':
|
||||
return undefined;
|
||||
case 'loading':
|
||||
case 'reloading':
|
||||
return 'pending';
|
||||
case 'resolved':
|
||||
case 'local':
|
||||
if (!res.hasValue()) {
|
||||
return undefined;
|
||||
}
|
||||
const errors = opts.errors(res.value()!, ctx as FieldContext<TValue, TPathKind>);
|
||||
return addDefaultField(errors, ctx.field);
|
||||
case 'error':
|
||||
// TODO: Design error handling for async validation. For now, just throw the error.
|
||||
throw res.error();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds async validation to the field corresponding to the given path based on an httpResource.
|
||||
* Async validation for a field only runs once all synchronous validation is passing.
|
||||
*
|
||||
* @param path A path indicating the field to bind the async validation logic to.
|
||||
* @param opts The http validation options.
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
* @template TResult The type of result returned by the httpResource
|
||||
* @template TPathKind The kind of path being validated (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
|
||||
) {
|
||||
validateAsync(path, {
|
||||
params: opts.request,
|
||||
factory: (request: Signal<any>) => httpResource(request, opts.options),
|
||||
errors: opts.errors,
|
||||
});
|
||||
}
|
||||
152
packages/forms/signals/src/api/control.ts
Normal file
152
packages/forms/signals/src/api/control.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* @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 {InputSignal, ModelSignal, OutputRef} from '@angular/core';
|
||||
import type {DisabledReason} from './types';
|
||||
import {ValidationError} from './validation_errors';
|
||||
|
||||
/** The base set of properties shared by all form control contracts. */
|
||||
export interface FormUiControl {
|
||||
// TODO: `ValidationError` and `DisabledReason` are inherently tied to the signal forms system.
|
||||
// They don't make sense when using a ccontrol separately from the forms system and setting the
|
||||
// inputs individually. Givn that, should they still be part of this interface?
|
||||
|
||||
/**
|
||||
* An input to receive the errors for the field. If implemented, the `Control` directive will
|
||||
* automatically bind errors from the bound field to this input.
|
||||
*/
|
||||
readonly errors?: InputSignal<readonly ValidationError[]>;
|
||||
/**
|
||||
* An input to receive the disabled status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the disabled status from the bound field to this input.
|
||||
*/
|
||||
readonly disabled?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the reasons for the disablement of the field. If implemented, the `Control`
|
||||
* directive will automatically bind the disabled reason from the bound field to this input.
|
||||
*/
|
||||
readonly disabledReasons?: InputSignal<readonly DisabledReason[]>;
|
||||
/**
|
||||
* An input to receive the readonly status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the readonly status from the bound field to this input.
|
||||
*/
|
||||
readonly readonly?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the hidden status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the hidden status from the bound field to this input.
|
||||
*/
|
||||
readonly hidden?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the invalid status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the invalid status from the bound field to this input.
|
||||
*/
|
||||
readonly invalid?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the pending status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the pending status from the bound field to this input.
|
||||
*/
|
||||
readonly pending?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the touched status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the touched status from the bound field to this input.
|
||||
*/
|
||||
readonly touched?: ModelSignal<boolean> | InputSignal<boolean> | OutputRef<boolean>;
|
||||
/**
|
||||
* An input to receive the dirty status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the dirty status from the bound field to this input.
|
||||
*/
|
||||
readonly dirty?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the name for the field. If implemented, the `Control` directive will
|
||||
* automatically bind the name from the bound field to this input.
|
||||
*/
|
||||
readonly name?: InputSignal<string>;
|
||||
/**
|
||||
* An input to receive the required status for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the required status from the bound field to this input.
|
||||
*/
|
||||
readonly required?: InputSignal<boolean>;
|
||||
/**
|
||||
* An input to receive the min value for the field. If implemented, the `Control` directive will
|
||||
* automatically bind the min value from the bound field to this input.
|
||||
*/
|
||||
readonly min?: InputSignal<number | undefined>;
|
||||
/**
|
||||
* An input to receive the min length for the field. If implemented, the `Control` directive will
|
||||
* automatically bind the min length from the bound field to this input.
|
||||
*/
|
||||
readonly minLength?: InputSignal<number | undefined>;
|
||||
/**
|
||||
* An input to receive the max value for the field. If implemented, the `Control` directive will
|
||||
* automatically bind the max value from the bound field to this input.
|
||||
*/
|
||||
readonly max?: InputSignal<number | undefined>;
|
||||
/**
|
||||
* An input to receive the max length for the field. If implemented, the `Control` directive will
|
||||
* automatically bind the max length from the bound field to this input.
|
||||
*/
|
||||
readonly maxLength?: InputSignal<number | undefined>;
|
||||
/**
|
||||
* An input to receive the value patterns for the field. If implemented, the `Control` directive
|
||||
* will automatically bind the value patterns from the bound field to this input.
|
||||
*/
|
||||
readonly pattern?: InputSignal<readonly RegExp[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contract for a form control that edits a `Field` of type `TValue`. Any component that
|
||||
* implements this contract can be used with the `Control` directive.
|
||||
*
|
||||
* Many of the properties declared on this contract are optional. They do not need to be
|
||||
* implemented, but if they are will be kept in sync with the field state of the field bound to the
|
||||
* `Control` directive.
|
||||
*
|
||||
* @template TValue The type of `Field` that the implementing component can edit.
|
||||
*/
|
||||
export interface FormValueControl<TValue> extends FormUiControl {
|
||||
/**
|
||||
* The value is the only required property in this contract. A component that wants to integrate
|
||||
* with the `Control` directive via this contract, *must* provide a `model()` that will be kept in
|
||||
* sync with the value of the bound `Field`.
|
||||
*/
|
||||
readonly value: ModelSignal<TValue>;
|
||||
// TODO: We currently require that a `checked` input not be present, as we may want to introduce a
|
||||
// third kind of form control for radio buttons that defines both a `value` and `checked` input.
|
||||
// We are still evaluating whether this makes sense, but if we decide not to persue this we can
|
||||
// remove this restriction.
|
||||
/**
|
||||
* The implementing component *must not* define a `checked` property. This is reserved for
|
||||
* components that want to integrate with the `Control` directive as a checkbox.
|
||||
*/
|
||||
readonly checked?: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contract for a form control that edits a boolean checkbox `Field`. Any component that
|
||||
* implements this contract can be used with the `Control` directive.
|
||||
*
|
||||
* Many of the properties declared on this contract are optional. They do not need to be
|
||||
* implemented, but if they are will be kept in sync with the field state of the field bound to the
|
||||
* `Control` directive.
|
||||
*/
|
||||
export interface FormCheckboxControl extends FormUiControl {
|
||||
/**
|
||||
* The checked is the only required property in this contract. A component that wants to integrate
|
||||
* with the `Control` directive, *must* provide a `model()` that will be kept in sync with the
|
||||
* value of the bound `Field`.
|
||||
*/
|
||||
readonly checked: ModelSignal<boolean>;
|
||||
// TODO: maybe this doesn't have to be strictly `undefined`? It just can't be a model signal.
|
||||
// Typescript doesn't really have a way to do any-but, but we could maybe introduce an optional
|
||||
// generic for it?
|
||||
/**
|
||||
* The implementing component *must not* define a `value` property. This is reserved for
|
||||
* components that want to integrate with the `Control` directive as a standard input.
|
||||
*/
|
||||
readonly value?: undefined;
|
||||
}
|
||||
209
packages/forms/signals/src/api/logic.ts
Normal file
209
packages/forms/signals/src/api/logic.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* @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 {addDefaultField} from '../field/validation';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {assertPathIsCurrent} from '../schema/schema';
|
||||
import {AggregateProperty, createProperty, Property} from './property';
|
||||
import type {
|
||||
FieldContext,
|
||||
FieldPath,
|
||||
FieldValidator,
|
||||
LogicFn,
|
||||
PathKind,
|
||||
TreeValidator,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Adds logic to a field to conditionally disable it. A disabled field does not contribute to the
|
||||
* validation, touched/dirty, or other state of its parent field.
|
||||
*
|
||||
* @param path The target path to add the disabled logic to.
|
||||
* @param logic A reactive function that returns `true` (or a string reason) when the field is disabled,
|
||||
* and `false` when it is not disabled.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addDisabledReasonRule((ctx) => {
|
||||
let result: boolean | string = true;
|
||||
if (typeof logic === 'string') {
|
||||
result = logic;
|
||||
} else if (logic) {
|
||||
result = logic(ctx as FieldContext<TValue, TPathKind>);
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return {field: ctx.field, message: result};
|
||||
}
|
||||
return result ? {field: ctx.field} : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds logic to a field to conditionally make it readonly. A readonly field does not contribute to
|
||||
* the validation, touched/dirty, or other state of its parent field.
|
||||
*
|
||||
* @param path The target path to make readonly.
|
||||
* @param logic A reactive function that returns `true` when the field is readonly.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>> = () => true,
|
||||
) {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addReadonlyRule(logic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds logic to a field to conditionally hide it. A hidden field does not contribute to the
|
||||
* validation, touched/dirty, or other state of its parent field.
|
||||
*
|
||||
* If a field may be hidden it is recommended to guard it with an `@if` in the template:
|
||||
* ```
|
||||
* @if (!email().hidden()) {
|
||||
* <label for="email">Email</label>
|
||||
* <input id="email" type="email" [control]="email" />
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param path The target path to add the hidden logic to.
|
||||
* @param logic A reactive function that returns `true` when the field is hidden.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addHiddenRule(logic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds logic to a field to determine if the field has validation errors.
|
||||
*
|
||||
* @param path The target path to add the validation logic to.
|
||||
* @param logic A `Validator` that returns the current validation errors.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
logic: NoInfer<FieldValidator<TValue, TPathKind>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addSyncErrorRule((ctx) =>
|
||||
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds logic to a field to determine if the field or any of its child fields has validation errors.
|
||||
*
|
||||
* @param path The target path to add the validation logic to.
|
||||
* @param logic A `TreeValidator` that returns the current validation errors.
|
||||
* Errors returned by the validator may specify a target field to indicate an error on a child field.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
logic: NoInfer<TreeValidator<TValue, TPathKind>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addSyncTreeErrorRule((ctx) =>
|
||||
addDefaultField(logic(ctx as FieldContext<TValue, TPathKind>), ctx.field),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a value to an `AggregateProperty` of a field.
|
||||
*
|
||||
* @param path The target path to set the aggregate property on.
|
||||
* @param prop The aggregate property
|
||||
* @param logic A function that receives the `FieldContext` and returns a value to add to the aggregate property.
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPropItem The type of value the property aggregates over.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function aggregateProperty<TValue, TPropItem, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
prop: AggregateProperty<any, TPropItem>,
|
||||
logic: NoInfer<LogicFn<TValue, TPropItem, TPathKind>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addAggregatePropertyRule(prop, logic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new `Property` and defines the value of the new property for the given field.
|
||||
*
|
||||
* @param path The path to define the property for.
|
||||
* @param factory A factory function that creates the value for the property.
|
||||
* This function is **not** reactive. It is run once when the field is created.
|
||||
* @returns The newly created property
|
||||
*/
|
||||
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
|
||||
): Property<TData>;
|
||||
|
||||
/**
|
||||
* Defines the value of a `Property` for a given field.
|
||||
*
|
||||
* @param path The path to define the property for.
|
||||
* @param prop The property to define.
|
||||
* @param factory A factory function that creates the value for the property.
|
||||
* This function is **not** reactive. It is run once when the field is created.
|
||||
* @returns The given property
|
||||
*/
|
||||
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
prop: Property<TData>,
|
||||
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
|
||||
): Property<TData>;
|
||||
|
||||
export function property<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
...rest:
|
||||
| [(ctx: FieldContext<TValue, TPathKind>) => TData]
|
||||
| [Property<TData>, (ctx: FieldContext<TValue, TPathKind>) => TData]
|
||||
): Property<TData> {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
let key: Property<TData>;
|
||||
let factory: (ctx: FieldContext<TValue, TPathKind>) => TData;
|
||||
if (rest.length === 2) {
|
||||
[key, factory] = rest;
|
||||
} else {
|
||||
[factory] = rest;
|
||||
}
|
||||
key ??= createProperty();
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.logic.addPropertyFactory(key, factory as (ctx: FieldContext<unknown>) => unknown);
|
||||
return key;
|
||||
}
|
||||
151
packages/forms/signals/src/api/property.ts
Normal file
151
packages/forms/signals/src/api/property.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a property that may be defined on a field when it is created using a `property` rule
|
||||
* in the schema. A particular `Property` can only be defined on a particular field **once**.
|
||||
*/
|
||||
export class Property<TValue> {
|
||||
private brand!: TValue;
|
||||
|
||||
/** Use {@link createProperty}. */
|
||||
private constructor() {}
|
||||
}
|
||||
|
||||
/** Creates a {@link Property}. */
|
||||
export function createProperty<TValue>(): Property<TValue> {
|
||||
return new (Property as new () => Property<TValue>)();
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a property that is aggregated from multiple parts according to the property's reducer
|
||||
* function. A value can be contributed to the aggregated value for a field using an
|
||||
* `aggregateProperty` rule in the schema. There may be multiple rules in a schema that contribute
|
||||
* values to the same `AggregateProperty` of the same field.
|
||||
*/
|
||||
export class AggregateProperty<TAcc, TItem> {
|
||||
private brand!: [TAcc, TItem];
|
||||
|
||||
/** Use {@link reducedProperty}. */
|
||||
private constructor(
|
||||
readonly reduce: (acc: TAcc, item: TItem) => TAcc,
|
||||
readonly getInitial: () => TAcc,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values into an accumulated value using
|
||||
* the given `reduce` and `getInitial` functions.
|
||||
* @param reduce The reducer function.
|
||||
* @param getInitial A function that gets the initial value for the reduce operation.
|
||||
*/
|
||||
export function reducedProperty<TAcc, TItem>(
|
||||
reduce: (acc: TAcc, item: TItem) => TAcc,
|
||||
getInitial: () => TAcc,
|
||||
): AggregateProperty<TAcc, TItem> {
|
||||
return new (AggregateProperty as new (
|
||||
reduce: (acc: TAcc, item: TItem) => TAcc,
|
||||
getInitial: () => TAcc,
|
||||
) => AggregateProperty<TAcc, TItem>)(reduce, getInitial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values into a list.
|
||||
*/
|
||||
export function listProperty<TItem>(): AggregateProperty<TItem[], TItem | undefined> {
|
||||
return reducedProperty<TItem[], TItem | undefined>(
|
||||
(acc, item) => (item === undefined ? acc : [...acc, item]),
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values by taking their min.
|
||||
*/
|
||||
export function minProperty(): AggregateProperty<number | undefined, number | undefined> {
|
||||
return reducedProperty<number | undefined, number | undefined>(
|
||||
(prev, next) => {
|
||||
if (prev === undefined) {
|
||||
return next;
|
||||
}
|
||||
if (next === undefined) {
|
||||
return prev;
|
||||
}
|
||||
return Math.min(prev, next);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values by taking their max.
|
||||
*/
|
||||
export function maxProperty(): AggregateProperty<number | undefined, number | undefined> {
|
||||
return reducedProperty<number | undefined, number | undefined>(
|
||||
(prev, next) => {
|
||||
if (prev === undefined) {
|
||||
return next;
|
||||
}
|
||||
if (next === undefined) {
|
||||
return prev;
|
||||
}
|
||||
return Math.max(prev, next);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values by logically or-ing them.
|
||||
*/
|
||||
export function orProperty(): AggregateProperty<boolean, boolean> {
|
||||
return reducedProperty(
|
||||
(prev, next) => prev || next,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an aggregate property that reduces its individual values by logically and-ing them.
|
||||
*/
|
||||
export function andProperty(): AggregateProperty<boolean, boolean> {
|
||||
return reducedProperty(
|
||||
(prev, next) => prev && next,
|
||||
() => true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An aggregate property representing whether the field is required.
|
||||
*/
|
||||
export const REQUIRED: AggregateProperty<boolean, boolean> = orProperty();
|
||||
|
||||
/**
|
||||
* An aggregate property representing the min value of the field.
|
||||
*/
|
||||
export const MIN: AggregateProperty<number | undefined, number | undefined> = maxProperty();
|
||||
|
||||
/**
|
||||
* An aggregate property representing the max value of the field.
|
||||
*/
|
||||
export const MAX: AggregateProperty<number | undefined, number | undefined> = minProperty();
|
||||
|
||||
/**
|
||||
* An aggregate property representing the min length of the field.
|
||||
*/
|
||||
export const MIN_LENGTH: AggregateProperty<number | undefined, number | undefined> = maxProperty();
|
||||
|
||||
/**
|
||||
* An aggregate property representing the max length of the field.
|
||||
*/
|
||||
export const MAX_LENGTH: AggregateProperty<number | undefined, number | undefined> = minProperty();
|
||||
|
||||
/**
|
||||
* An aggregate property representing the patterns the field must match.
|
||||
*/
|
||||
export const PATTERN: AggregateProperty<RegExp[], RegExp | undefined> = listProperty<RegExp>();
|
||||
428
packages/forms/signals/src/api/structure.ts
Normal file
428
packages/forms/signals/src/api/structure.ts
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
/**
|
||||
* @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 {inject, Injector, runInInjectionContext, WritableSignal} from '@angular/core';
|
||||
|
||||
import {BasicFieldAdapter, FieldAdapter} from '../field/field_adapter';
|
||||
import {FormFieldManager} from '../field/manager';
|
||||
import {FieldNode} from '../field/node';
|
||||
import {addDefaultField} from '../field/validation';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {assertPathIsCurrent, isSchemaOrSchemaFn, SchemaImpl} from '../schema/schema';
|
||||
import {isArray} from '../util/type_guards';
|
||||
import type {
|
||||
Field,
|
||||
FieldPath,
|
||||
LogicFn,
|
||||
OneOrMany,
|
||||
PathKind,
|
||||
Schema,
|
||||
SchemaFn,
|
||||
SchemaOrSchemaFn,
|
||||
TreeValidationResult,
|
||||
} from './types';
|
||||
import {ValidationError, WithOptionalField} from './validation_errors';
|
||||
|
||||
/** Options that may be specified when creating a form. */
|
||||
export interface FormOptions {
|
||||
/**
|
||||
* The injector to use for dependency injection. If this is not provided, the injector for the
|
||||
* current [injection context](guide/di/dependency-injection-context), will be used.
|
||||
*/
|
||||
injector?: Injector;
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Adapter allows managing fields in a more flexible way.
|
||||
* Currently this is used to support interop with reactive forms.
|
||||
*/
|
||||
adapter?: FieldAdapter;
|
||||
}
|
||||
|
||||
/** Extracts the model, schema, and options from the arguments passed to `form()`. */
|
||||
function normalizeFormArgs<TValue>(
|
||||
args: any[],
|
||||
): [WritableSignal<TValue>, SchemaOrSchemaFn<TValue> | undefined, FormOptions | undefined] {
|
||||
let model: WritableSignal<TValue>;
|
||||
let schema: SchemaOrSchemaFn<TValue> | undefined;
|
||||
let options: FormOptions | undefined;
|
||||
|
||||
if (args.length === 3) {
|
||||
[model, schema, options] = args;
|
||||
} else if (args.length === 2) {
|
||||
if (isSchemaOrSchemaFn(args[1])) {
|
||||
[model, schema] = args;
|
||||
} else {
|
||||
[model, options] = args;
|
||||
}
|
||||
} else {
|
||||
[model] = args;
|
||||
}
|
||||
|
||||
return [model, schema, options];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
|
||||
* the model data.
|
||||
*
|
||||
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
|
||||
* data. This means that updating the value on a `FieldState` updates the originally passed in model
|
||||
* as well.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameModel = signal({first: '', last: ''});
|
||||
* const nameForm = form(nameModel);
|
||||
* nameForm.first().value.set('John');
|
||||
* nameForm().value(); // {first: 'John', last: ''}
|
||||
* nameModel(); // {first: 'John', last: ''}
|
||||
* ```
|
||||
*
|
||||
* @param model A writable signal that contains the model data for the form. The resulting field
|
||||
* structure will match the shape of the model and any changes to the form data will be written to
|
||||
* the model.
|
||||
* @return A `Field` representing a form around the data model.
|
||||
* @template TValue The type of the data model.
|
||||
*/
|
||||
export function form<TValue>(model: WritableSignal<TValue>): Field<TValue>;
|
||||
|
||||
/**
|
||||
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
|
||||
* the model data.
|
||||
*
|
||||
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
|
||||
* data. This means that updating the value on a `FieldState` updates the originally passed in model
|
||||
* as well.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameModel = signal({first: '', last: ''});
|
||||
* const nameForm = form(nameModel);
|
||||
* nameForm.first().value.set('John');
|
||||
* nameForm().value(); // {first: 'John', last: ''}
|
||||
* nameModel(); // {first: 'John', last: ''}
|
||||
* ```
|
||||
*
|
||||
* The form can also be created with a schema, which is a set of rules that define the logic for the
|
||||
* form. The schema can be either a pre-defined schema created with the `schema` function, or a
|
||||
* function that builds the schema by binding logic to a parts of the field structure.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameForm = form(signal({first: '', last: ''}), (name) => {
|
||||
* required(name.first);
|
||||
* pattern(name.last, /^[a-z]+$/i, {message: 'Alphabet characters only'});
|
||||
* });
|
||||
* nameForm().valid(); // false
|
||||
* nameForm().value.set({first: 'John', last: 'Doe'});
|
||||
* nameForm().valid(); // true
|
||||
* ```
|
||||
*
|
||||
* @param model A writable signal that contains the model data for the form. The resulting field
|
||||
* structure will match the shape of the model and any changes to the form data will be written to
|
||||
* the model.
|
||||
* @param schemaOrOptions The second argument can be either
|
||||
* 1. A schema or a function used to specify logic for the form (e.g. validation, disabled fields, etc.).
|
||||
* When passing a schema, the form options can be passed as a third argument if needed.
|
||||
* 2. The form options
|
||||
* @return A `Field` representing a form around the data model
|
||||
* @template TValue The type of the data model.
|
||||
*/
|
||||
export function form<TValue>(
|
||||
model: WritableSignal<TValue>,
|
||||
schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions,
|
||||
): Field<TValue>;
|
||||
|
||||
/**
|
||||
* Creates a form wrapped around the given model data. A form is represented as simply a `Field` of
|
||||
* the model data.
|
||||
*
|
||||
* `form` uses the given model as the source of truth and *does not* maintain its own copy of the
|
||||
* data. This means that updating the value on a `FieldState` updates the originally passed in model
|
||||
* as well.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameModel = signal({first: '', last: ''});
|
||||
* const nameForm = form(nameModel);
|
||||
* nameForm.first().value.set('John');
|
||||
* nameForm().value(); // {first: 'John', last: ''}
|
||||
* nameModel(); // {first: 'John', last: ''}
|
||||
* ```
|
||||
*
|
||||
* The form can also be created with a schema, which is a set of rules that define the logic for the
|
||||
* form. The schema can be either a pre-defined schema created with the `schema` function, or a
|
||||
* function that builds the schema by binding logic to a parts of the field structure.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameForm = form(signal({first: '', last: ''}), (name) => {
|
||||
* required(name.first);
|
||||
* error(name.last, ({value}) => !/^[a-z]+$/i.test(value()), 'Alphabet characters only');
|
||||
* });
|
||||
* nameForm().valid(); // false
|
||||
* nameForm().value.set({first: 'John', last: 'Doe'});
|
||||
* nameForm().valid(); // true
|
||||
* ```
|
||||
*
|
||||
* @param model A writable signal that contains the model data for the form. The resulting field
|
||||
* structure will match the shape of the model and any changes to the form data will be written to
|
||||
* the model.
|
||||
* @param schema A schema or a function used to specify logic for the form (e.g. validation, disabled fields, etc.)
|
||||
* @param options The form options
|
||||
* @return A `Field` representing a form around the data model.
|
||||
* @template TValue The type of the data model.
|
||||
*/
|
||||
export function form<TValue>(
|
||||
model: WritableSignal<TValue>,
|
||||
schema: SchemaOrSchemaFn<TValue>,
|
||||
options: FormOptions,
|
||||
): Field<TValue>;
|
||||
|
||||
export function form<TValue>(...args: any[]): Field<TValue> {
|
||||
const [model, schema, options] = normalizeFormArgs<TValue>(args);
|
||||
const injector = options?.injector ?? inject(Injector);
|
||||
const pathNode = runInInjectionContext(injector, () => SchemaImpl.rootCompile(schema));
|
||||
const fieldManager = new FormFieldManager(injector, options?.name);
|
||||
const adapter = options?.adapter ?? new BasicFieldAdapter();
|
||||
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
|
||||
fieldManager.createFieldManagementEffect(fieldRoot.structure);
|
||||
|
||||
return fieldRoot.fieldProxy as Field<TValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a schema to each item of an array.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameSchema = schema<{first: string, last: string}>((name) => {
|
||||
* required(name.first);
|
||||
* required(name.last);
|
||||
* });
|
||||
* const namesForm = form(signal([{first: '', last: ''}]), (names) => {
|
||||
* applyEach(names, nameSchema);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* When binding logic to the array items, the `Field` for the array item is passed as an additional
|
||||
* argument. This can be used to reference other properties on the item.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const namesForm = form(signal([{first: '', last: ''}]), (names) => {
|
||||
* applyEach(names, (name) => {
|
||||
* error(
|
||||
* name.last,
|
||||
* (value, nameField) => value === nameField.first().value(),
|
||||
* 'Last name must be different than first name',
|
||||
* );
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param path The target path for an array field whose items the schema will be applied to.
|
||||
* @param schema A schema for an element of the array, or function that binds logic to an
|
||||
* element of the array.
|
||||
* @template TValue The data type of the item field to apply the schema to.
|
||||
*/
|
||||
export function applyEach<TValue>(
|
||||
path: FieldPath<TValue[]>,
|
||||
schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const elementPath = FieldPathNode.unwrapFieldPath(path).element.fieldPathProxy;
|
||||
apply(elementPath, schema as Schema<TValue>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a predefined schema to a given `FieldPath`.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const nameSchema = schema<{first: string, last: string}>((name) => {
|
||||
* required(name.first);
|
||||
* required(name.last);
|
||||
* });
|
||||
* const profileForm = form(signal({name: {first: '', last: ''}, age: 0}), (profile) => {
|
||||
* apply(profile.name, nameSchema);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param path The target path to apply the schema to.
|
||||
* @param schema The schema to apply to the property
|
||||
* @template TValue The data type of the field to apply the schema to.
|
||||
*/
|
||||
export function apply<TValue>(
|
||||
path: FieldPath<TValue>,
|
||||
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.mergeIn(SchemaImpl.create(schema));
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies a predefined schema to a given `FieldPath`.
|
||||
*
|
||||
* @param path The target path to apply the schema to.
|
||||
* @param logic A `LogicFn<T, boolean>` that returns `true` when the schema should be applied.
|
||||
* @param schema The schema to apply to the field when the `logic` function returns `true`.
|
||||
* @template TValue The data type of the field to apply the schema to.
|
||||
*/
|
||||
export function applyWhen<TValue>(
|
||||
path: FieldPath<TValue>,
|
||||
logic: LogicFn<TValue, boolean>,
|
||||
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
pathNode.mergeIn(SchemaImpl.create(schema), {fn: logic, path});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditionally applies a predefined schema to a given `FieldPath`.
|
||||
*
|
||||
* @param path The target path to apply the schema to.
|
||||
* @param predicate A type guard that accepts a value `T` and returns `true` if `T` is of type
|
||||
* `TNarrowed`.
|
||||
* @param schema The schema to apply to the field when `predicate` returns `true`.
|
||||
* @template TValue The data type of the field to apply the schema to.
|
||||
* @template TNarrowed The data type of the schema (a narrowed type of TValue).
|
||||
*/
|
||||
export function applyWhenValue<TValue, TNarrowed extends TValue>(
|
||||
path: FieldPath<TValue>,
|
||||
predicate: (value: TValue) => value is TNarrowed,
|
||||
schema: SchemaOrSchemaFn<TNarrowed>,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* Conditionally applies a predefined schema to a given `FieldPath`.
|
||||
*
|
||||
* @param path The target path to apply the schema to.
|
||||
* @param predicate A function that accepts a value `T` and returns `true` when the schema
|
||||
* should be applied.
|
||||
* @param schema The schema to apply to the field when `predicate` returns `true`.
|
||||
* @template TValue The data type of the field to apply the schema to.
|
||||
*/
|
||||
export function applyWhenValue<TValue>(
|
||||
path: FieldPath<TValue>,
|
||||
predicate: (value: TValue) => boolean,
|
||||
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
|
||||
): void;
|
||||
|
||||
export function applyWhenValue(
|
||||
path: FieldPath<unknown>,
|
||||
predicate: (value: unknown) => boolean,
|
||||
schema: SchemaOrSchemaFn<unknown>,
|
||||
) {
|
||||
applyWhen(path, ({value}) => predicate(value()), schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a given `Field` using the given action function and applies any server errors resulting
|
||||
* from the action to the field. Server errors returned by the `action` will be integrated into the
|
||||
* field as a `ValidationError` on the sub-field indicated by the `field` property of the server
|
||||
* error.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* async function registerNewUser(registrationForm: Field<{username: string, password: string}>) {
|
||||
* const result = await myClient.registerNewUser(registrationForm().value());
|
||||
* if (result.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
|
||||
* return [{
|
||||
* field: registrationForm.username,
|
||||
* error: {kind: 'server', message: 'Username already taken'}
|
||||
* }];
|
||||
* }
|
||||
* return undefined;
|
||||
* }
|
||||
*
|
||||
* const registrationForm = form(signal({username: 'god', password: ''}));
|
||||
* submit(registrationForm, async (f) => {
|
||||
* return registerNewUser(registrationForm);
|
||||
* });
|
||||
* registrationForm.username().errors(); // [{kind: 'server', message: 'Username already taken'}]
|
||||
* ```
|
||||
*
|
||||
* @param form The field to submit.
|
||||
* @param action An asynchronous action used to submit the field. The action may return server
|
||||
* errors.
|
||||
* @template TValue The data type of the field being submitted.
|
||||
*/
|
||||
export async function submit<TValue>(
|
||||
form: Field<TValue>,
|
||||
action: (form: Field<TValue>) => Promise<TreeValidationResult>,
|
||||
) {
|
||||
const node = form() as FieldNode;
|
||||
markAllAsTouched(node);
|
||||
|
||||
// Fail fast if the form is already invalid.
|
||||
if (node.invalid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.submitState.selfSubmitting.set(true);
|
||||
try {
|
||||
const errors = await action(form);
|
||||
errors && setServerErrors(node, errors);
|
||||
} finally {
|
||||
node.submitState.selfSubmitting.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a list of server errors to their individual fields.
|
||||
*
|
||||
* @param submittedField The field that was submitted, resulting in the errors.
|
||||
* @param errors The errors to set.
|
||||
*/
|
||||
function setServerErrors(
|
||||
submittedField: FieldNode,
|
||||
errors: OneOrMany<WithOptionalField<ValidationError>>,
|
||||
) {
|
||||
if (!isArray(errors)) {
|
||||
errors = [errors];
|
||||
}
|
||||
const errorsByField = new Map<FieldNode, ValidationError[]>();
|
||||
for (const error of errors) {
|
||||
const errorWithField = addDefaultField(error, submittedField.fieldProxy);
|
||||
const field = errorWithField.field() as FieldNode;
|
||||
let fieldErrors = errorsByField.get(field);
|
||||
if (!fieldErrors) {
|
||||
fieldErrors = [];
|
||||
errorsByField.set(field, fieldErrors);
|
||||
}
|
||||
fieldErrors.push(errorWithField);
|
||||
}
|
||||
for (const [field, fieldErrors] of errorsByField) {
|
||||
field.submitState.serverErrors.set(fieldErrors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `Schema` that adds logic rules to a form.
|
||||
* @param fn A **non-reactive** function that sets up reactive logic rules for the form.
|
||||
* @returns A schema object that implements the given logic.
|
||||
* @template TValue The value type of a `Field` that this schema binds to.
|
||||
*/
|
||||
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue> {
|
||||
return SchemaImpl.create(fn) as unknown as Schema<TValue>;
|
||||
}
|
||||
|
||||
/** Marks a {@link node} and its descendants as touched. */
|
||||
function markAllAsTouched(node: FieldNode) {
|
||||
node.markAsTouched();
|
||||
for (const child of node.structure.children()) {
|
||||
markAllAsTouched(child);
|
||||
}
|
||||
}
|
||||
486
packages/forms/signals/src/api/types.ts
Normal file
486
packages/forms/signals/src/api/types.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
/**
|
||||
* @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 {Signal, WritableSignal} from '@angular/core';
|
||||
import type {Control} from '../controls/control';
|
||||
import {AggregateProperty, Property} from './property';
|
||||
import type {ValidationError, WithOptionalField, WithoutField} from './validation_errors';
|
||||
|
||||
/**
|
||||
* Symbol used to retain generic type information when it would otherwise be lost.
|
||||
*/
|
||||
declare const ɵɵTYPE: unique symbol;
|
||||
|
||||
/**
|
||||
* Creates a type based on the given type T, but with all readonly properties made writable.
|
||||
* @template T The type to create a mutable version of.
|
||||
*/
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that represents either a single value of type `T` or a readonly array of `T`.
|
||||
* @template T The type of the value(s).
|
||||
*/
|
||||
export type OneOrMany<T> = T | readonly T[];
|
||||
|
||||
/**
|
||||
* The kind of `FieldPath` (`Root`, `Child` of another `FieldPath`, or `Item` in a `FieldPath` array)
|
||||
*/
|
||||
export declare namespace PathKind {
|
||||
/**
|
||||
* The `PathKind` for a `FieldPath` that is at the root of its field tree.
|
||||
*/
|
||||
export interface Root {
|
||||
/**
|
||||
* The `ɵɵTYPE` is constructed to allow the `extends` clause on `Child` and `Item` to narrow the
|
||||
* type. Another way to think about this is, if we have a function that expects this kind of
|
||||
* path, the `ɵɵTYPE` lists the kinds of path we are allowed to pass to it.
|
||||
*/
|
||||
[ɵɵTYPE]: 'root' | 'child' | 'item';
|
||||
}
|
||||
|
||||
/**
|
||||
* The `PathKind` for a `FieldPath` that is a child of another `FieldPath`.
|
||||
*/
|
||||
export interface Child extends PathKind.Root {
|
||||
[ɵɵTYPE]: 'child' | 'item';
|
||||
}
|
||||
|
||||
/**
|
||||
* The `PathKind` for a `FieldPath` that is an item in a `FieldPath` array.
|
||||
*/
|
||||
export interface Item extends PathKind.Child {
|
||||
[ɵɵTYPE]: 'item';
|
||||
}
|
||||
}
|
||||
export type PathKind = PathKind.Root | PathKind.Child | PathKind.Item;
|
||||
/**
|
||||
* A status indicating whether a field is unsubmitted, submitted, or currently submitting.
|
||||
*/
|
||||
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
|
||||
|
||||
/**
|
||||
* A reason for a field's disablement.
|
||||
*/
|
||||
export interface DisabledReason {
|
||||
/** The field that is disabled. */
|
||||
readonly field: Field<unknown>;
|
||||
/** A user-facing message describing the reason for the disablement. */
|
||||
readonly message?: string;
|
||||
}
|
||||
|
||||
/** The absence of an error which indicates a successful validation result. */
|
||||
export type ValidationSuccess = null | undefined | void;
|
||||
|
||||
/**
|
||||
* The result of running a field validation function.
|
||||
*
|
||||
* The result may be one of the following:
|
||||
* 1. A {@link ValidationSuccess} to indicate no errors.
|
||||
* 2. A {@link ValidationError} without a field to indicate an error on the field being validated.
|
||||
* 3. A list of {@link ValidationError} without fields to indicate multiple errors on the field
|
||||
* being validated.
|
||||
*
|
||||
* @template E the type of error (defaults to {@link ValidationError}).
|
||||
*/
|
||||
export type FieldValidationResult<E extends ValidationError = ValidationError> =
|
||||
| ValidationSuccess
|
||||
| OneOrMany<WithoutField<E>>;
|
||||
|
||||
/**
|
||||
* The result of running a tree validation function.
|
||||
*
|
||||
* The result may be one of the following:
|
||||
* 1. A {@link ValidationSuccess} to indicate no errors.
|
||||
* 2. A {@link ValidationError} without a field to indicate an error on the field being validated.
|
||||
* 3. A {@link ValidationError} with a field to indicate an error on the target field.
|
||||
* 4. A list of {@link ValidationError} with or without fields to indicate multiple errors.
|
||||
*
|
||||
* @template E the type of error (defaults to {@link ValidationError}).
|
||||
*/
|
||||
export type TreeValidationResult<E extends ValidationError = ValidationError> =
|
||||
| ValidationSuccess
|
||||
| OneOrMany<WithOptionalField<E>>;
|
||||
|
||||
/**
|
||||
* A validation result where all errors explicitly define their target field.
|
||||
*
|
||||
* The result may be one of the following:
|
||||
* 1. A {@link ValidationSuccess} to indicate no errors.
|
||||
* 2. A {@link ValidationError} with a field to indicate an error on the target field.
|
||||
* 3. A list of {@link ValidationError} with fields to indicate multiple errors.
|
||||
*
|
||||
* @template E the type of error (defaults to {@link ValidationError}).
|
||||
*/
|
||||
export type ValidationResult<E extends ValidationError = ValidationError> =
|
||||
| ValidationSuccess
|
||||
| OneOrMany<E>;
|
||||
|
||||
/**
|
||||
* An asynchronous validation result where all errors explicitly define their target field.
|
||||
*
|
||||
* The result may be one of the following:
|
||||
* 1. A {@link ValidationResult} to indicate the result if resolved.
|
||||
* 5. 'pending' if the validation is not yet resolved.
|
||||
*
|
||||
* @template E the type of error (defaults to {@link ValidationError}).
|
||||
*/
|
||||
export type AsyncValidationResult<E extends ValidationError = ValidationError> =
|
||||
| ValidationResult<E>
|
||||
| 'pending';
|
||||
|
||||
/**
|
||||
* An object that represents a single field in a form. This includes both primitive value fields
|
||||
* (e.g. fields that contain a `string` or `number`), as well as "grouping fields" that contain
|
||||
* sub-fields. `Field` objects are arranged in a tree whose structure mimics the structure of the
|
||||
* underlying data. For example a `Field<{x: number}>` has a property `x` which contains a
|
||||
* `Field<number>`. To access the state associated with a field, call it as a function.
|
||||
*
|
||||
* @template TValue The type of the data which the field is wrapped around.
|
||||
* @template TKey The type of the property key which this field resides under in its parent.
|
||||
*/
|
||||
export type Field<TValue, TKey extends string | number = string | number> = (() => FieldState<
|
||||
TValue,
|
||||
TKey
|
||||
>) &
|
||||
(TValue extends Array<infer U>
|
||||
? ReadonlyArrayLike<MaybeField<U, number>>
|
||||
: TValue extends Record<string, any>
|
||||
? Subfields<TValue>
|
||||
: unknown);
|
||||
|
||||
/**
|
||||
* The sub-fields that a user can navigate to from a `Field<TValue>`.
|
||||
*
|
||||
* @template TValue The type of the data which the parent field is wrapped around.
|
||||
*/
|
||||
export type Subfields<TValue> = {
|
||||
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeField<
|
||||
TValue[K],
|
||||
string
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An iterable object with the same shape as a readonly array.
|
||||
*
|
||||
* @template T The array item type.
|
||||
*/
|
||||
export type ReadonlyArrayLike<T> = Pick<
|
||||
ReadonlyArray<T>,
|
||||
number | 'length' | typeof Symbol.iterator
|
||||
>;
|
||||
|
||||
/**
|
||||
* Helper type for defining `Field`. Given a type `TValue` that may include `undefined`, it extracts
|
||||
* the `undefined` outside the `Field` type.
|
||||
*
|
||||
* For example `MaybeField<{a: number} | undefined, TKey>` would be equivalent to
|
||||
* `undefined | Field<{a: number}, TKey>`.
|
||||
*
|
||||
* @template TValue The type of the data which the field is wrapped around.
|
||||
* @template TKey The type of the property key which this field resides under in its parent.
|
||||
*/
|
||||
export type MaybeField<TValue, TKey extends string | number = string | number> =
|
||||
| (TValue & undefined)
|
||||
| Field<Exclude<TValue, undefined>, TKey>;
|
||||
|
||||
/**
|
||||
* Contains all of the state (e.g. value, statuses, etc.) associated with a `Field`, exposed as
|
||||
* signals.
|
||||
*/
|
||||
export interface FieldState<TValue, TKey extends string | number = string | number> {
|
||||
/**
|
||||
* A writable signal containing the value for this field. Updating this signal will update the
|
||||
* data model that the field is bound to.
|
||||
*/
|
||||
readonly value: WritableSignal<TValue>;
|
||||
/**
|
||||
* A signal indicating whether the field has been touched by the user.
|
||||
*/
|
||||
readonly touched: Signal<boolean>;
|
||||
/**
|
||||
* A signal indicating whether field value has been changed by user.
|
||||
*/
|
||||
readonly dirty: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether a field is hidden.
|
||||
*
|
||||
* When a field is hidden it is ignored when determining the valid, touched, and dirty states.
|
||||
*
|
||||
* Note: This doesn't hide the field in the template, that must be done manually.
|
||||
* ```
|
||||
* @if (!field.hidden()) {
|
||||
* ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
readonly hidden: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field is currently disabled.
|
||||
*/
|
||||
readonly disabled: Signal<boolean>;
|
||||
/**
|
||||
* A signal containing the reasons why the field is currently disabled.
|
||||
*/
|
||||
readonly disabledReasons: Signal<readonly DisabledReason[]>;
|
||||
/**
|
||||
* A signal indicating whether the field is currently readonly.
|
||||
*/
|
||||
readonly readonly: Signal<boolean>;
|
||||
/**
|
||||
* A signal containing the current errors for the field.
|
||||
*/
|
||||
readonly errors: Signal<ValidationError[]>;
|
||||
/**
|
||||
* A signal containing the {@link errors} of the field and its descendants.
|
||||
*/
|
||||
readonly errorSummary: Signal<ValidationError[]>;
|
||||
/**
|
||||
* A signal indicating whether the field's value is currently valid.
|
||||
*
|
||||
* Note: `valid()` is not the same as `!invalid()`.
|
||||
* - `valid()` is `true` when there are no validation errors *and* no pending validators.
|
||||
* - `invalid()` is `true` when there are validation errors, regardless of pending validators.
|
||||
*
|
||||
* Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
|
||||
* which is still pending. In this case `valid()` is `false` because of the pending validator.
|
||||
* However `invalid()` is also `false` because there are no errors.
|
||||
*/
|
||||
readonly valid: Signal<boolean>;
|
||||
/**
|
||||
* A signal indicating whether the field's value is currently invalid.
|
||||
*
|
||||
* Note: `invalid()` is not the same as `!valid()`.
|
||||
* - `invalid()` is `true` when there are validation errors, regardless of pending validators.
|
||||
* - `valid()` is `true` when there are no validation errors *and* no pending validators.
|
||||
*
|
||||
* Ex: consider the situation where a field has 3 validators, 2 of which have no errors and 1 of
|
||||
* which is still pending. In this case `invalid()` is `false` because there are no errors.
|
||||
* However `valid()` is also `false` because of the pending validator.
|
||||
*/
|
||||
readonly invalid: Signal<boolean>;
|
||||
/**
|
||||
* Whether there are any validators still pending for this field.
|
||||
*/
|
||||
readonly pending: Signal<boolean>;
|
||||
/**
|
||||
* A signal indicating whether the field is currently in the process of being submitted.
|
||||
*/
|
||||
readonly submitting: Signal<boolean>;
|
||||
/**
|
||||
* A signal of a unique name for the field, by default based on the name of its parent field.
|
||||
*/
|
||||
readonly name: Signal<string>;
|
||||
|
||||
/**
|
||||
* The property key in the parent field under which this field is stored. If the parent field is
|
||||
* array-valued, for example, this is the index of this field in that array.
|
||||
*/
|
||||
readonly keyInParent: Signal<TKey>;
|
||||
/**
|
||||
* A signal containing the `Control` directives this field is currently bound to.
|
||||
*/
|
||||
readonly controls: Signal<readonly Control<unknown>[]>;
|
||||
|
||||
/**
|
||||
* Reads an aggregate property value from the field.
|
||||
* @param prop The property to read.
|
||||
*/
|
||||
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
|
||||
|
||||
/**
|
||||
* Reads a property value from the field.
|
||||
* @param prop The property key to read.
|
||||
*/
|
||||
property<M>(prop: Property<M>): M | undefined;
|
||||
|
||||
/**
|
||||
* Checks whether the given metadata key has been defined for this field.
|
||||
*/
|
||||
hasProperty(key: Property<any> | AggregateProperty<any, any>): boolean;
|
||||
|
||||
/**
|
||||
* Sets the touched status of the field to `true`.
|
||||
*/
|
||||
markAsTouched(): void;
|
||||
|
||||
/**
|
||||
* Sets the dirty status of the field to `true`.
|
||||
*/
|
||||
markAsDirty(): void;
|
||||
|
||||
/**
|
||||
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
|
||||
*
|
||||
* Note this does not change the data model, which can be reset directly if desired.
|
||||
*/
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object that represents a location in the `Field` tree structure and is used to bind logic to a
|
||||
* particular part of the structure prior to the creation of the form. Because the `FieldPath`
|
||||
* exists prior to the form's creation, it cannot be used to access any of the field state.
|
||||
*
|
||||
* @template TValue The type of the data which the form is wrapped around.
|
||||
* @template TPathKind The kind of path (root field, child field, or item of an array)
|
||||
*/
|
||||
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
|
||||
[ɵɵTYPE]: [TValue, TPathKind];
|
||||
} & (TValue extends Array<unknown>
|
||||
? unknown
|
||||
: TValue extends Record<string, any>
|
||||
? {[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>}
|
||||
: unknown);
|
||||
|
||||
/**
|
||||
* Helper type for defining `FieldPath`. Given a type `TValue` that may include `undefined`, it
|
||||
* extracts the `undefined` outside the `FieldPath` type.
|
||||
*
|
||||
* For example `MaybeFieldPath<{a: number} | undefined, PathKind.Child>` would be equivalent to
|
||||
* `undefined | Field<{a: number}, PathKind.child>`.
|
||||
*
|
||||
* @template TValue The type of the data which the field is wrapped around.
|
||||
* @template TPathKind The kind of path (root field, child field, or item of an array)
|
||||
*/
|
||||
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> =
|
||||
| (TValue & undefined)
|
||||
| FieldPath<Exclude<TValue, undefined>, TPathKind>;
|
||||
|
||||
/**
|
||||
* Defines logic for a form.
|
||||
*
|
||||
* @template TValue The type of data stored in the form that this schema is attached to.
|
||||
*/
|
||||
export type Schema<in TValue> = {
|
||||
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function that defines rules for a schema.
|
||||
*
|
||||
* @template TValue The type of data stored in the form that this schema function is attached to.
|
||||
* @template TPathKind The kind of path this schema function can be bound to.
|
||||
*/
|
||||
export type SchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = (
|
||||
p: FieldPath<TValue, TPathKind>,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* A schema or schema definition function.
|
||||
*
|
||||
* @template TValue The type of data stored in the form that this schema function is attached to.
|
||||
* @template TPathKind The kind of path this schema function can be bound to.
|
||||
*/
|
||||
export type SchemaOrSchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> =
|
||||
| Schema<TValue>
|
||||
| SchemaFn<TValue, TPathKind>;
|
||||
|
||||
/**
|
||||
* A function that receives the `FieldContext` for the field the logic is bound to and returns
|
||||
* a specific result type.
|
||||
*
|
||||
* @template TValue The data type for the field the logic is bound to.
|
||||
* @template TReturn The type of the result returned by the logic function.
|
||||
* @template TPathKind The kind of path the logic is applied to (root field, child field, or item of an array)
|
||||
*/
|
||||
export type LogicFn<TValue, TReturn, TPathKind extends PathKind = PathKind.Root> = (
|
||||
ctx: FieldContext<TValue, TPathKind>,
|
||||
) => TReturn;
|
||||
|
||||
/**
|
||||
* A function that takes the `FieldContext` for the field being validated and returns a
|
||||
* `ValidationResult` indicating errors for the field.
|
||||
*
|
||||
* @template TValue The type of value stored in the field being validated
|
||||
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
|
||||
*/
|
||||
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
|
||||
TValue,
|
||||
FieldValidationResult,
|
||||
TPathKind
|
||||
>;
|
||||
|
||||
/**
|
||||
* A function that takes the `FieldContext` for the field being validated and returns a
|
||||
* `TreeValidationResult` indicating errors for the field and its sub-fields.
|
||||
*
|
||||
* @template TValue The type of value stored in the field being validated
|
||||
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
|
||||
*/
|
||||
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
|
||||
TValue,
|
||||
TreeValidationResult,
|
||||
TPathKind
|
||||
>;
|
||||
|
||||
/**
|
||||
* A function that takes the `FieldContext` for the field being validated and returns a
|
||||
* `ValidationResult` indicating errors for the field and its sub-fields. In a `Validator` all
|
||||
* errors must explicitly define their target field.
|
||||
*
|
||||
* @template TValue The type of value stored in the field being validated
|
||||
* @template TPathKind The kind of path being validated (root field, child field, or item of an array)
|
||||
*/
|
||||
export type Validator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<
|
||||
TValue,
|
||||
ValidationResult,
|
||||
TPathKind
|
||||
>;
|
||||
|
||||
/**
|
||||
* Provides access to the state of the current field as well as functions that can be used to look
|
||||
* up state of other fields based on a `FieldPath`.
|
||||
*/
|
||||
export type FieldContext<
|
||||
TValue,
|
||||
TPathKind extends PathKind = PathKind.Root,
|
||||
> = TPathKind extends PathKind.Item
|
||||
? ItemFieldContext<TValue>
|
||||
: TPathKind extends PathKind.Child
|
||||
? ChildFieldContext<TValue>
|
||||
: RootFieldContext<TValue>;
|
||||
|
||||
/**
|
||||
* The base field context that is available for all fields.
|
||||
*/
|
||||
export interface RootFieldContext<TValue> {
|
||||
/** A signal containing the value of the current field. */
|
||||
readonly value: Signal<TValue>;
|
||||
/** The state of the current field. */
|
||||
readonly state: FieldState<TValue>;
|
||||
/** The current field. */
|
||||
readonly field: Field<TValue>;
|
||||
/** Gets the value of the field represented by the given path. */
|
||||
readonly valueOf: <P>(p: FieldPath<P>) => P;
|
||||
/** Gets the state of the field represented by the given path. */
|
||||
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
|
||||
/** Gets the field represented by the given path. */
|
||||
readonly fieldOf: <P>(p: FieldPath<P>) => Field<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field context that is available for all fields that are a child of another field.
|
||||
*/
|
||||
export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
|
||||
/** The key of the current field in its parent field. */
|
||||
readonly key: Signal<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field context that is available for all fields that are an item in an array field.
|
||||
*/
|
||||
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
|
||||
/** The index of the current field in its parent field. */
|
||||
readonly index: Signal<number>;
|
||||
}
|
||||
415
packages/forms/signals/src/api/validation_errors.ts
Normal file
415
packages/forms/signals/src/api/validation_errors.ts
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
/**
|
||||
* @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 type {StandardSchemaV1} from '@standard-schema/spec';
|
||||
import {Field} from './types';
|
||||
|
||||
/** Internal symbol used for class branding. */
|
||||
const BRAND = Symbol();
|
||||
|
||||
/**
|
||||
* Options used to create a `ValidationError`.
|
||||
*/
|
||||
interface ValidationErrorOptions {
|
||||
/** Human readable error message. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type that requires the given type `T` to have a `field` property.
|
||||
* @template T The type to add a `field` to.
|
||||
*/
|
||||
export type WithField<T> = T & {field: Field<unknown>};
|
||||
|
||||
/**
|
||||
* A type that allows the given type `T` to optionally have a `field` property.
|
||||
* @template T The type to optionally add a `field` to.
|
||||
*/
|
||||
export type WithOptionalField<T> = T & {field?: Field<unknown>};
|
||||
|
||||
/**
|
||||
* A type that ensures the given type `T` does not have a `field` property.
|
||||
* @template T The type to remove the `field` from.
|
||||
*/
|
||||
export type WithoutField<T> = T & {field: never};
|
||||
|
||||
/**
|
||||
* Create a required error associated with the target field
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function requiredError(options: WithField<ValidationErrorOptions>): RequiredValidationError;
|
||||
/**
|
||||
* Create a required error
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function requiredError(
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<RequiredValidationError>;
|
||||
export function requiredError(
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<RequiredValidationError> {
|
||||
return new RequiredValidationError(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a min value error associated with the target field
|
||||
* @param min The min value constraint
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function minError(
|
||||
min: number,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): MinValidationError;
|
||||
/**
|
||||
* Create a min value error
|
||||
* @param min The min value constraint
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function minError(
|
||||
min: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<MinValidationError>;
|
||||
export function minError(
|
||||
min: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<MinValidationError> {
|
||||
return new MinValidationError(min, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a max value error associated with the target field
|
||||
* @param max The max value constraint
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function maxError(
|
||||
max: number,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): MaxValidationError;
|
||||
/**
|
||||
* Create a max value error
|
||||
* @param max The max value constraint
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function maxError(
|
||||
max: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<MaxValidationError>;
|
||||
export function maxError(
|
||||
max: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<MaxValidationError> {
|
||||
return new MaxValidationError(max, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minLength error associated with the target field
|
||||
* @param minLength The minLength constraint
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function minLengthError(
|
||||
minLength: number,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): MinLengthValidationError;
|
||||
/**
|
||||
* Create a minLength error
|
||||
* @param minLength The minLength constraint
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function minLengthError(
|
||||
minLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<MinLengthValidationError>;
|
||||
export function minLengthError(
|
||||
minLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<MinLengthValidationError> {
|
||||
return new MinLengthValidationError(minLength, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maxLength error associated with the target field
|
||||
* @param maxLength The maxLength constraint
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function maxLengthError(
|
||||
maxLength: number,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): MaxLengthValidationError;
|
||||
/**
|
||||
* Create a maxLength error
|
||||
* @param maxLength The maxLength constraint
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function maxLengthError(
|
||||
maxLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<MaxLengthValidationError>;
|
||||
export function maxLengthError(
|
||||
maxLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<MaxLengthValidationError> {
|
||||
return new MaxLengthValidationError(maxLength, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pattern matching error associated with the target field
|
||||
* @param pattern The violated pattern
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function patternError(
|
||||
pattern: RegExp,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): PatternValidationError;
|
||||
/**
|
||||
* Create a pattern matching error
|
||||
* @param pattern The violated pattern
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function patternError(
|
||||
pattern: RegExp,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<PatternValidationError>;
|
||||
export function patternError(
|
||||
pattern: RegExp,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<PatternValidationError> {
|
||||
return new PatternValidationError(pattern, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an email format error associated with the target field
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
|
||||
/**
|
||||
* Create an email format error
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function emailError(options?: ValidationErrorOptions): WithoutField<EmailValidationError>;
|
||||
export function emailError(
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<EmailValidationError> {
|
||||
return new EmailValidationError(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standard schema issue error associated with the target field
|
||||
* @param issue The standard schema issue
|
||||
* @param options The validation error options
|
||||
*/
|
||||
export function standardSchemaError(
|
||||
issue: StandardSchemaV1.Issue,
|
||||
options: WithField<ValidationErrorOptions>,
|
||||
): StandardSchemaValidationError;
|
||||
/**
|
||||
* Create a standard schema issue error
|
||||
* @param issue The standard schema issue
|
||||
* @param options The optional validation error options
|
||||
*/
|
||||
export function standardSchemaError(
|
||||
issue: StandardSchemaV1.Issue,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithoutField<StandardSchemaValidationError>;
|
||||
export function standardSchemaError(
|
||||
issue: StandardSchemaV1.Issue,
|
||||
options?: ValidationErrorOptions,
|
||||
): WithOptionalField<StandardSchemaValidationError> {
|
||||
return new StandardSchemaValidationError(issue, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom error associated with the target field
|
||||
* @param obj The object to create an error from
|
||||
*/
|
||||
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
|
||||
obj: WithField<E>,
|
||||
): CustomValidationError;
|
||||
/**
|
||||
* Create a custom error
|
||||
* @param obj The object to create an error from
|
||||
*/
|
||||
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
|
||||
obj?: E,
|
||||
): WithoutField<CustomValidationError>;
|
||||
export function customError<E extends Omit<Partial<ValidationError>, typeof BRAND>>(
|
||||
obj?: E,
|
||||
): WithOptionalField<CustomValidationError> {
|
||||
return new CustomValidationError(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common interface for all validation errors.
|
||||
*
|
||||
* Use the creation functions to create an instance (e.g. `requiredError`, `minError`, etc.).
|
||||
*/
|
||||
export abstract class ValidationError {
|
||||
/** Brand the class to avoid Typescript structural matching */
|
||||
[BRAND] = undefined;
|
||||
|
||||
/** Identifies the kind of error. */
|
||||
readonly kind: string = '';
|
||||
|
||||
/** The field associated with this error. */
|
||||
readonly field!: Field<unknown>;
|
||||
|
||||
/** Human readable error message. */
|
||||
readonly message?: string;
|
||||
|
||||
constructor(options?: ValidationErrorOptions) {
|
||||
if (options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom error that may contain additional properties
|
||||
*/
|
||||
export class CustomValidationError extends ValidationError {
|
||||
/**
|
||||
* Allow the user to attach arbitrary other properties.
|
||||
*/
|
||||
[key: PropertyKey]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal version of `NgValidationError`, we create this separately so we can change its type on
|
||||
* the exported version to a type union of the possible sub-classes.
|
||||
*/
|
||||
abstract class _NgValidationError extends ValidationError {}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a required field is empty.
|
||||
*/
|
||||
export class RequiredValidationError extends _NgValidationError {
|
||||
override readonly kind = 'required';
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value is lower than the minimum allowed.
|
||||
*/
|
||||
export class MinValidationError extends _NgValidationError {
|
||||
override readonly kind = 'min';
|
||||
|
||||
constructor(
|
||||
readonly min: number,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value is higher than the maximum allowed.
|
||||
*/
|
||||
export class MaxValidationError extends _NgValidationError {
|
||||
override readonly kind = 'max';
|
||||
|
||||
constructor(
|
||||
readonly max: number,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value is shorter than the minimum allowed length.
|
||||
*/
|
||||
export class MinLengthValidationError extends _NgValidationError {
|
||||
override readonly kind = 'minLength';
|
||||
|
||||
constructor(
|
||||
readonly minLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value is longer than the maximum allowed length.
|
||||
*/
|
||||
export class MaxLengthValidationError extends _NgValidationError {
|
||||
override readonly kind = 'maxLength';
|
||||
|
||||
constructor(
|
||||
readonly maxLength: number,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value does not match the required pattern.
|
||||
*/
|
||||
export class PatternValidationError extends _NgValidationError {
|
||||
override readonly kind = 'pattern';
|
||||
|
||||
constructor(
|
||||
readonly pattern: RegExp,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate that a value is not a valid email.
|
||||
*/
|
||||
export class EmailValidationError extends _NgValidationError {
|
||||
override readonly kind = 'email';
|
||||
}
|
||||
|
||||
/**
|
||||
* An error used to indicate an issue validating against a standard schema.
|
||||
*/
|
||||
export class StandardSchemaValidationError extends _NgValidationError {
|
||||
override readonly kind = 'standardSchema';
|
||||
|
||||
constructor(
|
||||
readonly issue: StandardSchemaV1.Issue,
|
||||
options?: ValidationErrorOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The base class for all built-in, non-custom errors. This class can be used to check if an error
|
||||
* is one of the standard kinds, allowing you to switch on the kind to further narrow the type.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const f = form(...);
|
||||
* for (const e of form().errors()) {
|
||||
* if (e instanceof NgValidationError) {
|
||||
* switch(e.kind) {
|
||||
* case 'required':
|
||||
* console.log('This is required!');
|
||||
* break;
|
||||
* case 'min':
|
||||
* console.log(`Must be at least ${e.min}`);
|
||||
* break;
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const NgValidationError: abstract new () => NgValidationError = _NgValidationError as any;
|
||||
export type NgValidationError =
|
||||
| RequiredValidationError
|
||||
| MinValidationError
|
||||
| MaxValidationError
|
||||
| MinLengthValidationError
|
||||
| MaxLengthValidationError
|
||||
| PatternValidationError
|
||||
| EmailValidationError
|
||||
| StandardSchemaValidationError;
|
||||
72
packages/forms/signals/src/api/validators/email.ts
Normal file
72
packages/forms/signals/src/api/validators/email.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* @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 {validate} from '../logic';
|
||||
import {FieldPath, PathKind} from '../types';
|
||||
import {emailError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getOption} from './util';
|
||||
|
||||
/**
|
||||
* A regular expression that matches valid e-mail addresses.
|
||||
*
|
||||
* At a high level, this regexp matches e-mail addresses of the format `local-part@tld`, where:
|
||||
* - `local-part` consists of one or more of the allowed characters (alphanumeric and some
|
||||
* punctuation symbols).
|
||||
* - `local-part` cannot begin or end with a period (`.`).
|
||||
* - `local-part` cannot be longer than 64 characters.
|
||||
* - `tld` consists of one or more `labels` separated by periods (`.`). For example `localhost` or
|
||||
* `foo.com`.
|
||||
* - A `label` consists of one or more of the allowed characters (alphanumeric, dashes (`-`) and
|
||||
* periods (`.`)).
|
||||
* - A `label` cannot begin or end with a dash (`-`) or a period (`.`).
|
||||
* - A `label` cannot be longer than 63 characters.
|
||||
* - The whole address cannot be longer than 254 characters.
|
||||
*
|
||||
* ## Implementation background
|
||||
*
|
||||
* This regexp was ported over from AngularJS (see there for git history):
|
||||
* https://github.com/angular/angular.js/blob/c133ef836/src/ng/directive/input.js#L27
|
||||
* It is based on the
|
||||
* [WHATWG version](https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) with
|
||||
* some enhancements to incorporate more RFC rules (such as rules related to domain names and the
|
||||
* lengths of different parts of the address). The main differences from the WHATWG version are:
|
||||
* - Disallow `local-part` to begin or end with a period (`.`).
|
||||
* - Disallow `local-part` length to exceed 64 characters.
|
||||
* - Disallow total address length to exceed 254 characters.
|
||||
*
|
||||
* See [this commit](https://github.com/angular/angular.js/commit/f3f5cf72e) for more details.
|
||||
*/
|
||||
const EMAIL_REGEXP =
|
||||
/^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the value to match the standard email format.
|
||||
* This function can only be called on string paths.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.email()`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function email<TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<string, TPathKind>,
|
||||
config?: BaseValidatorConfig<string, TPathKind>,
|
||||
) {
|
||||
validate(path, (ctx) => {
|
||||
if (!EMAIL_REGEXP.test(ctx.value())) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return emailError({message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
15
packages/forms/signals/src/api/validators/index.ts
Normal file
15
packages/forms/signals/src/api/validators/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export {email} from './email';
|
||||
export {max} from './max';
|
||||
export {maxLength} from './max_length';
|
||||
export {min} from './min';
|
||||
export {minLength} from './min_length';
|
||||
export {pattern} from './pattern';
|
||||
export {required} from './required';
|
||||
52
packages/forms/signals/src/api/validators/max.ts
Normal file
52
packages/forms/signals/src/api/validators/max.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {MAX} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {maxError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getOption} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the value to be less than or equal to the
|
||||
* given `maxValue`.
|
||||
* This function can only be called on number paths.
|
||||
* In addition to binding a validator, this function adds `MAX` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param maxValue The maximum value, or a LogicFn that returns the maximum value.
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.max(maxValue)`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function max<TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<number, TPathKind>,
|
||||
maxValue: number | LogicFn<number, number | undefined, TPathKind>,
|
||||
config?: BaseValidatorConfig<number, TPathKind>,
|
||||
) {
|
||||
const MAX_MEMO = property(path, (ctx) =>
|
||||
computed(() => (typeof maxValue === 'number' ? maxValue : maxValue(ctx))),
|
||||
);
|
||||
aggregateProperty(path, MAX, ({state}) => state.property(MAX_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
const max = ctx.state.property(MAX_MEMO)!();
|
||||
if (max === undefined || Number.isNaN(max)) {
|
||||
return undefined;
|
||||
}
|
||||
if (ctx.value() > max) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return maxError(max, {message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
56
packages/forms/signals/src/api/validators/max_length.ts
Normal file
56
packages/forms/signals/src/api/validators/max_length.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {MAX_LENGTH} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {maxLengthError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the length of the value to be less than or
|
||||
* equal to the given `maxLength`.
|
||||
* This function can only be called on string or array paths.
|
||||
* In addition to binding a validator, this function adds `MAX_LENGTH` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param maxLength The maximum length, or a LogicFn that returns the maximum length.
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.maxLength(maxLength)`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function maxLength<
|
||||
TValue extends ValueWithLengthOrSize,
|
||||
TPathKind extends PathKind = PathKind.Root,
|
||||
>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
maxLength: number | LogicFn<TValue, number | undefined, TPathKind>,
|
||||
config?: BaseValidatorConfig<TValue, TPathKind>,
|
||||
) {
|
||||
const MAX_LENGTH_MEMO = property(path, (ctx) =>
|
||||
computed(() => (typeof maxLength === 'number' ? maxLength : maxLength(ctx))),
|
||||
);
|
||||
aggregateProperty(path, MAX_LENGTH, ({state}) => state.property(MAX_LENGTH_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
const maxLength = ctx.state.property(MAX_LENGTH_MEMO)!();
|
||||
if (maxLength === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (getLengthOrSize(ctx.value()) > maxLength) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return maxLengthError(maxLength, {message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
52
packages/forms/signals/src/api/validators/min.ts
Normal file
52
packages/forms/signals/src/api/validators/min.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {MIN} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {minError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getOption} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the value to be greater than or equal to
|
||||
* the given `minValue`.
|
||||
* This function can only be called on number paths.
|
||||
* In addition to binding a validator, this function adds `MIN` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param minValue The minimum value, or a LogicFn that returns the minimum value.
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.min(minValue)`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function min<TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<number, TPathKind>,
|
||||
minValue: number | LogicFn<number, number | undefined, TPathKind>,
|
||||
config?: BaseValidatorConfig<number, TPathKind>,
|
||||
) {
|
||||
const MIN_MEMO = property(path, (ctx) =>
|
||||
computed(() => (typeof minValue === 'number' ? minValue : minValue(ctx))),
|
||||
);
|
||||
aggregateProperty(path, MIN, ({state}) => state.property(MIN_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
const min = ctx.state.property(MIN_MEMO)!();
|
||||
if (min === undefined || Number.isNaN(min)) {
|
||||
return undefined;
|
||||
}
|
||||
if (ctx.value() < min) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return minError(min, {message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
56
packages/forms/signals/src/api/validators/min_length.ts
Normal file
56
packages/forms/signals/src/api/validators/min_length.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {MIN_LENGTH} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {minLengthError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getLengthOrSize, getOption, ValueWithLengthOrSize} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the length of the value to be greater than or
|
||||
* equal to the given `minLength`.
|
||||
* This function can only be called on string or array paths.
|
||||
* In addition to binding a validator, this function adds `MIN_LENGTH` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param minLength The minimum length, or a LogicFn that returns the minimum length.
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.minLength(minLength)`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function minLength<
|
||||
TValue extends ValueWithLengthOrSize,
|
||||
TPathKind extends PathKind = PathKind.Root,
|
||||
>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
minLength: number | LogicFn<TValue, number | undefined, TPathKind>,
|
||||
config?: BaseValidatorConfig<TValue, TPathKind>,
|
||||
) {
|
||||
const MIN_LENGTH_MEMO = property(path, (ctx) =>
|
||||
computed(() => (typeof minLength === 'number' ? minLength : minLength(ctx))),
|
||||
);
|
||||
aggregateProperty(path, MIN_LENGTH, ({state}) => state.property(MIN_LENGTH_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
const minLength = ctx.state.property(MIN_LENGTH_MEMO)!();
|
||||
if (minLength === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (getLengthOrSize(ctx.value()) < minLength) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return minLengthError(minLength, {message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
55
packages/forms/signals/src/api/validators/pattern.ts
Normal file
55
packages/forms/signals/src/api/validators/pattern.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {PATTERN} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {patternError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getOption} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the value to match a specific regex pattern.
|
||||
* This function can only be called on string paths.
|
||||
* In addition to binding a validator, this function adds `PATTERN` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param pattern The RegExp pattern to match, or a LogicFn that returns the RegExp pattern.
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.pattern(pattern)`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function pattern<TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<string, TPathKind>,
|
||||
pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>,
|
||||
config?: BaseValidatorConfig<string, TPathKind>,
|
||||
) {
|
||||
const PATTERN_MEMO = property(path, (ctx) =>
|
||||
computed(() => (pattern instanceof RegExp ? pattern : pattern(ctx))),
|
||||
);
|
||||
aggregateProperty(path, PATTERN, ({state}) => state.property(PATTERN_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
const pattern = ctx.state.property(PATTERN_MEMO)!();
|
||||
|
||||
// A pattern validator should not fail on an empty value. This matches the behavior of HTML's
|
||||
// built in `pattern` attribute.
|
||||
if (pattern === undefined || ctx.value() == null || ctx.value() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!pattern.test(ctx.value())) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return patternError(pattern, {message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
56
packages/forms/signals/src/api/validators/required.ts
Normal file
56
packages/forms/signals/src/api/validators/required.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @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 {computed} from '@angular/core';
|
||||
import {aggregateProperty, property, validate} from '../logic';
|
||||
import {REQUIRED} from '../property';
|
||||
import {FieldPath, LogicFn, PathKind} from '../types';
|
||||
import {requiredError} from '../validation_errors';
|
||||
import {BaseValidatorConfig, getOption} from './util';
|
||||
|
||||
/**
|
||||
* Binds a validator to the given path that requires the value to be non-empty.
|
||||
* This function can only be called on any type of path.
|
||||
* In addition to binding a validator, this function adds `REQUIRED` property to the field.
|
||||
*
|
||||
* @param path Path of the field to validate
|
||||
* @param config Optional, allows providing any of the following options:
|
||||
* - `message`: A user-facing message for the error.
|
||||
* - `error`: Custom validation error(s) to be used instead of the default `ValidationError.required()`
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
* - `emptyPredicate`: A function that receives the value, and returns `true` if it is considered empty.
|
||||
* By default `false`, `''`, `null`, and `undefined` are considered empty
|
||||
* - `when`: A function that receives the `FieldContext` and returns true if the field is required
|
||||
* @template TValue The type of value stored in the field the logic is bound to.
|
||||
* @template TPathKind The kind of path the logic is bound to (a root path, child path, or item of an array)
|
||||
*/
|
||||
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: FieldPath<TValue, TPathKind>,
|
||||
config?: BaseValidatorConfig<TValue, TPathKind> & {
|
||||
emptyPredicate?: (value: TValue) => boolean;
|
||||
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
|
||||
},
|
||||
): void {
|
||||
const emptyPredicate =
|
||||
config?.emptyPredicate ?? ((value) => value === false || value == null || value === '');
|
||||
|
||||
const REQUIRED_MEMO = property(path, (ctx) =>
|
||||
computed(() => (config?.when ? config.when(ctx) : true)),
|
||||
);
|
||||
aggregateProperty(path, REQUIRED, ({state}) => state.property(REQUIRED_MEMO)!());
|
||||
validate(path, (ctx) => {
|
||||
if (ctx.state.property(REQUIRED_MEMO)!() && emptyPredicate(ctx.value())) {
|
||||
if (config?.error) {
|
||||
return getOption(config.error, ctx);
|
||||
} else {
|
||||
return requiredError({message: getOption(config?.message, ctx)});
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
106
packages/forms/signals/src/api/validators/standard_schema.ts
Normal file
106
packages/forms/signals/src/api/validators/standard_schema.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* @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 {computed, resource, ɵisPromise} from '@angular/core';
|
||||
import type {StandardSchemaV1} from '@standard-schema/spec';
|
||||
import {addDefaultField} from '../../field/validation';
|
||||
import {validateAsync} from '../async';
|
||||
import {property, validateTree} from '../logic';
|
||||
import {Field, FieldPath} from '../types';
|
||||
import {standardSchemaError, StandardSchemaValidationError} from '../validation_errors';
|
||||
|
||||
/**
|
||||
* Utility type that removes a string index key when its value is `unknown`,
|
||||
* i.e. `{[key: string]: unknown}`. It allows specific string keys to pass through, even if their
|
||||
* value is `unknown`, e.g. `{key: unknown}`.
|
||||
*/
|
||||
export type RemoveStringIndexUnknownKey<K, V> = string extends K
|
||||
? unknown extends V
|
||||
? never
|
||||
: K
|
||||
: K;
|
||||
|
||||
/**
|
||||
* Utility type that recursively ignores unknown string index properties on the given object.
|
||||
* We use this on the `TSchema` type in `validateStandardSchema` in order to accommodate Zod's
|
||||
* `looseObject` which includes `{[key: string]: unknown}` as part of the type.
|
||||
*/
|
||||
export type IgnoreUnknownProperties<T> =
|
||||
T extends Record<PropertyKey, unknown>
|
||||
? {
|
||||
[K in keyof T as RemoveStringIndexUnknownKey<K, T[K]>]: IgnoreUnknownProperties<T[K]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Validates a field using a `StandardSchemaV1` compatible validator (e.g. a Zod validator).
|
||||
*
|
||||
* See https://github.com/standard-schema/standard-schema for more about standard schema.
|
||||
*
|
||||
* @param path The `FieldPath` to the field to validate.
|
||||
* @param schema The standard schema compatible validator to use for validation.
|
||||
* @template TSchema The type validated by the schema. This may be either the full `TValue` type,
|
||||
* or a partial of it.
|
||||
* @template TValue The type of value stored in the field being validated.
|
||||
*/
|
||||
export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProperties<TSchema>>(
|
||||
path: FieldPath<TValue>,
|
||||
schema: StandardSchemaV1<TSchema>,
|
||||
) {
|
||||
// We create both a sync and async validator because the standard schema validator can return
|
||||
// either a sync result or a Promise, and we need to handle both cases. The sync validator
|
||||
// handles the sync result, and the async validator handles the Promise.
|
||||
// We memoize the result of the validation function here, so that it is only run once for both
|
||||
// validators, it can then be passed through both sync & async validation.
|
||||
const VALIDATOR_MEMO = property(path, ({value}) => {
|
||||
return computed(() => schema['~standard'].validate(value()));
|
||||
});
|
||||
validateTree(path, ({state, fieldOf}) => {
|
||||
// Skip sync validation if the result is a Promise.
|
||||
const result = state.property(VALIDATOR_MEMO)!();
|
||||
if (ɵisPromise(result)) {
|
||||
return [];
|
||||
}
|
||||
return result.issues?.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue)) ?? [];
|
||||
});
|
||||
validateAsync(path, {
|
||||
params: ({state}) => {
|
||||
// Skip async validation if the result is *not* a Promise.
|
||||
const result = state.property(VALIDATOR_MEMO)!();
|
||||
return ɵisPromise(result) ? result : undefined;
|
||||
},
|
||||
factory: (params) => {
|
||||
return resource({
|
||||
params,
|
||||
loader: async ({params}) => (await params)?.issues ?? [],
|
||||
});
|
||||
},
|
||||
errors: (issues, {fieldOf}) => {
|
||||
return issues.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a `StandardSchemaV1.Issue` to a `FormTreeError`.
|
||||
*
|
||||
* @param field The root field to which the issue's path is relative.
|
||||
* @param issue The `StandardSchemaV1.Issue` to convert.
|
||||
* @returns A `ValidationError` representing the issue.
|
||||
*/
|
||||
function standardIssueToFormTreeError(
|
||||
field: Field<unknown>,
|
||||
issue: StandardSchemaV1.Issue,
|
||||
): StandardSchemaValidationError {
|
||||
let target = field as Field<Record<PropertyKey, unknown>>;
|
||||
for (const pathPart of issue.path ?? []) {
|
||||
const pathKey = typeof pathPart === 'object' ? pathPart.key : pathPart;
|
||||
target = target[pathKey] as Field<Record<PropertyKey, unknown>>;
|
||||
}
|
||||
return addDefaultField(standardSchemaError(issue), target);
|
||||
}
|
||||
52
packages/forms/signals/src/api/validators/util.ts
Normal file
52
packages/forms/signals/src/api/validators/util.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @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 {LogicFn, OneOrMany, PathKind, type FieldContext} from '../types';
|
||||
import {ValidationError, WithoutField} from '../validation_errors';
|
||||
|
||||
/** Represents a value that has a length or size, such as an array or string, or set. */
|
||||
export type ValueWithLengthOrSize = {length: number} | {size: number};
|
||||
|
||||
/** Common options available on the standard validators. */
|
||||
export type BaseValidatorConfig<TValue, TPathKind extends PathKind = PathKind.Root> =
|
||||
| {
|
||||
/** A user-facing error message to include with the error. */
|
||||
message?: string | LogicFn<TValue, string, TPathKind>;
|
||||
error?: never;
|
||||
}
|
||||
| {
|
||||
/**
|
||||
* Custom validation error(s) to report instead of the default,
|
||||
* or a function that receives the `FieldContext` and returns custom validation error(s).
|
||||
*/
|
||||
error?:
|
||||
| OneOrMany<WithoutField<ValidationError>>
|
||||
| LogicFn<TValue, OneOrMany<WithoutField<ValidationError>>, TPathKind>;
|
||||
message?: never;
|
||||
};
|
||||
|
||||
/** Gets the length or size of the given value. */
|
||||
export function getLengthOrSize(value: ValueWithLengthOrSize) {
|
||||
const v = value as {length: number; size: number};
|
||||
return typeof v.length === 'number' ? v.length : v.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value for an option that may be either a static value or a logic function that produces
|
||||
* the option value.
|
||||
*
|
||||
* @param opt The option from BaseValidatorConfig.
|
||||
* @param ctx The current FieldContext.
|
||||
* @returns The value for the option.
|
||||
*/
|
||||
export function getOption<TOption, TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
opt: Exclude<TOption, Function> | LogicFn<TValue, TOption, TPathKind> | undefined,
|
||||
ctx: FieldContext<TValue, TPathKind>,
|
||||
): TOption | undefined {
|
||||
return opt instanceof Function ? opt(ctx) : opt;
|
||||
}
|
||||
454
packages/forms/signals/src/controls/control.ts
Normal file
454
packages/forms/signals/src/controls/control.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
/**
|
||||
* @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 {
|
||||
afterNextRender,
|
||||
computed,
|
||||
DestroyRef,
|
||||
Directive,
|
||||
effect,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Injector,
|
||||
Input,
|
||||
InputSignal,
|
||||
OutputEmitterRef,
|
||||
OutputRef,
|
||||
OutputRefSubscription,
|
||||
reflectComponentType,
|
||||
Renderer2,
|
||||
signal,
|
||||
Type,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
|
||||
import {FormCheckboxControl, FormUiControl, FormValueControl} from '../api/control';
|
||||
import {
|
||||
AggregateProperty,
|
||||
MAX,
|
||||
MAX_LENGTH,
|
||||
MIN,
|
||||
MIN_LENGTH,
|
||||
PATTERN,
|
||||
REQUIRED,
|
||||
} from '../api/property';
|
||||
import type {Field} from '../api/types';
|
||||
import type {FieldNode} from '../field/node';
|
||||
import {
|
||||
privateGetComponentInstance,
|
||||
privateIsModelInput,
|
||||
privateIsSignalInput,
|
||||
privateRunEffect,
|
||||
privateSetComponentInput as privateSetInputSignal,
|
||||
} from '../util/private';
|
||||
import {InteropNgControl} from './interop_ng_control';
|
||||
|
||||
/**
|
||||
* Binds a form `Field` to a UI control that edits it. A UI control can be one of several things:
|
||||
* 1. A native HTML input or textarea
|
||||
* 2. A signal forms custom control that implements `FormValueControl` or `FormCheckboxControl`
|
||||
* 3. A component that provides a ControlValueAccessor. This should only be used to backwards
|
||||
* compatibility with reactive forms. Prefer options (1) and (2).
|
||||
*
|
||||
* This directive has several responsibilities:
|
||||
* 1. Two-way binds the field's value with the UI control's value
|
||||
* 2. Binds additional forms related state on the field to the UI control (disabled, required, etc.)
|
||||
* 3. Relays relevant events on the control to the field (e.g. marks field touched on blur)
|
||||
* 4. Provides a fake `NgControl` that implements a subset of the features available on the reactive
|
||||
* forms `NgControl`. This is provided to improve interoperability with controls designed to work
|
||||
* with reactive forms. It should not be used by controls written for signal forms.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[control]',
|
||||
providers: [
|
||||
{
|
||||
provide: NgControl,
|
||||
useFactory: () => inject(Control).ngControl,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class Control<T> {
|
||||
/** The injector for this component. */
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
|
||||
/** Whether state synchronization with the field has been setup yet. */
|
||||
private initialized = false;
|
||||
|
||||
/** The field that is bound to this control. */
|
||||
readonly field = signal<Field<T>>(undefined as any);
|
||||
|
||||
// If `[control]` is applied to a custom UI control, it wants to synchronize state in the field w/
|
||||
// the inputs of that custom control. This is difficult to do in user-land. We use `effect`, but
|
||||
// effects don't run before the lifecycle hooks of the component. This is usually okay, but has
|
||||
// one significant issue: the UI control's required inputs won't be set in time for those
|
||||
// lifecycle hooks to run.
|
||||
//
|
||||
// Eventually we can build custom functionality for the `Control` directive into the framework,
|
||||
// but for now we work around this limitation with a hack. We use an `@Input` instead of a
|
||||
// signal-based `input()` for the `[control]` to hook the exact moment inputs are being set,
|
||||
// before the important lifecycle hooks of the UI control. We can then initialize all our effects
|
||||
// and force them to run immediately, ensuring all required inputs have values.
|
||||
@Input({required: true, alias: 'control'})
|
||||
set _field(value: Field<T>) {
|
||||
this.field.set(value);
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/** The field state of the bound field. */
|
||||
readonly state = computed(() => this.field()());
|
||||
|
||||
/** The HTMLElement this directive is attached to. */
|
||||
readonly el: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
|
||||
/** The NG_VALUE_ACCESSOR array for the host component. */
|
||||
readonly cvaArray = inject<ControlValueAccessor[]>(NG_VALUE_ACCESSOR, {optional: true});
|
||||
|
||||
/** The Cached value for the lazily created interop NgControl. */
|
||||
private _ngControl: InteropNgControl | undefined;
|
||||
|
||||
/** A fake NgControl provided for better interop with reactive forms. */
|
||||
get ngControl(): NgControl {
|
||||
return (this._ngControl ??= new InteropNgControl(() => this.state())) as unknown as NgControl;
|
||||
}
|
||||
|
||||
/** The ControlValueAccessor for the host component. */
|
||||
get cva(): ControlValueAccessor | undefined {
|
||||
return this.cvaArray?.[0] ?? this._ngControl?.valueAccessor ?? undefined;
|
||||
}
|
||||
|
||||
/** Initializes state synchronization between the field and the host UI control. */
|
||||
private initialize() {
|
||||
this.initialized = true;
|
||||
const injector = this.injector;
|
||||
const cmp = privateGetComponentInstance(injector);
|
||||
|
||||
// If component has a `control` input, we assume that it will handle binding the field to the
|
||||
// appropriate native/custom control in its template, so we do not attempt to bind any inputs on
|
||||
// this component.
|
||||
if (cmp && isShadowedControlComponent(cmp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmp && isFormUiControl(cmp)) {
|
||||
// If we're binding to a component that follows the standard form ui control contract,
|
||||
// set up state synchronization based on the contract.
|
||||
this.setupCustomUiControl(cmp);
|
||||
} else if (this.cva !== undefined) {
|
||||
// If we're binding to a component that doesn't follow the standard contract, but provides a
|
||||
// control value accessor, set up state synchronization based on th CVA.
|
||||
this.setupControlValueAccessor(this.cva);
|
||||
} else if (
|
||||
this.el.nativeElement instanceof HTMLInputElement ||
|
||||
this.el.nativeElement instanceof HTMLTextAreaElement ||
|
||||
this.el.nativeElement instanceof HTMLSelectElement
|
||||
) {
|
||||
// If we're binding to a native html input, set up state synchronization with its native
|
||||
// properties / attributes.
|
||||
this.setupNativeInput(this.el.nativeElement);
|
||||
} else {
|
||||
throw new Error(`Unhandled control?`);
|
||||
}
|
||||
|
||||
// Register this control on the field it is currently bound to. We do this at the end of
|
||||
// initialization so that it only runs if we are actually syncing with this control
|
||||
// (as opposed to just passing the field through to its `control` input).
|
||||
effect(
|
||||
(onCleanup) => {
|
||||
const fieldNode = this.state() as unknown as FieldNode;
|
||||
fieldNode.nodeState.controls.update((controls) => [...controls, this as Control<unknown>]);
|
||||
onCleanup(() => {
|
||||
fieldNode.nodeState.controls.update((controls) => controls.filter((c) => c !== this));
|
||||
});
|
||||
},
|
||||
{injector: this.injector},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up state synchronization between the field and a native <input>, <textarea>, or <select>.
|
||||
*/
|
||||
private setupNativeInput(
|
||||
input: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
|
||||
): void {
|
||||
const inputType =
|
||||
input instanceof HTMLTextAreaElement
|
||||
? 'text'
|
||||
: input instanceof HTMLSelectElement
|
||||
? 'select'
|
||||
: input.type;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
switch (inputType) {
|
||||
case 'checkbox':
|
||||
this.state().value.set((input as HTMLInputElement).checked as T);
|
||||
break;
|
||||
case 'radio':
|
||||
// The `input` event only fires when a radio button becomes selected, so write its `value`
|
||||
// into the state.
|
||||
this.state().value.set((input as HTMLInputElement).value as T);
|
||||
break;
|
||||
default:
|
||||
this.state().value.set(input.value as T);
|
||||
break;
|
||||
}
|
||||
this.state().markAsDirty();
|
||||
});
|
||||
input.addEventListener('blur', () => this.state().markAsTouched());
|
||||
|
||||
this.maybeSynchronize(
|
||||
() => this.state().readonly(),
|
||||
this.withBooleanAttribute(input, 'readonly'),
|
||||
);
|
||||
// TODO: consider making a global configuration option for using aria-disabled instead.
|
||||
this.maybeSynchronize(
|
||||
() => this.state().disabled(),
|
||||
this.withBooleanAttribute(input, 'disabled'),
|
||||
);
|
||||
this.maybeSynchronize(() => this.state().name(), this.withAttribute(input, 'name'));
|
||||
|
||||
this.maybeSynchronize(
|
||||
this.propertySource(REQUIRED),
|
||||
this.withBooleanAttribute(input, 'required'),
|
||||
);
|
||||
this.maybeSynchronize(this.propertySource(MIN), this.withAttribute(input, 'min'));
|
||||
this.maybeSynchronize(this.propertySource(MIN_LENGTH), this.withAttribute(input, 'minLength'));
|
||||
this.maybeSynchronize(this.propertySource(MAX), this.withAttribute(input, 'max'));
|
||||
this.maybeSynchronize(this.propertySource(MAX_LENGTH), this.withAttribute(input, 'maxLength'));
|
||||
|
||||
switch (inputType) {
|
||||
case 'checkbox':
|
||||
this.maybeSynchronize(
|
||||
() => this.state().value(),
|
||||
(value) => ((input as HTMLInputElement).checked = value as boolean),
|
||||
);
|
||||
break;
|
||||
case 'radio':
|
||||
this.maybeSynchronize(
|
||||
() => this.state().value(),
|
||||
(value) => {
|
||||
// Although HTML behavior is to clear the input already, we do this just in case.
|
||||
// It seems like it might be necessary in certain environments (e.g. Domino).
|
||||
(input as HTMLInputElement).checked = input.value === value;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case 'select':
|
||||
this.maybeSynchronize(
|
||||
() => this.state().value(),
|
||||
(value) => {
|
||||
// A select will not take a value unil the value's option has rendered.
|
||||
afterNextRender(() => (input.value = value as string), {injector: this.injector});
|
||||
},
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.maybeSynchronize(
|
||||
() => this.state().value(),
|
||||
(value) => {
|
||||
input.value = value as string;
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Set up state synchronization between the field and a ControlValueAccessor. */
|
||||
private setupControlValueAccessor(cva: ControlValueAccessor): void {
|
||||
cva.registerOnChange((value: T) => this.state().value.set(value));
|
||||
cva.registerOnTouched(() => this.state().markAsTouched());
|
||||
|
||||
this.maybeSynchronize(
|
||||
() => this.state().value(),
|
||||
(value) => cva.writeValue(value),
|
||||
);
|
||||
|
||||
if (cva.setDisabledState) {
|
||||
this.maybeSynchronize(
|
||||
() => this.state().disabled(),
|
||||
(value) => cva.setDisabledState!(value),
|
||||
);
|
||||
}
|
||||
|
||||
cva.writeValue(this.state().value());
|
||||
cva.setDisabledState?.(this.state().disabled());
|
||||
}
|
||||
|
||||
/** Set up state synchronization between the field and a FormUiControl. */
|
||||
private setupCustomUiControl(cmp: FormUiControl) {
|
||||
// Handle the property side of the model binding. How we do this depends on the shape of the
|
||||
// component. There are 2 options:
|
||||
// * it provides a `value` model (most controls that edit a single value)
|
||||
// * it provides a `checked` model with no `value` signal (custom checkbox)
|
||||
|
||||
let cleanupValue: OutputRefSubscription | undefined;
|
||||
if (isFormValueControl(cmp)) {
|
||||
// <custom-input [(value)]="state().value">
|
||||
this.maybeSynchronize(() => this.state().value(), withInput(cmp.value));
|
||||
cleanupValue = cmp.value.subscribe((newValue) => this.state().value.set(newValue as T));
|
||||
} else if (isFormCheckboxControl(cmp)) {
|
||||
// <custom-checkbox [(checked)]="state().value" />
|
||||
this.maybeSynchronize(() => this.state().value() as boolean, withInput(cmp.checked));
|
||||
cleanupValue = cmp.checked.subscribe((newValue) => this.state().value.set(newValue as T));
|
||||
} else {
|
||||
throw new Error(`Unknown custom control subtype`);
|
||||
}
|
||||
|
||||
this.maybeSynchronize(() => this.state().name(), withInput(cmp.name));
|
||||
this.maybeSynchronize(() => this.state().disabled(), withInput(cmp.disabled));
|
||||
this.maybeSynchronize(() => this.state().disabledReasons(), withInput(cmp.disabledReasons));
|
||||
this.maybeSynchronize(() => this.state().readonly(), withInput(cmp.readonly));
|
||||
this.maybeSynchronize(() => this.state().hidden(), withInput(cmp.hidden));
|
||||
this.maybeSynchronize(() => this.state().errors(), withInput(cmp.errors));
|
||||
if (privateIsModelInput(cmp.touched) || privateIsSignalInput(cmp.touched)) {
|
||||
this.maybeSynchronize(() => this.state().touched(), withInput(cmp.touched));
|
||||
}
|
||||
this.maybeSynchronize(() => this.state().dirty(), withInput(cmp.dirty));
|
||||
this.maybeSynchronize(() => this.state().invalid(), withInput(cmp.invalid));
|
||||
this.maybeSynchronize(() => this.state().pending(), withInput(cmp.pending));
|
||||
|
||||
this.maybeSynchronize(this.propertySource(REQUIRED), withInput(cmp.required));
|
||||
this.maybeSynchronize(this.propertySource(MIN), withInput(cmp.min));
|
||||
this.maybeSynchronize(this.propertySource(MIN_LENGTH), withInput(cmp.minLength));
|
||||
this.maybeSynchronize(this.propertySource(MAX), withInput(cmp.max));
|
||||
this.maybeSynchronize(this.propertySource(MAX_LENGTH), withInput(cmp.maxLength));
|
||||
this.maybeSynchronize(this.propertySource(PATTERN), withInput(cmp.pattern));
|
||||
|
||||
let cleanupTouch: OutputRefSubscription | undefined;
|
||||
let cleanupDefaultTouch: (() => void) | undefined;
|
||||
if (privateIsModelInput(cmp.touched) || isOutputRef(cmp.touched)) {
|
||||
cleanupTouch = cmp.touched.subscribe(() => this.state().markAsTouched());
|
||||
} else {
|
||||
// If the component did not give us a touch event stream, use the standard touch logic,
|
||||
// marking it touched when the focus moves from inside the host element to outside.
|
||||
const listener = (event: FocusEvent) => {
|
||||
const newActiveEl = event.relatedTarget;
|
||||
if (!this.el.nativeElement.contains(newActiveEl as Element | null)) {
|
||||
this.state().markAsTouched();
|
||||
}
|
||||
};
|
||||
this.el.nativeElement.addEventListener('focusout', listener);
|
||||
cleanupDefaultTouch = () => this.el.nativeElement.removeEventListener('focusout', listener);
|
||||
}
|
||||
|
||||
// Cleanup for output binding subscriptions:
|
||||
this.injector.get(DestroyRef).onDestroy(() => {
|
||||
cleanupValue?.unsubscribe();
|
||||
cleanupTouch?.unsubscribe();
|
||||
cleanupDefaultTouch?.();
|
||||
});
|
||||
}
|
||||
|
||||
/** Synchronize a value from a reactive source to a given sink. */
|
||||
private maybeSynchronize<T>(source: () => T, sink: ((value: T) => void) | undefined): void {
|
||||
if (!sink) {
|
||||
return undefined;
|
||||
}
|
||||
const ref = effect(
|
||||
() => {
|
||||
const value = source();
|
||||
untracked(() => sink(value));
|
||||
},
|
||||
{injector: this.injector},
|
||||
);
|
||||
// Run the effect immediately to ensure sinks which are required inputs are set before they can
|
||||
// be observed. See the note on `_field` for more details.
|
||||
privateRunEffect(ref);
|
||||
}
|
||||
|
||||
/** Creates a reactive value source by reading the given AggregateProperty from the field. */
|
||||
private propertySource<T>(key: AggregateProperty<T, any>): () => T {
|
||||
const metaSource = computed(() =>
|
||||
this.state().hasProperty(key) ? this.state().property(key) : key.getInitial,
|
||||
);
|
||||
return () => metaSource()?.();
|
||||
}
|
||||
|
||||
/** Creates a (non-boolean) value sync that writes the given attribute of the given element. */
|
||||
private withAttribute(
|
||||
element: HTMLElement,
|
||||
attribute: string,
|
||||
): (value: {toString(): string} | undefined) => void {
|
||||
return (value) => {
|
||||
if (value !== undefined) {
|
||||
this.renderer.setAttribute(element, attribute, value.toString());
|
||||
} else {
|
||||
this.renderer.removeAttribute(element, attribute);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates a boolean value sync that writes the given attribute of the given element. */
|
||||
private withBooleanAttribute(element: HTMLElement, attribute: string): (value: boolean) => void {
|
||||
return (value) => {
|
||||
if (value) {
|
||||
this.renderer.setAttribute(element, attribute, '');
|
||||
} else {
|
||||
this.renderer.removeAttribute(element, attribute);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates a value sync from an input signal. */
|
||||
function withInput<T>(input: InputSignal<T> | undefined): ((value: T) => void) | undefined {
|
||||
return input ? (value: T) => privateSetInputSignal(input, value) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given component matches the contract for either FormValueControl or
|
||||
* FormCheckboxControl.
|
||||
*/
|
||||
function isFormUiControl(cmp: unknown): cmp is FormUiControl {
|
||||
const castCmp = cmp as FormUiControl;
|
||||
return (
|
||||
(isFormValueControl(castCmp) || isFormCheckboxControl(castCmp)) &&
|
||||
(castCmp.readonly === undefined || privateIsSignalInput(castCmp.readonly)) &&
|
||||
(castCmp.disabled === undefined || privateIsSignalInput(castCmp.disabled)) &&
|
||||
(castCmp.disabledReasons === undefined || privateIsSignalInput(castCmp.disabledReasons)) &&
|
||||
(castCmp.errors === undefined || privateIsSignalInput(castCmp.errors)) &&
|
||||
(castCmp.invalid === undefined || privateIsSignalInput(castCmp.invalid)) &&
|
||||
(castCmp.pending === undefined || privateIsSignalInput(castCmp.pending)) &&
|
||||
(castCmp.touched === undefined ||
|
||||
privateIsModelInput(castCmp.touched) ||
|
||||
privateIsSignalInput(castCmp.touched) ||
|
||||
isOutputRef(castCmp.touched)) &&
|
||||
(castCmp.dirty === undefined || privateIsSignalInput(castCmp.dirty)) &&
|
||||
(castCmp.min === undefined || privateIsSignalInput(castCmp.min)) &&
|
||||
(castCmp.minLength === undefined || privateIsSignalInput(castCmp.minLength)) &&
|
||||
(castCmp.max === undefined || privateIsSignalInput(castCmp.max)) &&
|
||||
(castCmp.maxLength === undefined || privateIsSignalInput(castCmp.maxLength))
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks whether the given FormUiControl is a FormValueControl. */
|
||||
function isFormValueControl(cmp: FormUiControl): cmp is FormValueControl<unknown> {
|
||||
return privateIsModelInput((cmp as FormValueControl<unknown>).value);
|
||||
}
|
||||
|
||||
/** Checks whether the given FormUiControl is a FormCheckboxControl. */
|
||||
function isFormCheckboxControl(cmp: FormUiControl): cmp is FormCheckboxControl {
|
||||
return (
|
||||
privateIsModelInput((cmp as FormCheckboxControl).checked) &&
|
||||
(cmp as FormCheckboxControl).value === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/** Checks whether the given component has an input called `control`. */
|
||||
function isShadowedControlComponent(cmp: unknown): boolean {
|
||||
const mirror = reflectComponentType((cmp as {}).constructor as Type<unknown>);
|
||||
return mirror?.inputs.some((input) => input.templateName === 'control') ?? false;
|
||||
}
|
||||
|
||||
/** Checks whether the given object is an output ref. */
|
||||
function isOutputRef(value: unknown): value is OutputRef<unknown> {
|
||||
return value instanceof OutputEmitterRef || value instanceof EventEmitter;
|
||||
}
|
||||
144
packages/forms/signals/src/controls/interop_ng_control.ts
Normal file
144
packages/forms/signals/src/controls/interop_ng_control.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* @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 {
|
||||
ControlValueAccessor,
|
||||
Validators,
|
||||
type AbstractControl,
|
||||
type FormControlStatus,
|
||||
type NgControl,
|
||||
type ValidationErrors,
|
||||
type ValidatorFn,
|
||||
} from '@angular/forms';
|
||||
import {REQUIRED} from '../api/property';
|
||||
import type {FieldState} from '../api/types';
|
||||
|
||||
// TODO: Also consider supporting (if possible):
|
||||
// - hasError
|
||||
// - getError
|
||||
// - reset
|
||||
// - name
|
||||
// - path
|
||||
// - markAs[Touched,Dirty,etc.]
|
||||
|
||||
/**
|
||||
* Properties of both NgControl & AbstractControl that are supported by the InteropNgControl.
|
||||
*/
|
||||
export type InteropSharedKeys =
|
||||
| 'value'
|
||||
| 'valid'
|
||||
| 'invalid'
|
||||
| 'touched'
|
||||
| 'untouched'
|
||||
| 'disabled'
|
||||
| 'enabled'
|
||||
| 'errors'
|
||||
| 'pristine'
|
||||
| 'dirty'
|
||||
| 'status';
|
||||
|
||||
/**
|
||||
* A fake version of `NgControl` provided by the `Control` directive. This allows interoperability
|
||||
* with a wider range of components designed to work with reactive forms, in particular ones that
|
||||
* inject the `NgControl`. The interop control does not implement *all* properties and methods of
|
||||
* the real `NgControl`, but does implement some of the most commonly used ones that have a clear
|
||||
* equivalent in signal forms.
|
||||
*/
|
||||
export class InteropNgControl
|
||||
implements
|
||||
Pick<NgControl, InteropSharedKeys | 'control' | 'valueAccessor'>,
|
||||
Pick<AbstractControl<unknown>, InteropSharedKeys | 'hasValidator'>
|
||||
{
|
||||
constructor(protected field: () => FieldState<unknown>) {}
|
||||
|
||||
readonly control: AbstractControl<any, any> = this as unknown as AbstractControl<any, any>;
|
||||
|
||||
get value(): any {
|
||||
return this.field().value();
|
||||
}
|
||||
|
||||
get valid(): boolean {
|
||||
return this.field().valid();
|
||||
}
|
||||
|
||||
get invalid(): boolean {
|
||||
return this.field().invalid();
|
||||
}
|
||||
|
||||
get pending(): boolean | null {
|
||||
return this.field().pending();
|
||||
}
|
||||
|
||||
get disabled(): boolean {
|
||||
return this.field().disabled();
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return !this.field().disabled();
|
||||
}
|
||||
|
||||
get errors(): ValidationErrors | null {
|
||||
const errors = this.field().errors();
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const errObj: ValidationErrors = {};
|
||||
for (const error of errors) {
|
||||
errObj[error.kind] = error;
|
||||
}
|
||||
return errObj;
|
||||
}
|
||||
|
||||
get pristine(): boolean {
|
||||
return !this.field().dirty();
|
||||
}
|
||||
|
||||
get dirty(): boolean {
|
||||
return this.field().dirty();
|
||||
}
|
||||
|
||||
get touched(): boolean {
|
||||
return this.field().touched();
|
||||
}
|
||||
|
||||
get untouched(): boolean {
|
||||
return !this.field().touched();
|
||||
}
|
||||
|
||||
get status(): FormControlStatus {
|
||||
if (this.field().disabled()) {
|
||||
return 'DISABLED';
|
||||
}
|
||||
if (this.field().valid()) {
|
||||
return 'VALID';
|
||||
}
|
||||
if (this.field().invalid()) {
|
||||
return 'INVALID';
|
||||
}
|
||||
if (this.field().pending()) {
|
||||
return 'PENDING';
|
||||
}
|
||||
throw Error('AssertionError: unknown form control status');
|
||||
}
|
||||
|
||||
valueAccessor: ControlValueAccessor | null = null;
|
||||
|
||||
hasValidator(validator: ValidatorFn): boolean {
|
||||
// This addresses a common case where users look for the presence of `Validators.required` to
|
||||
// determine whether or not to show a required "*" indicator in the UI.
|
||||
if (validator === Validators.required) {
|
||||
return this.field().property(REQUIRED)();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
updateValueAndValidity() {
|
||||
// No-op since value and validity are always up to date in signal forms.
|
||||
// We offer this method so that reactive forms code attempting to call it doesn't error.
|
||||
}
|
||||
}
|
||||
113
packages/forms/signals/src/field/context.ts
Normal file
113
packages/forms/signals/src/field/context.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* @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 {computed, Signal, untracked, WritableSignal} from '@angular/core';
|
||||
import {Field, FieldContext, FieldPath, FieldState} from '../api/types';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {isArray} from '../util/type_guards';
|
||||
import type {FieldNode} from './node';
|
||||
import {getBoundPathDepth} from './resolution';
|
||||
|
||||
/**
|
||||
* `FieldContext` implementation, backed by a `FieldNode`.
|
||||
*/
|
||||
export class FieldNodeContext implements FieldContext<unknown> {
|
||||
/**
|
||||
* Cache of paths that have been resolved for this context.
|
||||
*
|
||||
* For each resolved path we keep track of a signal of field that it maps to rather than a static
|
||||
* field, since it theoretically could change. In practice for the current system it should not
|
||||
* actually change, as they only place we currently track fields moving within the parent
|
||||
* structure is for arrays, and paths do not currently support array indexing.
|
||||
*/
|
||||
private readonly cache = new WeakMap<FieldPath<unknown>, Signal<Field<unknown>>>();
|
||||
|
||||
constructor(
|
||||
/** The field node this context corresponds to. */
|
||||
private readonly node: FieldNode,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolves a target path relative to this context.
|
||||
* @param target The path to resolve
|
||||
* @returns The field corresponding to the target path.
|
||||
*/
|
||||
private resolve<U>(target: FieldPath<U>): Field<U> {
|
||||
if (!this.cache.has(target)) {
|
||||
const resolver = computed<Field<unknown>>(() => {
|
||||
const targetPathNode = FieldPathNode.unwrapFieldPath(target);
|
||||
|
||||
// First, find the field where the root our target path was merged in.
|
||||
// We determine this by walking up the field tree from the current field and looking for
|
||||
// the place where the LogicNodeBuilder from the target path's root was merged in.
|
||||
// We always make sure to walk up at least as far as the depth of the path we were bound to.
|
||||
// This ensures that we do not accidentally match on the wrong application of a recursively
|
||||
// applied schema.
|
||||
let field: FieldNode | undefined = this.node;
|
||||
let stepsRemaining = getBoundPathDepth();
|
||||
while (stepsRemaining > 0 || !field.structure.logic.hasLogic(targetPathNode.root.logic)) {
|
||||
stepsRemaining--;
|
||||
field = field.structure.parent;
|
||||
if (field === undefined) {
|
||||
throw new Error('Path is not part of this field tree.');
|
||||
}
|
||||
}
|
||||
|
||||
// Now, we can navigate to the target field using the relative path in the target path node
|
||||
// to traverse down from the field we just found.
|
||||
for (let key of targetPathNode.keys) {
|
||||
field = field.structure.getChild(key);
|
||||
if (field === undefined) {
|
||||
throw new Error(
|
||||
`Cannot resolve path .${targetPathNode.keys.join('.')} relative to field ${[
|
||||
'<root>',
|
||||
...this.node.structure.pathKeys(),
|
||||
].join('.')}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return field.fieldProxy;
|
||||
});
|
||||
|
||||
this.cache.set(target, resolver);
|
||||
}
|
||||
return this.cache.get(target)!() as Field<U>;
|
||||
}
|
||||
|
||||
get field(): Field<unknown> {
|
||||
return this.node.fieldProxy;
|
||||
}
|
||||
|
||||
get state(): FieldState<unknown> {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
get value(): WritableSignal<unknown> {
|
||||
return this.node.structure.value;
|
||||
}
|
||||
|
||||
get key(): Signal<string> {
|
||||
return this.node.structure.keyInParent;
|
||||
}
|
||||
|
||||
readonly index = computed(() => {
|
||||
// Attempt to read the key first, this will throw an error if we're on a root field.
|
||||
const key = this.key();
|
||||
// Assert that the parent is actually an array.
|
||||
if (!isArray(untracked(this.node.structure.parent!.value))) {
|
||||
throw new Error(`RuntimeError: cannot access index, parent field is not an array`);
|
||||
}
|
||||
// Return the key as a number if we are indeed inside an array field.
|
||||
return Number(key);
|
||||
});
|
||||
|
||||
readonly fieldOf = <P>(p: FieldPath<P>) => this.resolve(p);
|
||||
readonly stateOf = <P>(p: FieldPath<P>) => this.resolve(p)();
|
||||
readonly valueOf = <P>(p: FieldPath<P>) => this.resolve(p)().value();
|
||||
}
|
||||
124
packages/forms/signals/src/field/field_adapter.ts
Normal file
124
packages/forms/signals/src/field/field_adapter.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* @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 {FieldPathNode} from '../schema/path_node';
|
||||
|
||||
import {FormFieldManager} from './manager';
|
||||
import {FieldNode} from './node';
|
||||
import {FieldNodeState} from './state';
|
||||
import {ChildFieldNodeOptions, FieldNodeOptions, FieldNodeStructure} from './structure';
|
||||
import {ValidationState, FieldValidationState} from './validation';
|
||||
import {WritableSignal} from '@angular/core';
|
||||
|
||||
/**
|
||||
* Adapter allowing customization of the creation logic for a field and its associated
|
||||
* structure and state.
|
||||
*/
|
||||
export interface FieldAdapter {
|
||||
/**
|
||||
* Creates a node structure.
|
||||
* @param node
|
||||
* @param options
|
||||
*/
|
||||
createStructure(node: FieldNode, options: FieldNodeOptions): FieldNodeStructure;
|
||||
|
||||
/**
|
||||
* Creates node validation state
|
||||
* @param param
|
||||
* @param options
|
||||
*/
|
||||
createValidationState(param: FieldNode, options: FieldNodeOptions): ValidationState;
|
||||
|
||||
/**
|
||||
* Creates node state.
|
||||
* @param param
|
||||
* @param options
|
||||
*/
|
||||
createNodeState(param: FieldNode, options: FieldNodeOptions): FieldNodeState;
|
||||
|
||||
/**
|
||||
* Creates a custom child node.
|
||||
* @param options
|
||||
*/
|
||||
newChild(options: ChildFieldNodeOptions): FieldNode;
|
||||
|
||||
/**
|
||||
* Creates a custom root node.
|
||||
* @param fieldManager
|
||||
* @param model
|
||||
* @param pathNode
|
||||
* @param adapter
|
||||
*/
|
||||
newRoot<TValue>(
|
||||
fieldManager: FormFieldManager,
|
||||
model: WritableSignal<TValue>,
|
||||
pathNode: FieldPathNode,
|
||||
adapter: FieldAdapter,
|
||||
): FieldNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic adapter supporting standard form behavior.
|
||||
*/
|
||||
export class BasicFieldAdapter implements FieldAdapter {
|
||||
/**
|
||||
* Creates a new Root field node.
|
||||
* @param fieldManager
|
||||
* @param value
|
||||
* @param pathNode
|
||||
* @param adapter
|
||||
*/
|
||||
newRoot<TValue>(
|
||||
fieldManager: FormFieldManager,
|
||||
value: WritableSignal<TValue>,
|
||||
pathNode: FieldPathNode,
|
||||
adapter: FieldAdapter,
|
||||
): FieldNode {
|
||||
return new FieldNode({
|
||||
kind: 'root',
|
||||
fieldManager,
|
||||
value,
|
||||
pathNode,
|
||||
logic: pathNode.logic.build(),
|
||||
fieldAdapter: adapter,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new child field node.
|
||||
* @param options
|
||||
*/
|
||||
newChild(options: ChildFieldNodeOptions): FieldNode {
|
||||
return new FieldNode(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a node state.
|
||||
* @param node
|
||||
*/
|
||||
createNodeState(node: FieldNode): FieldNodeState {
|
||||
return new FieldNodeState(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a validation state.
|
||||
* @param node
|
||||
*/
|
||||
createValidationState(node: FieldNode): ValidationState {
|
||||
return new FieldValidationState(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a node structure.
|
||||
* @param node
|
||||
* @param options
|
||||
*/
|
||||
createStructure(node: FieldNode, options: FieldNodeOptions): FieldNodeStructure {
|
||||
return node.createStructure(options);
|
||||
}
|
||||
}
|
||||
83
packages/forms/signals/src/field/manager.ts
Normal file
83
packages/forms/signals/src/field/manager.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @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 {APP_ID, effect, inject, Injector, untracked} from '@angular/core';
|
||||
import type {FieldNodeStructure} from './structure';
|
||||
|
||||
/**
|
||||
* Manages the collection of fields associated with a given `form`.
|
||||
*
|
||||
* Fields are created implicitly, through reactivity, and may create "owned" entities like effects
|
||||
* or resources. When a field is no longer connected to the form, these owned entities should be
|
||||
* destroyed, which is the job of the `FormFieldManager`.
|
||||
*/
|
||||
export class FormFieldManager {
|
||||
readonly rootName: string;
|
||||
constructor(
|
||||
readonly injector: Injector,
|
||||
rootName: string | undefined,
|
||||
) {
|
||||
this.rootName = rootName ?? `${this.injector.get(APP_ID)}.form${nextFormId++}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains all child field structures that have been created as part of the current form.
|
||||
* New child structures are automatically added when they are created.
|
||||
* Structures are destroyed and removed when they are no longer reachable from the root.
|
||||
*/
|
||||
readonly structures = new Set<FieldNodeStructure>();
|
||||
|
||||
/**
|
||||
* Creates an effect that runs when the form's structure changes and checks for structures that
|
||||
* have become unreachable to clean up.
|
||||
*
|
||||
* For example, consider a form wrapped around the following model: `signal([0, 1, 2])`.
|
||||
* This form would have 4 nodes as part of its structure tree.
|
||||
* One structure for the root array, and one structure for each element of the array.
|
||||
* Now imagine the data is updated: `model.set([0])`. In this case the structure for the first
|
||||
* element can still be reached from the root, but the structures for the second and third
|
||||
* elements are now orphaned and not connected to the root. Thus they will be destroyed.
|
||||
*
|
||||
* @param root The root field structure.
|
||||
*/
|
||||
createFieldManagementEffect(root: FieldNodeStructure): void {
|
||||
effect(
|
||||
() => {
|
||||
const liveStructures = new Set<FieldNodeStructure>();
|
||||
this.markStructuresLive(root, liveStructures);
|
||||
|
||||
// Destroy all nodes that are no longer live.
|
||||
for (const structure of this.structures) {
|
||||
if (!liveStructures.has(structure)) {
|
||||
this.structures.delete(structure);
|
||||
untracked(() => structure.destroy());
|
||||
}
|
||||
}
|
||||
},
|
||||
{injector: this.injector},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all structures reachable from the given structure into the given set.
|
||||
*
|
||||
* @param structure The root structure
|
||||
* @param liveStructures The set of reachable structures to populate
|
||||
*/
|
||||
private markStructuresLive(
|
||||
structure: FieldNodeStructure,
|
||||
liveStructures: Set<FieldNodeStructure>,
|
||||
): void {
|
||||
liveStructures.add(structure);
|
||||
for (const child of structure.children()) {
|
||||
this.markStructuresLive(child.structure, liveStructures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nextFormId = 0;
|
||||
227
packages/forms/signals/src/field/node.ts
Normal file
227
packages/forms/signals/src/field/node.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/**
|
||||
* @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 type {Signal, WritableSignal} from '@angular/core';
|
||||
import {AggregateProperty, Property} from '../api/property';
|
||||
import type {DisabledReason, Field, FieldContext, FieldState} from '../api/types';
|
||||
import type {ValidationError} from '../api/validation_errors';
|
||||
import type {Control} from '../controls/control';
|
||||
import {LogicNode} from '../schema/logic_node';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {FieldNodeContext} from './context';
|
||||
import type {FormFieldManager} from './manager';
|
||||
import {FieldPropertyState} from './property';
|
||||
import {FIELD_PROXY_HANDLER} from './proxy';
|
||||
import {FieldNodeState} from './state';
|
||||
import {
|
||||
type ChildFieldNodeOptions,
|
||||
ChildFieldNodeStructure,
|
||||
type FieldNodeOptions,
|
||||
type FieldNodeStructure,
|
||||
RootFieldNodeStructure,
|
||||
} from './structure';
|
||||
import {FieldSubmitState} from './submit';
|
||||
import {ValidationState} from './validation';
|
||||
import type {FieldAdapter} from './field_adapter';
|
||||
/**
|
||||
* Internal node in the form tree for a given field.
|
||||
*
|
||||
* Field nodes have several responsibilities:
|
||||
* - They track instance state for the particular field (touched)
|
||||
* - They compute signals for derived state (valid, disabled, etc) based on their associated
|
||||
* `LogicNode`
|
||||
* - They act as the public API for the field (they implement the `FieldState` interface)
|
||||
* - They implement navigation of the form tree via `.parent` and `.getChild()`.
|
||||
*
|
||||
* This class is largely a wrapper that aggregates several smaller pieces that each manage a subset of
|
||||
* the responsibilities.
|
||||
*/
|
||||
export class FieldNode implements FieldState<unknown> {
|
||||
readonly structure: FieldNodeStructure;
|
||||
readonly validationState: ValidationState;
|
||||
readonly propertyState: FieldPropertyState;
|
||||
readonly nodeState: FieldNodeState;
|
||||
readonly submitState: FieldSubmitState;
|
||||
|
||||
private _context: FieldContext<unknown> | undefined = undefined;
|
||||
readonly fieldAdapter: FieldAdapter;
|
||||
|
||||
get context(): FieldContext<unknown> {
|
||||
return (this._context ??= new FieldNodeContext(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy to this node which allows navigation of the form graph below it.
|
||||
*/
|
||||
readonly fieldProxy = new Proxy(() => this, FIELD_PROXY_HANDLER) as unknown as Field<any>;
|
||||
|
||||
constructor(options: FieldNodeOptions) {
|
||||
this.fieldAdapter = options.fieldAdapter;
|
||||
this.structure = this.fieldAdapter.createStructure(this, options);
|
||||
this.validationState = this.fieldAdapter.createValidationState(this, options);
|
||||
this.nodeState = this.fieldAdapter.createNodeState(this, options);
|
||||
this.propertyState = new FieldPropertyState(this);
|
||||
this.submitState = new FieldSubmitState(this);
|
||||
}
|
||||
|
||||
get logicNode(): LogicNode {
|
||||
return this.structure.logic;
|
||||
}
|
||||
|
||||
get value(): WritableSignal<unknown> {
|
||||
return this.structure.value;
|
||||
}
|
||||
|
||||
get keyInParent(): Signal<string | number> {
|
||||
return this.structure.keyInParent;
|
||||
}
|
||||
|
||||
get errors(): Signal<ValidationError[]> {
|
||||
return this.validationState.errors;
|
||||
}
|
||||
|
||||
get errorSummary(): Signal<ValidationError[]> {
|
||||
return this.validationState.errorSummary;
|
||||
}
|
||||
|
||||
get pending(): Signal<boolean> {
|
||||
return this.validationState.pending;
|
||||
}
|
||||
|
||||
get valid(): Signal<boolean> {
|
||||
return this.validationState.valid;
|
||||
}
|
||||
|
||||
get invalid(): Signal<boolean> {
|
||||
return this.validationState.invalid;
|
||||
}
|
||||
|
||||
get dirty(): Signal<boolean> {
|
||||
return this.nodeState.dirty;
|
||||
}
|
||||
|
||||
get touched(): Signal<boolean> {
|
||||
return this.nodeState.touched;
|
||||
}
|
||||
|
||||
get disabled(): Signal<boolean> {
|
||||
return this.nodeState.disabled;
|
||||
}
|
||||
|
||||
get disabledReasons(): Signal<readonly DisabledReason[]> {
|
||||
return this.nodeState.disabledReasons;
|
||||
}
|
||||
|
||||
get hidden(): Signal<boolean> {
|
||||
return this.nodeState.hidden;
|
||||
}
|
||||
|
||||
get readonly(): Signal<boolean> {
|
||||
return this.nodeState.readonly;
|
||||
}
|
||||
|
||||
get controls(): Signal<readonly Control<unknown>[]> {
|
||||
return this.nodeState.controls;
|
||||
}
|
||||
|
||||
get submitting(): Signal<boolean> {
|
||||
return this.submitState.submitting;
|
||||
}
|
||||
|
||||
get name(): Signal<string> {
|
||||
return this.nodeState.name;
|
||||
}
|
||||
|
||||
property<M>(prop: AggregateProperty<M, any>): Signal<M>;
|
||||
property<M>(prop: Property<M>): M | undefined;
|
||||
property<M>(prop: Property<M> | AggregateProperty<M, any>): Signal<M> | M | undefined {
|
||||
return this.propertyState.get(prop);
|
||||
}
|
||||
hasProperty(prop: Property<unknown> | AggregateProperty<unknown, any>): boolean {
|
||||
return this.propertyState.has(prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this specific field as touched.
|
||||
*/
|
||||
markAsTouched(): void {
|
||||
this.nodeState.markAsTouched();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this specific field as dirty.
|
||||
*/
|
||||
markAsDirty(): void {
|
||||
this.nodeState.markAsDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the {@link touched} and {@link dirty} state of the field and its descendants.
|
||||
*
|
||||
* Note this does not change the data model, which can be reset directly if desired.
|
||||
*/
|
||||
reset(): void {
|
||||
this.nodeState.markAsUntouched();
|
||||
this.nodeState.markAsPristine();
|
||||
|
||||
for (const child of this.structure.children()) {
|
||||
child.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new root field node for a new form.
|
||||
*/
|
||||
static newRoot<T>(
|
||||
fieldManager: FormFieldManager,
|
||||
value: WritableSignal<T>,
|
||||
pathNode: FieldPathNode,
|
||||
adapter: FieldAdapter,
|
||||
): FieldNode {
|
||||
return adapter.newRoot(fieldManager, value, pathNode, adapter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a child field node based on the given options.
|
||||
*/
|
||||
private static newChild(options: ChildFieldNodeOptions): FieldNode {
|
||||
return options.fieldAdapter.newChild(options);
|
||||
}
|
||||
|
||||
createStructure(options: FieldNodeOptions) {
|
||||
return options.kind === 'root'
|
||||
? new RootFieldNodeStructure(
|
||||
this,
|
||||
options.pathNode,
|
||||
options.logic,
|
||||
options.fieldManager,
|
||||
options.value,
|
||||
options.fieldAdapter,
|
||||
FieldNode.newChild,
|
||||
)
|
||||
: new ChildFieldNodeStructure(
|
||||
this,
|
||||
options.pathNode,
|
||||
options.logic,
|
||||
options.parent,
|
||||
options.identityInParent,
|
||||
options.initialKeyInParent,
|
||||
options.fieldAdapter,
|
||||
FieldNode.newChild,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Field node of a field that has children.
|
||||
* This simplifies and makes certain types cleaner.
|
||||
*/
|
||||
export interface ParentFieldNode extends FieldNode {
|
||||
readonly value: WritableSignal<Record<string, unknown>>;
|
||||
readonly structure: FieldNodeStructure & {value: WritableSignal<Record<string, unknown>>};
|
||||
}
|
||||
74
packages/forms/signals/src/field/property.ts
Normal file
74
packages/forms/signals/src/field/property.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* @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 {computed, runInInjectionContext, Signal, untracked} from '@angular/core';
|
||||
import {AggregateProperty, Property} from '../api/property';
|
||||
import type {FieldNode} from './node';
|
||||
import {cast} from './util';
|
||||
|
||||
/**
|
||||
* Tracks custom properties associated with a `FieldNode`.
|
||||
*/
|
||||
export class FieldPropertyState {
|
||||
/** A map of all `Property` and `AggregateProperty` that have been defined for this field. */
|
||||
private readonly properties = new Map<
|
||||
Property<unknown> | AggregateProperty<unknown, unknown>,
|
||||
unknown
|
||||
>();
|
||||
|
||||
constructor(private readonly node: FieldNode) {
|
||||
// Field nodes (and thus their property state) are created in a linkedSignal in order to mirror
|
||||
// the structure of the model data. We need to run the property factories untracked so that they
|
||||
// do not cause recomputation of the linkedSignal.
|
||||
untracked(() =>
|
||||
// Property factories are run in the form's injection context so they can create resources
|
||||
// and inject DI dependencies.
|
||||
runInInjectionContext(this.node.structure.injector, () => {
|
||||
for (const [key, factory] of this.node.logicNode.logic.getPropertyFactoryEntries()) {
|
||||
this.properties.set(key, factory(this.node.context));
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Gets the value of a `Property` or `AggregateProperty` for the field. */
|
||||
get<T>(prop: Property<T> | AggregateProperty<T, unknown>): T | undefined | Signal<T> {
|
||||
if (prop instanceof Property) {
|
||||
return this.properties.get(prop) as T | undefined;
|
||||
}
|
||||
// Aggregate properties come with an initial value, and are considered to exist for every field.
|
||||
// If no logic explicitly contributes values for the property, it is just considered to be the
|
||||
// initial value. Therefore if the user asks for an aggregate property for a field,
|
||||
// we just create its computed on the fly.
|
||||
cast<AggregateProperty<unknown, unknown>>(prop);
|
||||
if (!this.properties.has(prop)) {
|
||||
const logic = this.node.logicNode.logic.getAggregateProperty(prop);
|
||||
const result = computed(() => logic.compute(this.node.context));
|
||||
this.properties.set(prop, result);
|
||||
}
|
||||
return this.properties.get(prop)! as Signal<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the current property state has the given property.
|
||||
* @param prop
|
||||
* @returns
|
||||
*/
|
||||
has(prop: Property<unknown> | AggregateProperty<unknown, unknown>): boolean {
|
||||
if (prop instanceof AggregateProperty) {
|
||||
// For aggregate properties, they get added to the map lazily, on first access, so we can't
|
||||
// rely on checking presence in the properties map. Instead we check if there is any logic for
|
||||
// the given property.
|
||||
return this.node.logicNode.logic.hasAggregateProperty(prop);
|
||||
} else {
|
||||
// Non-aggregate proeprties get added to our properties map on construction, so we can just
|
||||
// refer to their presence in the map.
|
||||
return this.properties.has(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
54
packages/forms/signals/src/field/proxy.ts
Normal file
54
packages/forms/signals/src/field/proxy.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @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 {untracked} from '@angular/core';
|
||||
import {isArray} from '../util/type_guards';
|
||||
import type {FieldNode} from './node';
|
||||
|
||||
/**
|
||||
* Proxy handler which implements `Field<T>` on top of `FieldNode`.
|
||||
*/
|
||||
export const FIELD_PROXY_HANDLER: ProxyHandler<() => FieldNode> = {
|
||||
get(getTgt: () => FieldNode, p: string | symbol) {
|
||||
const tgt = getTgt();
|
||||
|
||||
// First, check whether the requested property is a defined child node of this node.
|
||||
const child = tgt.structure.getChild(p);
|
||||
if (child !== undefined) {
|
||||
// If so, return the child node's `Field` proxy, allowing the developer to continue navigating
|
||||
// the form structure.
|
||||
return child.fieldProxy;
|
||||
}
|
||||
|
||||
// Otherwise, we need to consider whether the properties they're accessing are related to array
|
||||
// iteration. We're specifically interested in `length`, but we only want to pass this through
|
||||
// if the value is actually an array.
|
||||
//
|
||||
// We untrack the value here to avoid spurious reactive notifications. In reality, we've already
|
||||
// incurred a dependency on the value via `tgt.getChild()` above.
|
||||
const value = untracked(tgt.value);
|
||||
|
||||
if (isArray(value)) {
|
||||
// Allow access to the length for field arrays, it should be the same as the length of the data.
|
||||
if (p === 'length') {
|
||||
return (tgt.value() as Array<unknown>).length;
|
||||
}
|
||||
// Allow access to the iterator. This allows the user to spread the field array into a
|
||||
// standard array in order to call methods like `filter`, `map`, etc.
|
||||
if (p === Symbol.iterator) {
|
||||
return (Array.prototype as any)[p];
|
||||
}
|
||||
// Note: We can consider supporting additional array methods if we want in the future,
|
||||
// but they should be thoroughly tested. Just forwarding the method directly from the
|
||||
// `Array` prototype results in broken behavior for some methods like `map`.
|
||||
}
|
||||
|
||||
// Otherwise, this property doesn't exist.
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
61
packages/forms/signals/src/field/resolution.ts
Normal file
61
packages/forms/signals/src/field/resolution.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
let boundPathDepth = 0;
|
||||
|
||||
/**
|
||||
* The depth of the current path when evaluating a logic function.
|
||||
* Do not set this directly, it is a context variable managed by `setBoundPathDepthForResolution`.
|
||||
*/
|
||||
export function getBoundPathDepth() {
|
||||
return boundPathDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the bound path depth for the duration of the given logic function.
|
||||
* This is used to ensure that the field resolution algorithm walks far enough up the field tree to
|
||||
* reach the point where the root of the path we're bound to is applied. This normally isn't a big
|
||||
* concern, but matters when we're dealing with recursive structures.
|
||||
*
|
||||
* Consider this example:
|
||||
*
|
||||
* ```
|
||||
* const s = schema(p => {
|
||||
* disabled(p.next, ({valueOf}) => valueOf(p.data));
|
||||
* apply(p.next, s);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Here we need to know that the `disabled` logic was bound to a path of depth 1. Otherwise we'd
|
||||
* attempt to resolve `p.data` in the context of the field corresponding to `p.next`.
|
||||
* The resolution algorithm would start with the field for `p.next` and see that it *does* contain
|
||||
* the logic for `s` (due to the fact that its recursively applied.) It would then decide not to
|
||||
* walk up the field tree at all, and to immediately start walking down the keys for the target path
|
||||
* `p.data`, leading it to grab the field corresponding to `p.next.data`.
|
||||
*
|
||||
* We avoid the problem described above by keeping track of the depth (relative to Schema root) of
|
||||
* the path we were bound to. We then require the resolution algorithm to walk at least that far up
|
||||
* the tree before finding a node that contains the logic for `s`.
|
||||
*
|
||||
* @param fn A logic function that is bound to a particular path
|
||||
* @param depth The depth in the field tree of the field the logic is bound to
|
||||
* @returns A version of the logic function that is aware of its depth.
|
||||
*/
|
||||
export function setBoundPathDepthForResolution<A extends any[], R>(
|
||||
fn: (...args: A) => R,
|
||||
depth: number,
|
||||
): (...args: A) => R {
|
||||
return (...args: A) => {
|
||||
try {
|
||||
boundPathDepth = depth;
|
||||
return fn(...args);
|
||||
} finally {
|
||||
boundPathDepth = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
159
packages/forms/signals/src/field/state.ts
Normal file
159
packages/forms/signals/src/field/state.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* @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 {computed, signal, Signal} from '@angular/core';
|
||||
import type {DisabledReason} from '../api/types';
|
||||
import type {Control} from '../controls/control';
|
||||
import type {FieldNode} from './node';
|
||||
import {reduceChildren, shortCircuitTrue} from './util';
|
||||
|
||||
/**
|
||||
* The non-validation and non-submit state associated with a `FieldNode`, such as touched and dirty
|
||||
* status, as well as derived logical state.
|
||||
*/
|
||||
export class FieldNodeState {
|
||||
/**
|
||||
* Indicates whether this field has been touched directly by the user (as opposed to indirectly by
|
||||
* touching a child field).
|
||||
*
|
||||
* A field is considered directly touched when a user stops editing it for the first time (i.e. on blur)
|
||||
*/
|
||||
private readonly selfTouched = signal(false);
|
||||
|
||||
/**
|
||||
* Indicates whether this field has been dirtied directly by the user (as opposed to indirectly by
|
||||
* dirtying a child field).
|
||||
*
|
||||
* A field is considered directly dirtied if a user changed the value of the field at least once.
|
||||
*/
|
||||
private readonly selfDirty = signal(false);
|
||||
|
||||
/**
|
||||
* Marks this specific field as touched.
|
||||
*/
|
||||
markAsTouched(): void {
|
||||
// TODO: should this be noop for fields that are hidden/disabled/readonly
|
||||
this.selfTouched.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this specific field as dirty.
|
||||
*/
|
||||
markAsDirty(): void {
|
||||
// TODO: should this be noop for fields that are hidden/disabled/readonly
|
||||
this.selfDirty.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this specific field as not dirty.
|
||||
*/
|
||||
markAsPristine(): void {
|
||||
this.selfDirty.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this specific field as not touched.
|
||||
*/
|
||||
markAsUntouched(): void {
|
||||
this.selfTouched.set(false);
|
||||
}
|
||||
|
||||
/** The UI controls the field is currently bound to. */
|
||||
readonly controls = signal<readonly Control<unknown>[]>([]);
|
||||
|
||||
constructor(private readonly node: FieldNode) {}
|
||||
|
||||
/**
|
||||
* Whether this field is considered dirty.
|
||||
*
|
||||
* A field is considered dirty if one of the following is true:
|
||||
* - It was directly dirtied
|
||||
* - One of its children is considered dirty
|
||||
*/
|
||||
readonly dirty: Signal<boolean> = computed(() => {
|
||||
return reduceChildren(
|
||||
this.node,
|
||||
this.selfDirty(),
|
||||
(child, value) => value || child.nodeState.dirty(),
|
||||
shortCircuitTrue,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether this field is considered touched.
|
||||
*
|
||||
* A field is considered touched if one of the following is true:
|
||||
* - It was directly touched
|
||||
* - One of its children is considered touched
|
||||
*/
|
||||
readonly touched: Signal<boolean> = computed(() =>
|
||||
reduceChildren(
|
||||
this.node,
|
||||
this.selfTouched(),
|
||||
(child, value) => value || child.nodeState.touched(),
|
||||
shortCircuitTrue,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The reasons for this field's disablement. This includes disabled reasons for any parent field
|
||||
* that may have been disabled, indirectly causing this field to be disabled as well.
|
||||
* The `field` property of the `DisabledReason` can be used to determine which field ultimately
|
||||
* caused the disablement.
|
||||
*/
|
||||
readonly disabledReasons: Signal<readonly DisabledReason[]> = computed(() => [
|
||||
...(this.node.structure.parent?.nodeState.disabledReasons() ?? []),
|
||||
...this.node.logicNode.logic.disabledReasons.compute(this.node.context),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether this field is considered disabled.
|
||||
*
|
||||
* A field is considered disabled if one of the following is true:
|
||||
* - The schema contains logic that directly disabled it
|
||||
* - Its parent field is considered disabled
|
||||
*/
|
||||
readonly disabled: Signal<boolean> = computed(() => !!this.disabledReasons().length);
|
||||
|
||||
/**
|
||||
* Whether this field is considered readonly.
|
||||
*
|
||||
* A field is considered readonly if one of the following is true:
|
||||
* - The schema contains logic that directly made it readonly
|
||||
* - Its parent field is considered readonly
|
||||
*/
|
||||
readonly readonly: Signal<boolean> = computed(
|
||||
() =>
|
||||
(this.node.structure.parent?.nodeState.readonly() ||
|
||||
this.node.logicNode.logic.readonly.compute(this.node.context)) ??
|
||||
false,
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this field is considered hidden.
|
||||
*
|
||||
* A field is considered hidden if one of the following is true:
|
||||
* - The schema contains logic that directly hides it
|
||||
* - Its parent field is considered hidden
|
||||
*/
|
||||
readonly hidden: Signal<boolean> = computed(
|
||||
() =>
|
||||
(this.node.structure.parent?.nodeState.hidden() ||
|
||||
this.node.logicNode.logic.hidden.compute(this.node.context)) ??
|
||||
false,
|
||||
);
|
||||
|
||||
readonly name: Signal<string> = computed(() => {
|
||||
const parent = this.node.structure.parent;
|
||||
if (!parent) {
|
||||
return this.node.structure.fieldManager.rootName;
|
||||
}
|
||||
|
||||
return `${parent.name()}.${this.node.structure.keyInParent()}`;
|
||||
});
|
||||
}
|
||||
460
packages/forms/signals/src/field/structure.ts
Normal file
460
packages/forms/signals/src/field/structure.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
/**
|
||||
* @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 {
|
||||
computed,
|
||||
DestroyableInjector,
|
||||
Injector,
|
||||
linkedSignal,
|
||||
Signal,
|
||||
WritableSignal,
|
||||
} from '@angular/core';
|
||||
|
||||
import {DYNAMIC} from '../schema/logic';
|
||||
import {LogicNode} from '../schema/logic_node';
|
||||
import type {FieldPathNode} from '../schema/path_node';
|
||||
import {deepSignal} from '../util/deep_signal';
|
||||
import {isArray, isObject} from '../util/type_guards';
|
||||
import type {FormFieldManager} from './manager';
|
||||
import type {FieldNode, ParentFieldNode} from './node';
|
||||
import type {FieldAdapter} from './field_adapter';
|
||||
|
||||
/**
|
||||
* Key by which a parent `FieldNode` tracks its children.
|
||||
*
|
||||
* Often this is the actual property key of the child, but in the case of arrays it could be a
|
||||
* tracking key allocated for the object.
|
||||
*/
|
||||
export type TrackingKey = PropertyKey & {__brand: 'FieldIdentity'};
|
||||
|
||||
/** Structural component of a `FieldNode` which tracks its path, parent, and children. */
|
||||
export abstract class FieldNodeStructure {
|
||||
/** Computed map of child fields, based on the current value of this field. */
|
||||
abstract readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
|
||||
|
||||
/** The field's value. */
|
||||
abstract readonly value: WritableSignal<unknown>;
|
||||
|
||||
/**
|
||||
* The key of this field in its parent field.
|
||||
* Attempting to read this for the root field will result in an error being thrown.
|
||||
*/
|
||||
abstract readonly keyInParent: Signal<string>;
|
||||
|
||||
/** The field manager responsible for managing this field. */
|
||||
abstract readonly fieldManager: FormFieldManager;
|
||||
|
||||
/** The root field that this field descends from. */
|
||||
abstract readonly root: FieldNode;
|
||||
|
||||
/** The list of property keys to follow to get from the `root` to this field. */
|
||||
abstract readonly pathKeys: Signal<readonly PropertyKey[]>;
|
||||
|
||||
/** The parent field of this field. */
|
||||
abstract readonly parent: FieldNode | undefined;
|
||||
|
||||
/** Added to array elements for tracking purposes. */
|
||||
// TODO: given that we don't ever let a field move between parents, is it safe to just extract
|
||||
// this to a shared symbol for all fields, rather than having a separate one per parent?
|
||||
readonly identitySymbol = Symbol();
|
||||
|
||||
/** Lazily initialized injector. Do not access directly, access via `injector` getter instead. */
|
||||
private _injector: DestroyableInjector | undefined = undefined;
|
||||
|
||||
/** Lazily initialized injector. */
|
||||
get injector(): DestroyableInjector {
|
||||
this._injector ??= Injector.create({
|
||||
providers: [],
|
||||
parent: this.fieldManager.injector,
|
||||
}) as DestroyableInjector;
|
||||
return this._injector;
|
||||
}
|
||||
|
||||
constructor(
|
||||
/** The logic to apply to this field. */
|
||||
readonly logic: LogicNode,
|
||||
) {}
|
||||
|
||||
/** Gets the child fields of this field. */
|
||||
children(): Iterable<FieldNode> {
|
||||
return this.childrenMap()?.values() ?? [];
|
||||
}
|
||||
|
||||
/** Retrieve a child `FieldNode` of this node by property key. */
|
||||
getChild(key: PropertyKey): FieldNode | undefined {
|
||||
const map = this.childrenMap();
|
||||
const value = this.value();
|
||||
if (!map || !isObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isArray(value)) {
|
||||
const childValue = value[key];
|
||||
if (isObject(childValue) && childValue.hasOwnProperty(this.identitySymbol)) {
|
||||
// For arrays, we want to use the tracking identity of the value instead of the raw property
|
||||
// as our index into the `childrenMap`.
|
||||
key = childValue[this.identitySymbol] as PropertyKey;
|
||||
}
|
||||
}
|
||||
|
||||
return map.get((typeof key === 'number' ? key.toString() : key) as TrackingKey);
|
||||
}
|
||||
|
||||
/** Destroys the field when it is no longer needed. */
|
||||
destroy(): void {
|
||||
this.injector.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** The structural component of a `FieldNode` that is the root of its field tree. */
|
||||
export class RootFieldNodeStructure extends FieldNodeStructure {
|
||||
override get parent(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override get root(): FieldNode {
|
||||
return this.node;
|
||||
}
|
||||
|
||||
override get pathKeys(): Signal<readonly PropertyKey[]> {
|
||||
return ROOT_PATH_KEYS;
|
||||
}
|
||||
|
||||
override get keyInParent(): Signal<string> {
|
||||
return ROOT_KEY_IN_PARENT;
|
||||
}
|
||||
|
||||
override readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
|
||||
|
||||
/**
|
||||
* Creates the structure for the root node of a field tree.
|
||||
*
|
||||
* @param node The full field node that this structure belongs to
|
||||
* @param pathNode The path corresponding to this node in the schema
|
||||
* @param logic The logic to apply to this field
|
||||
* @param fieldManager The field manager for this field
|
||||
* @param value The value signal for this field
|
||||
* @param adapter Adapter that knows how to create new fields and appropriate state.
|
||||
* @param createChildNode A factory function to create child nodes for this field.
|
||||
*/
|
||||
constructor(
|
||||
/** The full field node that corresponds to this structure. */
|
||||
private readonly node: FieldNode,
|
||||
pathNode: FieldPathNode,
|
||||
logic: LogicNode,
|
||||
override readonly fieldManager: FormFieldManager,
|
||||
override readonly value: WritableSignal<unknown>,
|
||||
adapter: FieldAdapter,
|
||||
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
|
||||
) {
|
||||
super(logic);
|
||||
this.childrenMap = makeChildrenMapSignal(
|
||||
node as ParentFieldNode,
|
||||
value,
|
||||
this.identitySymbol,
|
||||
pathNode,
|
||||
logic,
|
||||
adapter,
|
||||
createChildNode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The structural component of a child `FieldNode` within a field tree. */
|
||||
export class ChildFieldNodeStructure extends FieldNodeStructure {
|
||||
override readonly root: FieldNode;
|
||||
override readonly pathKeys: Signal<readonly PropertyKey[]>;
|
||||
override readonly keyInParent: Signal<string>;
|
||||
override readonly value: WritableSignal<unknown>;
|
||||
|
||||
override readonly childrenMap: Signal<Map<TrackingKey, FieldNode> | undefined>;
|
||||
|
||||
override get fieldManager(): FormFieldManager {
|
||||
return this.root.structure.fieldManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the structure for a child field node in a field tree.
|
||||
*
|
||||
* @param node The full field node that this structure belongs to
|
||||
* @param pathNode The path corresponding to this node in the schema
|
||||
* @param logic The logic to apply to this field
|
||||
* @param parent The parent field node for this node
|
||||
* @param identityInParent The identity used to track this field in its parent
|
||||
* @param initialKeyInParent The key of this field in its parent at the time of creation
|
||||
* @param adapter Adapter that knows how to create new fields and appropriate state.
|
||||
* @param createChildNode A factory function to create child nodes for this field.
|
||||
*/
|
||||
constructor(
|
||||
node: FieldNode,
|
||||
pathNode: FieldPathNode,
|
||||
logic: LogicNode,
|
||||
override readonly parent: ParentFieldNode,
|
||||
identityInParent: TrackingKey | undefined,
|
||||
initialKeyInParent: string,
|
||||
adapter: FieldAdapter,
|
||||
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
|
||||
) {
|
||||
super(logic);
|
||||
|
||||
this.root = this.parent.structure.root;
|
||||
|
||||
this.pathKeys = computed(() => [...parent.structure.pathKeys(), this.keyInParent()]);
|
||||
|
||||
if (identityInParent === undefined) {
|
||||
const key = initialKeyInParent;
|
||||
this.keyInParent = computed(() => {
|
||||
if (parent.structure.childrenMap()?.get(key as TrackingKey) !== node) {
|
||||
throw new Error(
|
||||
`RuntimeError: orphan field, looking for property '${key}' of ${getDebugName(parent)}`,
|
||||
);
|
||||
}
|
||||
return key;
|
||||
});
|
||||
} else {
|
||||
let lastKnownKey = initialKeyInParent;
|
||||
this.keyInParent = computed(() => {
|
||||
// TODO(alxhub): future perf optimization: here we depend on the parent's value, but most
|
||||
// changes to the value aren't structural - they aren't moving around objects and thus
|
||||
// shouldn't affect `keyInParent`. We currently mitigate this issue via `lastKnownKey`
|
||||
// which avoids a search.
|
||||
const parentValue = parent.structure.value();
|
||||
if (!isArray(parentValue)) {
|
||||
// It should not be possible to encounter this error. It would require the parent to
|
||||
// change from an array field to non-array field. However, in the current implementation
|
||||
// a field's parent can never change.
|
||||
throw new Error(
|
||||
`RuntimeError: orphan field, expected ${getDebugName(parent)} to be an array`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check the parent value at the last known key to avoid a scan.
|
||||
// Note: lastKnownKey is a string, but we pretend to typescript like its a number,
|
||||
// since accessing someArray['1'] is the same as accessing someArray[1]
|
||||
const data = parentValue[lastKnownKey as unknown as number];
|
||||
if (
|
||||
isObject(data) &&
|
||||
data.hasOwnProperty(parent.structure.identitySymbol) &&
|
||||
data[parent.structure.identitySymbol] === identityInParent
|
||||
) {
|
||||
return lastKnownKey;
|
||||
}
|
||||
|
||||
// Otherwise, we need to check all the keys in the parent.
|
||||
for (let i = 0; i < parentValue.length; i++) {
|
||||
const data = parentValue[i];
|
||||
if (
|
||||
isObject(data) &&
|
||||
data.hasOwnProperty(parent.structure.identitySymbol) &&
|
||||
data[parent.structure.identitySymbol] === identityInParent
|
||||
) {
|
||||
return (lastKnownKey = i.toString());
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`RuntimeError: orphan field, can't find element in array ${getDebugName(parent)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.value = deepSignal(this.parent.structure.value, this.keyInParent);
|
||||
this.childrenMap = makeChildrenMapSignal(
|
||||
node as ParentFieldNode,
|
||||
this.value,
|
||||
this.identitySymbol,
|
||||
pathNode,
|
||||
logic,
|
||||
adapter,
|
||||
createChildNode,
|
||||
);
|
||||
|
||||
this.fieldManager.structures.add(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** Global id used for tracking keys. */
|
||||
let globalId = 0;
|
||||
|
||||
/** Options passed when constructing a root field node. */
|
||||
export interface RootFieldNodeOptions {
|
||||
/** Kind of node, used to differentiate root node options from child node options. */
|
||||
readonly kind: 'root';
|
||||
/** The path node corresponding to this field in the schema. */
|
||||
readonly pathNode: FieldPathNode;
|
||||
/** The logic to apply to this field. */
|
||||
readonly logic: LogicNode;
|
||||
/** The value signal for this field. */
|
||||
readonly value: WritableSignal<unknown>;
|
||||
/** The field manager for this field. */
|
||||
readonly fieldManager: FormFieldManager;
|
||||
/** This allows for more granular field and state management, and is currently used for compat. */
|
||||
readonly fieldAdapter: FieldAdapter;
|
||||
}
|
||||
|
||||
/** Options passed when constructing a child field node. */
|
||||
export interface ChildFieldNodeOptions {
|
||||
/** Kind of node, used to differentiate root node options from child node options. */
|
||||
readonly kind: 'child';
|
||||
/** The parent field node of this field. */
|
||||
readonly parent: ParentFieldNode;
|
||||
/** The path node corresponding to this field in the schema. */
|
||||
readonly pathNode: FieldPathNode;
|
||||
/** The logic to apply to this field. */
|
||||
readonly logic: LogicNode;
|
||||
/** The key of this field in its parent at the time of creation. */
|
||||
readonly initialKeyInParent: string;
|
||||
/** The identity used to track this field in its parent. */
|
||||
readonly identityInParent: TrackingKey | undefined;
|
||||
/** This allows for more granular field and state management, and is currently used for compat. */
|
||||
readonly fieldAdapter: FieldAdapter;
|
||||
}
|
||||
|
||||
/** Options passed when constructing a field node. */
|
||||
export type FieldNodeOptions = RootFieldNodeOptions | ChildFieldNodeOptions;
|
||||
|
||||
/** A signal representing an empty list of path keys, used for root fields. */
|
||||
const ROOT_PATH_KEYS = computed<readonly PropertyKey[]>(() => []);
|
||||
|
||||
/**
|
||||
* A signal representing a non-existent key of the field in its parent, used for root fields which
|
||||
* do not have a parent. This signal will throw if it is read.
|
||||
*/
|
||||
const ROOT_KEY_IN_PARENT = computed(() => {
|
||||
throw new Error(`RuntimeError: the top-level field in the form has no parent`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a linked signal map of all child fields for a field.
|
||||
*
|
||||
* @param node The field to create the children map signal for.
|
||||
* @param valueSignal The value signal for the field.
|
||||
* @param identitySymbol The key used to access the tracking id of a field.
|
||||
* @param pathNode The path node corresponding to the field in the schema.
|
||||
* @param logic The logic to apply to the field.
|
||||
* @param adapter Adapter that knows how to create new fields and appropriate state.
|
||||
* @param createChildNode A factory function to create child nodes for this field.
|
||||
* @returns
|
||||
*/
|
||||
function makeChildrenMapSignal(
|
||||
node: FieldNode,
|
||||
valueSignal: WritableSignal<unknown>,
|
||||
identitySymbol: symbol,
|
||||
pathNode: FieldPathNode,
|
||||
logic: LogicNode,
|
||||
adapter: FieldAdapter,
|
||||
createChildNode: (options: ChildFieldNodeOptions) => FieldNode,
|
||||
): Signal<Map<TrackingKey, FieldNode> | undefined> {
|
||||
// We use a `linkedSignal` to preserve the instances of `FieldNode` for each child field even if
|
||||
// the value of this field changes its object identity. The computation creates or updates the map
|
||||
// of child `FieldNode`s for `node` based on its current value.
|
||||
return linkedSignal<unknown, Map<TrackingKey, FieldNode> | undefined>({
|
||||
source: valueSignal,
|
||||
computation: (value, previous): Map<TrackingKey, FieldNode> | undefined => {
|
||||
// We may or may not have a previous map. If there isn't one, then `childrenMap` will be lazily
|
||||
// initialized to a new map instance if needed.
|
||||
let childrenMap = previous?.value;
|
||||
|
||||
if (!isObject(value)) {
|
||||
// Non-object values have no children.
|
||||
return undefined;
|
||||
}
|
||||
const isValueArray = isArray(value);
|
||||
|
||||
// Remove fields that have disappeared since the last time this map was computed.
|
||||
if (childrenMap !== undefined) {
|
||||
let oldKeys: Set<TrackingKey> | undefined = undefined;
|
||||
if (isValueArray) {
|
||||
oldKeys = new Set(childrenMap.keys());
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const childValue = value[i] as unknown;
|
||||
if (isObject(childValue) && childValue.hasOwnProperty(identitySymbol)) {
|
||||
oldKeys.delete(childValue[identitySymbol] as TrackingKey);
|
||||
} else {
|
||||
oldKeys.delete(i.toString() as TrackingKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of oldKeys) {
|
||||
childrenMap.delete(key);
|
||||
}
|
||||
} else {
|
||||
for (let key of childrenMap.keys()) {
|
||||
if (!value.hasOwnProperty(key)) {
|
||||
childrenMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add fields that exist in the value but don't yet have instances in the map.
|
||||
for (let key of Object.keys(value)) {
|
||||
let trackingId: TrackingKey | undefined = undefined;
|
||||
const childValue = value[key] as unknown;
|
||||
|
||||
// Fields explicitly set to `undefined` are treated as if they don't exist.
|
||||
// This ensures that `{value: undefined}` and `{}` have the same behavior for their `value`
|
||||
// field.
|
||||
if (childValue === undefined) {
|
||||
// The value might have _become_ `undefined`, so we need to delete it here.
|
||||
childrenMap?.delete(key as TrackingKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isValueArray && isObject(childValue)) {
|
||||
// For object values in arrays, assign a synthetic identity instead.
|
||||
trackingId = (childValue[identitySymbol] as TrackingKey) ??= Symbol(
|
||||
ngDevMode ? `id:${globalId++}` : '',
|
||||
) as TrackingKey;
|
||||
}
|
||||
|
||||
const identity = trackingId ?? (key as TrackingKey);
|
||||
|
||||
if (childrenMap?.has(identity)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the logic for the field that we're defining.
|
||||
let childPath: FieldPathNode | undefined;
|
||||
let childLogic: LogicNode;
|
||||
if (isValueArray) {
|
||||
// Fields for array elements have their logic defined by the `element` mechanism.
|
||||
// TODO: other dynamic data
|
||||
childPath = pathNode.getChild(DYNAMIC);
|
||||
childLogic = logic.getChild(DYNAMIC);
|
||||
} else {
|
||||
// Fields for plain properties exist in our logic node's child map.
|
||||
childPath = pathNode.getChild(key);
|
||||
childLogic = logic.getChild(key);
|
||||
}
|
||||
|
||||
childrenMap ??= new Map<TrackingKey, FieldNode>();
|
||||
childrenMap.set(
|
||||
identity,
|
||||
createChildNode({
|
||||
kind: 'child',
|
||||
parent: node as ParentFieldNode,
|
||||
pathNode: childPath,
|
||||
logic: childLogic,
|
||||
initialKeyInParent: key,
|
||||
identityInParent: trackingId,
|
||||
fieldAdapter: adapter,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return childrenMap;
|
||||
},
|
||||
equal: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Gets a human readable name for a field node for use in error messages. */
|
||||
function getDebugName(node: FieldNode) {
|
||||
return `<root>.${node.structure.pathKeys().join('.')}`;
|
||||
}
|
||||
40
packages/forms/signals/src/field/submit.ts
Normal file
40
packages/forms/signals/src/field/submit.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @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 {computed, linkedSignal, Signal, signal, WritableSignal} from '@angular/core';
|
||||
import {ValidationError} from '../api/validation_errors';
|
||||
import type {FieldNode} from './node';
|
||||
|
||||
/**
|
||||
* State of a `FieldNode` that's associated with form submission.
|
||||
*/
|
||||
export class FieldSubmitState {
|
||||
/**
|
||||
* Whether this field was directly submitted (as opposed to indirectly by a parent field being submitted)
|
||||
* and is still in the process of submitting.
|
||||
*/
|
||||
readonly selfSubmitting = signal<boolean>(false);
|
||||
|
||||
/** Server errors that are associated with this field. */
|
||||
readonly serverErrors: WritableSignal<readonly ValidationError[]>;
|
||||
|
||||
constructor(private readonly node: FieldNode) {
|
||||
this.serverErrors = linkedSignal({
|
||||
source: this.node.structure.value,
|
||||
computation: () => [] as readonly ValidationError[],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this form is currently in the process of being submitted.
|
||||
* Either because the field was submitted directly, or because a parent field was submitted.
|
||||
*/
|
||||
readonly submitting: Signal<boolean> = computed(() => {
|
||||
return this.selfSubmitting() || (this.node.structure.parent?.submitting() ?? false);
|
||||
});
|
||||
}
|
||||
60
packages/forms/signals/src/field/util.ts
Normal file
60
packages/forms/signals/src/field/util.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @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 type {FieldNode} from './node';
|
||||
import type {FieldNodeOptions} from './structure';
|
||||
|
||||
/**
|
||||
* Perform a reduction over a field's children (if any) and return the result.
|
||||
*
|
||||
* Optionally, the reduction is short circuited based on the provided `shortCircuit` function.
|
||||
*/
|
||||
export function reduceChildren<T>(
|
||||
node: FieldNode,
|
||||
initialValue: T,
|
||||
fn: (child: FieldNode, value: T) => T,
|
||||
shortCircuit?: (value: T) => boolean,
|
||||
): T {
|
||||
const childrenMap = node.structure.childrenMap();
|
||||
if (!childrenMap) {
|
||||
return initialValue;
|
||||
}
|
||||
let value = initialValue;
|
||||
for (const child of childrenMap.values()) {
|
||||
if (shortCircuit?.(value)) {
|
||||
break;
|
||||
}
|
||||
value = fn(child, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A shortCircuit function for reduceChildren that short-circuits if the value is false. */
|
||||
export function shortCircuitFalse(value: boolean): boolean {
|
||||
return !value;
|
||||
}
|
||||
|
||||
/** A shortCircuit function for reduceChildren that short-circuits if the value is true. */
|
||||
export function shortCircuitTrue(value: boolean): boolean {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Recasts the given value as a new type. */
|
||||
export function cast<T>(value: unknown): asserts value is T {}
|
||||
|
||||
/**
|
||||
* A helper method allowing to get injector regardless of the options type.
|
||||
* @param options
|
||||
*/
|
||||
export function getInjectorFromOptions(options: FieldNodeOptions) {
|
||||
if (options.kind === 'root') {
|
||||
return options.fieldManager.injector;
|
||||
}
|
||||
|
||||
return options.parent.structure.root.structure.injector;
|
||||
}
|
||||
380
packages/forms/signals/src/field/validation.ts
Normal file
380
packages/forms/signals/src/field/validation.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* @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 {computed, Signal} from '@angular/core';
|
||||
import type {Field, Mutable, TreeValidationResult, ValidationResult} from '../api/types';
|
||||
import type {ValidationError, WithOptionalField} from '../api/validation_errors';
|
||||
import {isArray} from '../util/type_guards';
|
||||
import type {FieldNode} from './node';
|
||||
import {reduceChildren, shortCircuitFalse} from './util';
|
||||
|
||||
/**
|
||||
* Helper function taking validation state, and returning own state of the node.
|
||||
* @param state
|
||||
*/
|
||||
export function calculateValidationSelfStatus(
|
||||
state: ValidationState,
|
||||
): 'invalid' | 'unknown' | 'valid' {
|
||||
if (state.errors().length > 0) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (state.pending()) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return 'valid';
|
||||
}
|
||||
|
||||
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[]>;
|
||||
|
||||
/**
|
||||
* The full set of synchronous errors for this field, including synchronous tree errors and server
|
||||
* errors. Server errors are considered "synchronous" because they are imperatively added. From
|
||||
* the perspective of the field state they are either there or not, they are never in a pending
|
||||
* state.
|
||||
*/
|
||||
syncErrors: Signal<ValidationError[]>;
|
||||
|
||||
/**
|
||||
* Whether the field is considered valid according solely to its synchronous validators.
|
||||
* Errors resulting from a previous submit attempt are also considered for this state.
|
||||
*/
|
||||
syncValid: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* The full set of asynchronous tree errors visible to this field. This includes ones that are
|
||||
* 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 | '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 | 'pending')[]>;
|
||||
|
||||
/**
|
||||
* The combined set of all errors that currently apply to this field.
|
||||
*/
|
||||
errors: Signal<ValidationError[]>;
|
||||
|
||||
/**
|
||||
* The combined set of all errors that currently apply to this field and its descendants.
|
||||
*/
|
||||
errorSummary: Signal<ValidationError[]>;
|
||||
|
||||
/**
|
||||
* Whether this field has any asynchronous validators still pending.
|
||||
*/
|
||||
pending: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* The validation status of the field.
|
||||
* - The status is 'valid' if neither the field nor any of its children has any errors or pending
|
||||
* validators.
|
||||
* - The status is 'invalid' if the field or any of its children has an error
|
||||
* (regardless of pending validators)
|
||||
* - The status is 'unknown' if neither the field nor any of its children has any errors,
|
||||
* but the field or any of its children does have a pending validator.
|
||||
*
|
||||
* A field is considered valid if *all* of the following are true:
|
||||
* - It has no errors or pending validators
|
||||
* - All of its children are considered valid
|
||||
* A field is considered invalid if *any* of the following are true:
|
||||
* - It has an error
|
||||
* - Any of its children is considered invalid
|
||||
* A field is considered to have unknown validity status if it is not valid or invalid.
|
||||
*/
|
||||
status: Signal<'valid' | 'invalid' | 'unknown'>;
|
||||
/**
|
||||
* Whether the field is considered valid.
|
||||
*
|
||||
* A field is considered valid if *all* of the following are true:
|
||||
* - It has no errors or pending validators
|
||||
* - All of its children are considered valid
|
||||
*
|
||||
* Note: `!valid()` is *not* the same as `invalid()`. Both `valid()` and `invalid()` can be false
|
||||
* if there are currently no errors, but validators are still pending.
|
||||
*/
|
||||
valid: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* Whether the field is considered invalid.
|
||||
*
|
||||
* A field is considered invalid if *any* of the following are true:
|
||||
* - It has an error
|
||||
* - Any of its children is considered invalid
|
||||
*
|
||||
* Note: `!invalid()` is *not* the same as `valid()`. Both `valid()` and `invalid()` can be false
|
||||
* if there are currently no errors, but validators are still pending.
|
||||
*/
|
||||
invalid: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* Indicates whether validation should be skipped for this field because it is hidden, disabled,
|
||||
* or readonly.
|
||||
*/
|
||||
shouldSkipValidation: Signal<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The validation state associated with a `FieldNode`.
|
||||
*
|
||||
* This class collects together various types of errors to represent the full validation state of
|
||||
* the field. There are 4 types of errors that need to be combined to determine validity:
|
||||
* 1. The synchronous errors produced by the schema logic.
|
||||
* 2. The synchronous tree errors produced by the schema logic. Tree errors may apply to a different
|
||||
* field than the one that the logic that produced them is bound to. They support targeting the
|
||||
* error at an arbitrary descendant field.
|
||||
* 3. The asynchronous tree errors produced by the schema logic. These work like synchronous tree
|
||||
* errors, except the error list may also contain a special sentinel value indicating that a
|
||||
* validator is still running.
|
||||
* 4. Server errors are not produced by the schema logic, but instead get imperatively added when a
|
||||
* form submit fails with errors.
|
||||
*/
|
||||
export class FieldValidationState implements ValidationState {
|
||||
constructor(readonly node: FieldNode) {}
|
||||
|
||||
/**
|
||||
* 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[]> = computed(() => {
|
||||
if (this.shouldSkipValidation()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...this.node.logicNode.logic.syncTreeErrors.compute(this.node.context),
|
||||
...(this.node.structure.parent?.validationState.rawSyncTreeErrors() ?? []),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* The full set of synchronous errors for this field, including synchronous tree errors and server
|
||||
* errors. Server errors are considered "synchronous" because they are imperatively 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[]> = computed(() => {
|
||||
// Short-circuit running validators if validation doesn't apply to this field.
|
||||
if (this.shouldSkipValidation()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...this.node.logicNode.logic.syncErrors.compute(this.node.context),
|
||||
...this.syncTreeErrors(),
|
||||
...normalizeErrors(this.node.submitState.serverErrors()),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the field is considered valid according solely to its synchronous validators.
|
||||
* Errors resulting from a previous submit attempt are also considered for this state.
|
||||
*/
|
||||
readonly syncValid: Signal<boolean> = computed(() => {
|
||||
// Short-circuit checking children if validation doesn't apply to this field.
|
||||
if (this.shouldSkipValidation()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return reduceChildren(
|
||||
this.node,
|
||||
this.syncErrors().length === 0,
|
||||
(child, value) => value && child.validationState.syncValid(),
|
||||
shortCircuitFalse,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The synchronous tree errors visible to this field that are specifically targeted at this field
|
||||
* rather than a descendant.
|
||||
*/
|
||||
readonly syncTreeErrors: Signal<ValidationError[]> = computed(() =>
|
||||
this.rawSyncTreeErrors().filter((err) => err.field === this.node.fieldProxy),
|
||||
);
|
||||
|
||||
/**
|
||||
* The full set of asynchronous tree errors visible to this field. This includes ones that are
|
||||
* 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 | 'pending')[]> = computed(() => {
|
||||
// Short-circuit running validators if validation doesn't apply to this field.
|
||||
if (this.shouldSkipValidation()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
// TODO: add field in `validateAsync` and remove this map
|
||||
...this.node.logicNode.logic.asyncErrors.compute(this.node.context),
|
||||
// TODO: does it make sense to filter this to errors in this subtree?
|
||||
...(this.node.structure.parent?.validationState.rawAsyncErrors() ?? []),
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
readonly asyncErrors: Signal<(ValidationError | 'pending')[]> = computed(() => {
|
||||
if (this.shouldSkipValidation()) {
|
||||
return [];
|
||||
}
|
||||
return this.rawAsyncErrors().filter(
|
||||
(err) => err === 'pending' || err.field === this.node.fieldProxy,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The combined set of all errors that currently apply to this field.
|
||||
*/
|
||||
readonly errors = computed(() => [
|
||||
...this.syncErrors(),
|
||||
...this.asyncErrors().filter((err) => err !== 'pending'),
|
||||
]);
|
||||
|
||||
readonly errorSummary = computed(() =>
|
||||
reduceChildren(this.node, this.errors(), (child, result) => [
|
||||
...result,
|
||||
...child.errorSummary(),
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether this field has any asynchronous validators still pending.
|
||||
*/
|
||||
readonly pending = computed(() =>
|
||||
reduceChildren(
|
||||
this.node,
|
||||
this.asyncErrors().includes('pending'),
|
||||
(child, value) => value || child.validationState.asyncErrors().includes('pending'),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The validation status of the field.
|
||||
* - The status is 'valid' if neither the field nor any of its children has any errors or pending
|
||||
* validators.
|
||||
* - The status is 'invalid' if the field or any of its children has an error
|
||||
* (regardless of pending validators)
|
||||
* - The status is 'unknown' if neither the field nor any of its children has any errors,
|
||||
* but the field or any of its children does have a pending validator.
|
||||
*
|
||||
* A field is considered valid if *all* of the following are true:
|
||||
* - It has no errors or pending validators
|
||||
* - All of its children are considered valid
|
||||
* A field is considered invalid if *any* of the following are true:
|
||||
* - It has an error
|
||||
* - Any of its children is considered invalid
|
||||
* A field is considered to have unknown validity status if it is not valid or invalid.
|
||||
*/
|
||||
readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => {
|
||||
// Short-circuit checking children if validation doesn't apply to this field.
|
||||
if (this.shouldSkipValidation()) {
|
||||
return 'valid';
|
||||
}
|
||||
let ownStatus = calculateValidationSelfStatus(this);
|
||||
|
||||
return reduceChildren<'valid' | 'invalid' | 'unknown'>(
|
||||
this.node,
|
||||
ownStatus,
|
||||
(child, value) => {
|
||||
if (value === 'invalid' || child.validationState.status() === 'invalid') {
|
||||
return 'invalid';
|
||||
} else if (value === 'unknown' || child.validationState.status() === 'unknown') {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'valid';
|
||||
},
|
||||
(v) => v === 'invalid', // short-circuit on 'invalid'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the field is considered valid.
|
||||
*
|
||||
* A field is considered valid if *all* of the following are true:
|
||||
* - It has no errors or pending validators
|
||||
* - All of its children are considered valid
|
||||
*
|
||||
* Note: `!valid()` is *not* the same as `invalid()`. Both `valid()` and `invalid()` can be false
|
||||
* if there are currently no errors, but validators are still pending.
|
||||
*/
|
||||
readonly valid = computed(() => this.status() === 'valid');
|
||||
|
||||
/**
|
||||
* Whether the field is considered invalid.
|
||||
*
|
||||
* A field is considered invalid if *any* of the following are true:
|
||||
* - It has an error
|
||||
* - Any of its children is considered invalid
|
||||
*
|
||||
* Note: `!invalid()` is *not* the same as `valid()`. Both `valid()` and `invalid()` can be false
|
||||
* if there are currently no errors, but validators are still pending.
|
||||
*/
|
||||
readonly invalid = computed(() => this.status() === 'invalid');
|
||||
|
||||
/**
|
||||
* Indicates whether validation should be skipped for this field because it is hidden, disabled,
|
||||
* or readonly.
|
||||
*/
|
||||
readonly shouldSkipValidation = computed(
|
||||
() => this.node.hidden() || this.node.disabled() || this.node.readonly(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalizes a validation result to a list of validation errors. */
|
||||
function normalizeErrors(error: ValidationResult): readonly ValidationError[] {
|
||||
if (error === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isArray(error)) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return [error as ValidationError];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given field on the given error(s) if it does not already have a field.
|
||||
* @param errors The error(s) to add the field to
|
||||
* @param field The default field to add
|
||||
* @returns The passed in error(s), with its field set.
|
||||
*/
|
||||
export function addDefaultField<E extends ValidationError>(
|
||||
error: WithOptionalField<E>,
|
||||
field: Field<unknown>,
|
||||
): E;
|
||||
export function addDefaultField<E extends ValidationError>(
|
||||
errors: TreeValidationResult<E>,
|
||||
field: Field<unknown>,
|
||||
): ValidationResult<E>;
|
||||
export function addDefaultField<E extends ValidationError>(
|
||||
errors: TreeValidationResult<E>,
|
||||
field: Field<unknown>,
|
||||
): ValidationResult<E> {
|
||||
if (isArray(errors)) {
|
||||
for (const error of errors) {
|
||||
(error as Mutable<ValidationError>).field ??= field;
|
||||
}
|
||||
} else if (errors) {
|
||||
(errors as Mutable<ValidationError>).field ??= field;
|
||||
}
|
||||
return errors as ValidationResult<E>;
|
||||
}
|
||||
356
packages/forms/signals/src/schema/logic.ts
Normal file
356
packages/forms/signals/src/schema/logic.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
/**
|
||||
* @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 {untracked} from '@angular/core';
|
||||
import {AggregateProperty, Property} from '../api/property';
|
||||
import {DisabledReason, type FieldContext, type FieldPath, type LogicFn} from '../api/types';
|
||||
import type {ValidationError} from '../api/validation_errors';
|
||||
import type {FieldNode} from '../field/node';
|
||||
import {isArray} from '../util/type_guards';
|
||||
|
||||
/**
|
||||
* Special key which is used to represent a dynamic logic property in a `FieldPathNode` path.
|
||||
* This property is used to represent logic that applies to every element of some dynamic form data
|
||||
* (i.e. an array).
|
||||
*
|
||||
* For example, a rule like `applyEach(p.myArray, () => { ... })` will add logic to the `DYNAMIC`
|
||||
* property of `p.myArray`.
|
||||
*/
|
||||
export const DYNAMIC: unique symbol = Symbol();
|
||||
|
||||
/** Represents a result that should be ignored because its predicate indicates it is not active. */
|
||||
const IGNORED = Symbol();
|
||||
|
||||
/**
|
||||
* A predicate that indicates whether an `AbstractLogic` instance is currently active, or should be
|
||||
* ignored.
|
||||
*/
|
||||
export interface Predicate {
|
||||
/** A boolean logic function that returns true if the logic is considered active. */
|
||||
readonly fn: LogicFn<any, boolean>;
|
||||
/**
|
||||
* The path which this predicate was created for. This is used to determine the correct
|
||||
* `FieldContext` to pass to the predicate function.
|
||||
*/
|
||||
readonly path: FieldPath<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a predicate that is bound to a particular depth in the field tree. This is needed for
|
||||
* recursively applied logic to ensure that the predicate is evaluated against the correct
|
||||
* application of that logic.
|
||||
*
|
||||
* Consider the following example:
|
||||
*
|
||||
* ```
|
||||
* const s = schema(p => {
|
||||
* disabled(p.data);
|
||||
* applyWhen(p.next, ({valueOf}) => valueOf(p.data) === 1, s);
|
||||
* });
|
||||
*
|
||||
* const f = form(signal({data: 0, next: {data: 1, next: {data: 2, next: undefined}}}), s);
|
||||
*
|
||||
* const isDisabled = f.next.next.data().disabled();
|
||||
* ```
|
||||
*
|
||||
* In order to determine `isDisabled` we need to evaluate the predicate from `applyWhen` *twice*.
|
||||
* Once to see if the schema should be applied to `f.next` and again to see if it should be applied
|
||||
* to `f.next.next`. The `depth` tells us which field we should be evaluating against each time.
|
||||
*/
|
||||
export interface BoundPredicate extends Predicate {
|
||||
/** The depth in the field tree at which this predicate is bound. */
|
||||
readonly depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all logic. It is responsible for combining the results from multiple individual
|
||||
* logic functions registered in the schema, and using them to derive the value for some associated
|
||||
* piece of field state.
|
||||
*/
|
||||
export abstract class AbstractLogic<TReturn, TValue = TReturn> {
|
||||
/** The set of logic functions that contribute to the value of the associated state. */
|
||||
protected readonly fns: Array<LogicFn<any, TValue | typeof IGNORED>> = [];
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* A list of predicates that conditionally enable all logic in this logic instance.
|
||||
* The logic is only enabled when *all* of the predicates evaluate to true.
|
||||
*/
|
||||
private predicates: ReadonlyArray<BoundPredicate>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Computes the value of the associated field state based on the logic functions and predicates
|
||||
* registered with this logic instance.
|
||||
*/
|
||||
abstract compute(arg: FieldContext<any>): TReturn;
|
||||
|
||||
/**
|
||||
* The default value that the associated field state should assume if there are no logic functions
|
||||
* registered by the schema (or if the logic is disabled by a predicate).
|
||||
*/
|
||||
abstract get defaultValue(): TReturn;
|
||||
|
||||
/** Registers a logic function with this logic instance. */
|
||||
push(logicFn: LogicFn<any, TValue>) {
|
||||
this.fns.push(wrapWithPredicates(this.predicates, logicFn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges in the logic from another logic instance, subject to the predicates of both the other
|
||||
* instance and this instance.
|
||||
*/
|
||||
mergeIn(other: AbstractLogic<TReturn, TValue>) {
|
||||
const fns = this.predicates
|
||||
? other.fns.map((fn) => wrapWithPredicates(this.predicates, fn))
|
||||
: other.fns;
|
||||
this.fns.push(...fns);
|
||||
}
|
||||
}
|
||||
|
||||
/** Logic that combines its individual logic function results with logical OR. */
|
||||
export class BooleanOrLogic extends AbstractLogic<boolean> {
|
||||
override get defaultValue() {
|
||||
return false;
|
||||
}
|
||||
|
||||
override compute(arg: FieldContext<any>): boolean {
|
||||
return this.fns.some((f) => {
|
||||
const result = f(arg);
|
||||
return result && result !== IGNORED;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic that combines its individual logic function results by aggregating them in an array.
|
||||
* Depending on its `ignore` function it may ignore certain values, omitting them from the array.
|
||||
*/
|
||||
export class ArrayMergeIgnoreLogic<TElement, TIgnore = never> extends AbstractLogic<
|
||||
readonly TElement[],
|
||||
TElement | readonly (TElement | TIgnore)[] | TIgnore | undefined | void
|
||||
> {
|
||||
/** Creates an instance of this class that ignores `null` values. */
|
||||
static ignoreNull<TElement>(predicates: ReadonlyArray<BoundPredicate>) {
|
||||
return new ArrayMergeIgnoreLogic<TElement, null>(predicates, (e: unknown) => e === null);
|
||||
}
|
||||
|
||||
constructor(
|
||||
predicates: ReadonlyArray<BoundPredicate>,
|
||||
private ignore: undefined | ((e: TElement | undefined | TIgnore) => e is TIgnore),
|
||||
) {
|
||||
super(predicates);
|
||||
}
|
||||
|
||||
override get defaultValue() {
|
||||
return [];
|
||||
}
|
||||
|
||||
override compute(arg: FieldContext<any>): readonly TElement[] {
|
||||
return this.fns.reduce((prev, f) => {
|
||||
const value = f(arg);
|
||||
|
||||
if (value === undefined || value === IGNORED) {
|
||||
return prev;
|
||||
} else if (isArray(value)) {
|
||||
return [...prev, ...(this.ignore ? value.filter((e) => !this.ignore!(e)) : value)];
|
||||
} else {
|
||||
if (this.ignore && this.ignore(value as TElement | TIgnore | undefined)) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, value];
|
||||
}
|
||||
}, [] as TElement[]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Logic that combines its individual logic function results by aggregating them in an array. */
|
||||
export class ArrayMergeLogic<TElement> extends ArrayMergeIgnoreLogic<TElement, never> {
|
||||
constructor(predicates: ReadonlyArray<BoundPredicate>) {
|
||||
super(predicates, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/** Logic that combines an AggregateProperty according to the property's own reduce function. */
|
||||
export class AggregatePropertyMergeLogic<TAcc, TItem> extends AbstractLogic<TAcc, TItem> {
|
||||
override get defaultValue() {
|
||||
return this.key.getInitial();
|
||||
}
|
||||
|
||||
constructor(
|
||||
predicates: ReadonlyArray<BoundPredicate>,
|
||||
private key: AggregateProperty<TAcc, TItem>,
|
||||
) {
|
||||
super(predicates);
|
||||
}
|
||||
|
||||
override compute(ctx: FieldContext<any>): TAcc {
|
||||
if (this.fns.length === 0) {
|
||||
return this.key.getInitial();
|
||||
}
|
||||
let acc: TAcc = this.key.getInitial();
|
||||
for (let i = 0; i < this.fns.length; i++) {
|
||||
const item = this.fns[i](ctx);
|
||||
if (item !== IGNORED) {
|
||||
acc = this.key.reduce(acc, item);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a logic function such that it returns the special `IGNORED` sentinel value if any of the
|
||||
* given predicates evaluates to false.
|
||||
*
|
||||
* @param predicates A list of bound predicates to apply to the logic function
|
||||
* @param logicFn The logic function to wrap
|
||||
* @returns A wrapped version of the logic function that may return `IGNORED`.
|
||||
*/
|
||||
function wrapWithPredicates<TValue, TReturn>(
|
||||
predicates: ReadonlyArray<BoundPredicate>,
|
||||
logicFn: LogicFn<TValue, TReturn>,
|
||||
): LogicFn<TValue, TReturn | typeof IGNORED> {
|
||||
if (predicates.length === 0) {
|
||||
return logicFn;
|
||||
}
|
||||
return (arg: FieldContext<any>): TReturn | typeof IGNORED => {
|
||||
for (const predicate of predicates) {
|
||||
let predicateField = arg.stateOf(predicate.path) as FieldNode;
|
||||
// Check the depth of the current field vs the depth this predicate is supposed to be
|
||||
// evaluated at. If necessary, walk up the field tree to grab the correct context field.
|
||||
// We can check the pathKeys as an untracked read since we know the structure of our fields
|
||||
// doesn't change.
|
||||
const depthDiff = untracked(predicateField.structure.pathKeys).length - predicate.depth;
|
||||
for (let i = 0; i < depthDiff; i++) {
|
||||
predicateField = predicateField.structure.parent!;
|
||||
}
|
||||
// If any of the predicates don't match, don't actually run the logic function, just return
|
||||
// the default value.
|
||||
if (!predicate.fn(predicateField.context)) {
|
||||
return IGNORED;
|
||||
}
|
||||
}
|
||||
return logicFn(arg);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for all the different types of logic that can be applied to a field
|
||||
* (disabled, hidden, errors, etc.)
|
||||
*/
|
||||
|
||||
export class LogicContainer {
|
||||
/** Logic that determines if the field is hidden. */
|
||||
readonly hidden: BooleanOrLogic;
|
||||
/** Logic that determines reasons for the field being disabled. */
|
||||
readonly disabledReasons: ArrayMergeLogic<DisabledReason>;
|
||||
/** 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, null>;
|
||||
/** Logic that produces synchronous validation errors for the field's subtree. */
|
||||
readonly syncTreeErrors: ArrayMergeIgnoreLogic<ValidationError, null>;
|
||||
/** Logic that produces asynchronous validation results (errors or 'pending'). */
|
||||
readonly asyncErrors: ArrayMergeIgnoreLogic<ValidationError | 'pending', null>;
|
||||
/** A map of aggregate properties to the `AbstractLogic` instances that compute their values. */
|
||||
private readonly aggregateProperties = new Map<
|
||||
AggregateProperty<unknown, unknown>,
|
||||
AbstractLogic<unknown>
|
||||
>();
|
||||
/** A map of property keys to the factory functions that create their values. */
|
||||
private readonly propertyFactories = new Map<
|
||||
Property<unknown>,
|
||||
(ctx: FieldContext<unknown>) => unknown
|
||||
>();
|
||||
|
||||
/**
|
||||
* Constructs a new `Logic` container.
|
||||
* @param predicates An array of predicates that must all be true for the logic
|
||||
* functions within this container to be active.
|
||||
*/
|
||||
constructor(private predicates: ReadonlyArray<BoundPredicate>) {
|
||||
this.hidden = new BooleanOrLogic(predicates);
|
||||
this.disabledReasons = new ArrayMergeLogic(predicates);
|
||||
this.readonly = new BooleanOrLogic(predicates);
|
||||
this.syncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
|
||||
this.syncTreeErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError>(predicates);
|
||||
this.asyncErrors = ArrayMergeIgnoreLogic.ignoreNull<ValidationError | 'pending'>(predicates);
|
||||
}
|
||||
|
||||
/** Checks whether there is logic for the given aggregate property. */
|
||||
hasAggregateProperty(prop: AggregateProperty<unknown, unknown>) {
|
||||
return this.aggregateProperties.has(prop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an iterable of [aggregate property, logic function] pairs.
|
||||
* @returns An iterable of aggregate property entries.
|
||||
*/
|
||||
getAggregatePropertyEntries() {
|
||||
return this.aggregateProperties.entries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an iterable of [property, value factory function] pairs.
|
||||
* @returns An iterable of property factory entries.
|
||||
*/
|
||||
getPropertyFactoryEntries() {
|
||||
return this.propertyFactories.entries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves or creates the `AbstractLogic` for a given aggregate property.
|
||||
* @param prop The `AggregateProperty` for which to get the logic.
|
||||
* @returns The `AbstractLogic` associated with the key.
|
||||
*/
|
||||
getAggregateProperty<T>(prop: AggregateProperty<unknown, T>): AbstractLogic<T> {
|
||||
if (!this.aggregateProperties.has(prop as AggregateProperty<unknown, unknown>)) {
|
||||
this.aggregateProperties.set(
|
||||
prop as AggregateProperty<unknown, unknown>,
|
||||
new AggregatePropertyMergeLogic(this.predicates, prop),
|
||||
);
|
||||
}
|
||||
return this.aggregateProperties.get(
|
||||
prop as AggregateProperty<unknown, unknown>,
|
||||
)! as AbstractLogic<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a factory function for a given property.
|
||||
* @param prop The `Property` to associate the factory with.
|
||||
* @param factory The factory function.
|
||||
* @throws If a factory is already defined for the given key.
|
||||
*/
|
||||
addPropertyFactory(prop: Property<unknown>, factory: (ctx: FieldContext<unknown>) => unknown) {
|
||||
if (this.propertyFactories.has(prop)) {
|
||||
// TODO: name of the property?
|
||||
throw new Error(`Can't define value twice for the same Property`);
|
||||
}
|
||||
this.propertyFactories.set(prop, factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges logic from another `Logic` instance into this one.
|
||||
* @param other The `Logic` instance to merge from.
|
||||
*/
|
||||
mergeIn(other: LogicContainer) {
|
||||
this.hidden.mergeIn(other.hidden);
|
||||
this.disabledReasons.mergeIn(other.disabledReasons);
|
||||
this.readonly.mergeIn(other.readonly);
|
||||
this.syncErrors.mergeIn(other.syncErrors);
|
||||
this.syncTreeErrors.mergeIn(other.syncTreeErrors);
|
||||
this.asyncErrors.mergeIn(other.asyncErrors);
|
||||
for (const [prop, propertyLogic] of other.getAggregatePropertyEntries()) {
|
||||
this.getAggregateProperty(prop).mergeIn(propertyLogic);
|
||||
}
|
||||
for (const [prop, propertyFactory] of other.getPropertyFactoryEntries()) {
|
||||
this.addPropertyFactory(prop, propertyFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
470
packages/forms/signals/src/schema/logic_node.ts
Normal file
470
packages/forms/signals/src/schema/logic_node.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
/**
|
||||
* @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 {AggregateProperty, Property} from '../api/property';
|
||||
import type {
|
||||
AsyncValidationResult,
|
||||
DisabledReason,
|
||||
FieldContext,
|
||||
LogicFn,
|
||||
ValidationResult,
|
||||
} from '../api/types';
|
||||
import {setBoundPathDepthForResolution} from '../field/resolution';
|
||||
import {BoundPredicate, LogicContainer, Predicate} from './logic';
|
||||
|
||||
/**
|
||||
* Abstract base class for building a `LogicNode`.
|
||||
* This class defines the interface for adding various logic rules (e.g., hidden, disabled)
|
||||
* and data factories to a node in the logic tree.
|
||||
* LogicNodeBuilders are 1:1 with nodes in the Schema tree.
|
||||
*/
|
||||
export abstract class AbstractLogicNodeBuilder {
|
||||
constructor(
|
||||
/** The depth of this node in the schema tree. */
|
||||
protected readonly depth: number,
|
||||
) {}
|
||||
|
||||
/** Adds a rule to determine if a field should be hidden. */
|
||||
abstract addHiddenRule(logic: LogicFn<any, boolean>): void;
|
||||
/** Adds a rule to determine if a field should be disabled, and for what reason. */
|
||||
abstract addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void;
|
||||
/** Adds a rule to determine if a field should be read-only. */
|
||||
abstract addReadonlyRule(logic: LogicFn<any, boolean>): void;
|
||||
/** Adds a rule for synchronous validation errors for a field. */
|
||||
abstract addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void;
|
||||
/** Adds a rule for synchronous validation errors that apply to a subtree. */
|
||||
abstract addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void;
|
||||
/** Adds a rule for asynchronous validation errors for a field. */
|
||||
abstract addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void;
|
||||
/** Adds a rule to compute an aggregate property for a field. */
|
||||
abstract addAggregatePropertyRule<M>(
|
||||
key: AggregateProperty<unknown, M>,
|
||||
logic: LogicFn<any, M>,
|
||||
): void;
|
||||
/** Adds a factory function to produce a data value associated with a field. */
|
||||
abstract addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void;
|
||||
/**
|
||||
* Gets a builder for a child node associated with the given property key.
|
||||
* @param key The property key of the child.
|
||||
* @returns A `LogicNodeBuilder` for the child.
|
||||
*/
|
||||
abstract getChild(key: PropertyKey): LogicNodeBuilder;
|
||||
|
||||
/**
|
||||
* Checks whether a particular `AbstractLogicNodeBuilder` has been merged into this one.
|
||||
* @param builder The builder to check for.
|
||||
* @returns True if the builder has been merged, false otherwise.
|
||||
*/
|
||||
abstract hasLogic(builder: AbstractLogicNodeBuilder): boolean;
|
||||
|
||||
/**
|
||||
* Builds the `LogicNode` from the accumulated rules and child builders.
|
||||
* @returns The constructed `LogicNode`.
|
||||
*/
|
||||
build(): LogicNode {
|
||||
return new LeafLogicNode(this, [], 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A builder for `LogicNode`. Used to add logic to the final `LogicNode` tree.
|
||||
* This builder supports merging multiple sources of logic, potentially with predicates,
|
||||
* preserving the order of rule application.
|
||||
*/
|
||||
export class LogicNodeBuilder extends AbstractLogicNodeBuilder {
|
||||
constructor(depth: number) {
|
||||
super(depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current `NonMergeableLogicNodeBuilder` being used to add rules directly to this
|
||||
* `LogicNodeBuilder`. Do not use this directly, call `getCurrent()` which will create a current
|
||||
* builder if there is none.
|
||||
*/
|
||||
private current: NonMergeableLogicNodeBuilder | undefined;
|
||||
/**
|
||||
* Stores all builders that contribute to this node, along with any predicates
|
||||
* that gate their application.
|
||||
*/
|
||||
readonly all: {builder: AbstractLogicNodeBuilder; predicate?: Predicate}[] = [];
|
||||
|
||||
override addHiddenRule(logic: LogicFn<any, boolean>): void {
|
||||
this.getCurrent().addHiddenRule(logic);
|
||||
}
|
||||
|
||||
override addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void {
|
||||
this.getCurrent().addDisabledReasonRule(logic);
|
||||
}
|
||||
|
||||
override addReadonlyRule(logic: LogicFn<any, boolean>): void {
|
||||
this.getCurrent().addReadonlyRule(logic);
|
||||
}
|
||||
|
||||
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
|
||||
this.getCurrent().addSyncErrorRule(logic);
|
||||
}
|
||||
|
||||
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
|
||||
this.getCurrent().addSyncTreeErrorRule(logic);
|
||||
}
|
||||
|
||||
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
|
||||
this.getCurrent().addAsyncErrorRule(logic);
|
||||
}
|
||||
|
||||
override addAggregatePropertyRule<T>(
|
||||
key: AggregateProperty<unknown, T>,
|
||||
logic: LogicFn<any, T>,
|
||||
): void {
|
||||
this.getCurrent().addAggregatePropertyRule(key, logic);
|
||||
}
|
||||
|
||||
override addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void {
|
||||
this.getCurrent().addPropertyFactory(key, factory);
|
||||
}
|
||||
|
||||
override getChild(key: PropertyKey): LogicNodeBuilder {
|
||||
return this.getCurrent().getChild(key);
|
||||
}
|
||||
|
||||
override hasLogic(builder: AbstractLogicNodeBuilder): boolean {
|
||||
if (this === builder) {
|
||||
return true;
|
||||
}
|
||||
return this.all.some(({builder: subBuilder}) => subBuilder.hasLogic(builder));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges logic from another `LogicNodeBuilder` into this one.
|
||||
* If a `predicate` is provided, all logic from the `other` builder will only apply
|
||||
* when the predicate evaluates to true.
|
||||
* @param other The `LogicNodeBuilder` to merge in.
|
||||
* @param predicate An optional predicate to gate the merged logic.
|
||||
*/
|
||||
mergeIn(other: LogicNodeBuilder, predicate?: Predicate): void {
|
||||
// Add the other builder to our collection, we'll defer the actual merging of the logic until
|
||||
// the logic node is requested to be created. In order to preserve the original ordering of the
|
||||
// rules, we close off the current builder to any further edits. If additional logic is added,
|
||||
// a new current builder will be created to capture it.
|
||||
if (predicate) {
|
||||
this.all.push({
|
||||
builder: other,
|
||||
predicate: {
|
||||
fn: setBoundPathDepthForResolution(predicate.fn, this.depth),
|
||||
path: predicate.path,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.all.push({builder: other});
|
||||
}
|
||||
this.current = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current `NonMergeableLogicNodeBuilder` for adding rules directly to this
|
||||
* `LogicNodeBuilder`. If no current builder exists, a new one is created.
|
||||
* The current builder is cleared whenever `mergeIn` is called to preserve the order
|
||||
* of rules when merging separate builder trees.
|
||||
* @returns The current `NonMergeableLogicNodeBuilder`.
|
||||
*/
|
||||
private getCurrent(): NonMergeableLogicNodeBuilder {
|
||||
if (this.current === undefined) {
|
||||
this.current = new NonMergeableLogicNodeBuilder(this.depth);
|
||||
this.all.push({builder: this.current});
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new root `LogicNodeBuilder`.
|
||||
* @returns A new instance of `LogicNodeBuilder`.
|
||||
*/
|
||||
static newRoot(): LogicNodeBuilder {
|
||||
return new LogicNodeBuilder(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A type of `AbstractLogicNodeBuilder` used internally by the `LogicNodeBuilder` to record "pure"
|
||||
* chunks of logic that do not require merging in other builders.
|
||||
*/
|
||||
class NonMergeableLogicNodeBuilder extends AbstractLogicNodeBuilder {
|
||||
/** The collection of logic rules directly added to this builder. */
|
||||
readonly logic = new LogicContainer([]);
|
||||
/**
|
||||
* A map of child property keys to their corresponding `LogicNodeBuilder` instances.
|
||||
* This allows for building a tree of logic.
|
||||
*/
|
||||
readonly children = new Map<PropertyKey, LogicNodeBuilder>();
|
||||
|
||||
constructor(depth: number) {
|
||||
super(depth);
|
||||
}
|
||||
|
||||
override addHiddenRule(logic: LogicFn<any, boolean>): void {
|
||||
this.logic.hidden.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addDisabledReasonRule(logic: LogicFn<any, DisabledReason | undefined>): void {
|
||||
this.logic.disabledReasons.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addReadonlyRule(logic: LogicFn<any, boolean>): void {
|
||||
this.logic.readonly.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addSyncErrorRule(logic: LogicFn<any, ValidationResult>): void {
|
||||
this.logic.syncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addSyncTreeErrorRule(logic: LogicFn<any, ValidationResult>): void {
|
||||
this.logic.syncTreeErrors.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addAsyncErrorRule(logic: LogicFn<any, AsyncValidationResult>): void {
|
||||
this.logic.asyncErrors.push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addAggregatePropertyRule<T>(
|
||||
key: AggregateProperty<unknown, T>,
|
||||
logic: LogicFn<any, T>,
|
||||
): void {
|
||||
this.logic.getAggregateProperty(key).push(setBoundPathDepthForResolution(logic, this.depth));
|
||||
}
|
||||
|
||||
override addPropertyFactory<D>(key: Property<D>, factory: (ctx: FieldContext<any>) => D): void {
|
||||
this.logic.addPropertyFactory(key, setBoundPathDepthForResolution(factory, this.depth));
|
||||
}
|
||||
|
||||
override getChild(key: PropertyKey): LogicNodeBuilder {
|
||||
if (!this.children.has(key)) {
|
||||
this.children.set(key, new LogicNodeBuilder(this.depth + 1));
|
||||
}
|
||||
return this.children.get(key)!;
|
||||
}
|
||||
|
||||
override hasLogic(builder: AbstractLogicNodeBuilder): boolean {
|
||||
return this === builder;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a node in the logic tree, containing all logic applicable
|
||||
* to a specific field or path in the form structure.
|
||||
* LogicNodes are 1:1 with nodes in the Field tree.
|
||||
*/
|
||||
export interface LogicNode {
|
||||
/** The collection of logic rules (hidden, disabled, errors, etc.) for this node. */
|
||||
readonly logic: LogicContainer;
|
||||
|
||||
/**
|
||||
* Retrieves the `LogicNode` for a child identified by the given property key.
|
||||
* @param key The property key of the child.
|
||||
* @returns The `LogicNode` for the specified child.
|
||||
*/
|
||||
getChild(key: PropertyKey): LogicNode;
|
||||
|
||||
/**
|
||||
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
|
||||
* node.
|
||||
* @param builder The builder to check for.
|
||||
* @returns True if the builder has been merged, false otherwise.
|
||||
*/
|
||||
hasLogic(builder: AbstractLogicNodeBuilder): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tree structure of `Logic` corresponding to a tree of fields.
|
||||
* This implementation represents a leaf in the sense that its logic is derived
|
||||
* from a single builder.
|
||||
*/
|
||||
class LeafLogicNode implements LogicNode {
|
||||
/** The computed logic for this node. */
|
||||
readonly logic: LogicContainer;
|
||||
|
||||
/**
|
||||
* Constructs a `LeafLogicNode`.
|
||||
* @param builder The `AbstractLogicNodeBuilder` from which to derive the logic.
|
||||
* If undefined, an empty `Logic` instance is created.
|
||||
* @param predicates An array of predicates that gate the logic from the builder.
|
||||
*/
|
||||
constructor(
|
||||
private builder: AbstractLogicNodeBuilder | undefined,
|
||||
private predicates: BoundPredicate[],
|
||||
/** The depth of this node in the field tree. */
|
||||
private depth: number,
|
||||
) {
|
||||
this.logic = builder ? createLogic(builder, predicates, depth) : new LogicContainer([]);
|
||||
}
|
||||
|
||||
// TODO: cache here, or just rely on the user of this API to do caching?
|
||||
/**
|
||||
* Retrieves the `LogicNode` for a child identified by the given property key.
|
||||
* @param key The property key of the child.
|
||||
* @returns The `LogicNode` for the specified child.
|
||||
*/
|
||||
getChild(key: PropertyKey): LogicNode {
|
||||
// The logic for a particular child may be spread across multiple builders. We lazily combine
|
||||
// this logic at the time the child logic node is requested to be created.
|
||||
const childBuilders = this.builder ? getAllChildBuilders(this.builder, key) : [];
|
||||
if (childBuilders.length === 0) {
|
||||
return new LeafLogicNode(undefined, [], this.depth + 1);
|
||||
} else if (childBuilders.length === 1) {
|
||||
const {builder, predicates} = childBuilders[0];
|
||||
return new LeafLogicNode(
|
||||
builder,
|
||||
[...this.predicates, ...predicates.map((p) => bindLevel(p, this.depth))],
|
||||
this.depth + 1,
|
||||
);
|
||||
} else {
|
||||
const builtNodes = childBuilders.map(
|
||||
({builder, predicates}) =>
|
||||
new LeafLogicNode(
|
||||
builder,
|
||||
[...this.predicates, ...predicates.map((p) => bindLevel(p, this.depth))],
|
||||
this.depth + 1,
|
||||
),
|
||||
);
|
||||
return new CompositeLogicNode(builtNodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
|
||||
* node.
|
||||
* @param builder The builder to check for.
|
||||
* @returns True if the builder has been merged, false otherwise.
|
||||
*/
|
||||
hasLogic(builder: AbstractLogicNodeBuilder): boolean {
|
||||
return this.builder?.hasLogic(builder) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `LogicNode` that represents the composition of multiple `LogicNode` instances.
|
||||
* This is used when logic for a particular path is contributed by several distinct
|
||||
* builder branches that need to be merged.
|
||||
*/
|
||||
class CompositeLogicNode implements LogicNode {
|
||||
/** The merged logic from all composed nodes. */
|
||||
readonly logic: LogicContainer;
|
||||
|
||||
/**
|
||||
* Constructs a `CompositeLogicNode`.
|
||||
* @param all An array of `LogicNode` instances to compose.
|
||||
*/
|
||||
constructor(private all: LogicNode[]) {
|
||||
this.logic = new LogicContainer([]);
|
||||
for (const node of all) {
|
||||
this.logic.mergeIn(node.logic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the child `LogicNode` by composing the results of `getChild` from all
|
||||
* underlying `LogicNode` instances.
|
||||
* @param key The property key of the child.
|
||||
* @returns A `CompositeLogicNode` representing the composed child.
|
||||
*/
|
||||
getChild(key: PropertyKey): LogicNode {
|
||||
return new CompositeLogicNode(this.all.flatMap((child) => child.getChild(key)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the logic from a particular `AbstractLogicNodeBuilder` has been merged into this
|
||||
* node.
|
||||
* @param builder The builder to check for.
|
||||
* @returns True if the builder has been merged, false otherwise.
|
||||
*/
|
||||
hasLogic(builder: AbstractLogicNodeBuilder): boolean {
|
||||
return this.all.some((node) => node.hasLogic(builder));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all of the builders that contribute logic to the given child of the parent builder.
|
||||
* This function recursively traverses the builder hierarchy.
|
||||
* @param builder The parent `AbstractLogicNodeBuilder`.
|
||||
* @param key The property key of the child.
|
||||
* @returns An array of objects, each containing a `LogicNodeBuilder` for the child and any associated predicates.
|
||||
*/
|
||||
function getAllChildBuilders(
|
||||
builder: AbstractLogicNodeBuilder,
|
||||
key: PropertyKey,
|
||||
): {builder: LogicNodeBuilder; predicates: Predicate[]}[] {
|
||||
if (builder instanceof LogicNodeBuilder) {
|
||||
return builder.all.flatMap(({builder, predicate}) => {
|
||||
const children = getAllChildBuilders(builder, key);
|
||||
if (predicate) {
|
||||
return children.map(({builder, predicates}) => ({
|
||||
builder,
|
||||
predicates: [...predicates, predicate],
|
||||
}));
|
||||
}
|
||||
return children;
|
||||
});
|
||||
} else if (builder instanceof NonMergeableLogicNodeBuilder) {
|
||||
if (builder.children.has(key)) {
|
||||
return [{builder: builder.children.get(key)!, predicates: []}];
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown LogicNodeBuilder type');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the full `Logic` for a given builder.
|
||||
* This function handles different types of builders (`LogicNodeBuilder`, `NonMergeableLogicNodeBuilder`)
|
||||
* and applies the provided predicates.
|
||||
* @param builder The `AbstractLogicNodeBuilder` to process.
|
||||
* @param predicates Predicates to apply to the logic derived from the builder.
|
||||
* @param depth The depth in the field tree of the field which this logic applies to.
|
||||
* @returns The `Logic` instance.
|
||||
*/
|
||||
function createLogic(
|
||||
builder: AbstractLogicNodeBuilder,
|
||||
predicates: BoundPredicate[],
|
||||
depth: number,
|
||||
): LogicContainer {
|
||||
const logic = new LogicContainer(predicates);
|
||||
if (builder instanceof LogicNodeBuilder) {
|
||||
const builtNodes = builder.all.map(
|
||||
({builder, predicate}) =>
|
||||
new LeafLogicNode(
|
||||
builder,
|
||||
predicate ? [...predicates, bindLevel(predicate, depth)] : predicates,
|
||||
depth,
|
||||
),
|
||||
);
|
||||
for (const node of builtNodes) {
|
||||
logic.mergeIn(node.logic);
|
||||
}
|
||||
} else if (builder instanceof NonMergeableLogicNodeBuilder) {
|
||||
logic.mergeIn(builder.logic);
|
||||
} else {
|
||||
throw new Error('Unknown LogicNodeBuilder type');
|
||||
}
|
||||
return logic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a bound version of the given predicate to a specific depth in the field tree.
|
||||
* This allows us to unambiguously know which `FieldContext` the predicate function should receive.
|
||||
*
|
||||
* This is of particular concern when a schema is applied recursively to itself. Since the schema is
|
||||
* only compiled once, each nested application adds the same predicate instance. We differentiate
|
||||
* these by recording the depth of the field they're bound to.
|
||||
*
|
||||
* @param predicate The unbound predicate
|
||||
* @param depth The depth of the field the predicate is bound to
|
||||
* @returns A bound predicate
|
||||
*/
|
||||
function bindLevel(predicate: Predicate, depth: number): BoundPredicate {
|
||||
return {...predicate, depth: depth};
|
||||
}
|
||||
101
packages/forms/signals/src/schema/path_node.ts
Normal file
101
packages/forms/signals/src/schema/path_node.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* @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 {FieldPath} from '../api/types';
|
||||
import {DYNAMIC, Predicate} from './logic';
|
||||
import {LogicNodeBuilder} from './logic_node';
|
||||
import type {SchemaImpl} from './schema';
|
||||
|
||||
/**
|
||||
* Special key which is used to retrieve the `FieldPathNode` instance from its `FieldPath` proxy wrapper.
|
||||
*/
|
||||
const PATH = Symbol('PATH');
|
||||
|
||||
/**
|
||||
* A path in the schema on which logic is stored so that it can be added to the corresponding field
|
||||
* when the field is created.
|
||||
*/
|
||||
export class FieldPathNode {
|
||||
/** The root path node from which this path node is descended. */
|
||||
readonly root: FieldPathNode;
|
||||
|
||||
/**
|
||||
* A map containing all child path nodes that have been created on this path.
|
||||
* Child path nodes are created automatically on first access if they do not exist already.
|
||||
*/
|
||||
private readonly children = new Map<PropertyKey, FieldPathNode>();
|
||||
|
||||
/**
|
||||
* A proxy that wraps the path node, allowing navigation to its child paths via property access.
|
||||
*/
|
||||
readonly fieldPathProxy: FieldPath<any> = new Proxy(
|
||||
this,
|
||||
FIELD_PATH_PROXY_HANDLER,
|
||||
) as unknown as FieldPath<any>;
|
||||
|
||||
protected constructor(
|
||||
/** The property keys used to navigate from the root path to this path. */
|
||||
readonly keys: PropertyKey[],
|
||||
/** The logic builder used to accumulate logic on this path node. */
|
||||
readonly logic: LogicNodeBuilder,
|
||||
root: FieldPathNode,
|
||||
) {
|
||||
this.root = root ?? this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the special path node containing the per-element logic that applies to *all* children paths.
|
||||
*/
|
||||
get element(): FieldPathNode {
|
||||
return this.getChild(DYNAMIC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path node for the given child property key.
|
||||
* Child paths are created automatically on first access if they do not exist already.
|
||||
*/
|
||||
getChild(key: PropertyKey): FieldPathNode {
|
||||
if (!this.children.has(key)) {
|
||||
this.children.set(
|
||||
key,
|
||||
new FieldPathNode([...this.keys, key], this.logic.getChild(key), this.root),
|
||||
);
|
||||
}
|
||||
return this.children.get(key)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges in logic from another schema to this one.
|
||||
* @param other The other schema to merge in the logic from
|
||||
* @param predicate A predicate indicating when the merged in logic should be active.
|
||||
*/
|
||||
mergeIn(other: SchemaImpl, predicate?: Predicate) {
|
||||
const path = other.compile();
|
||||
this.logic.mergeIn(path.logic, predicate);
|
||||
}
|
||||
|
||||
/** Extracts the underlying path node from the given path proxy. */
|
||||
static unwrapFieldPath(formPath: FieldPath<unknown>): FieldPathNode {
|
||||
return (formPath as any)[PATH] as FieldPathNode;
|
||||
}
|
||||
|
||||
/** Creates a new root path node to be passed in to a schema function. */
|
||||
static newRoot() {
|
||||
return new FieldPathNode([], LogicNodeBuilder.newRoot(), undefined!);
|
||||
}
|
||||
}
|
||||
|
||||
/** Proxy handler which implements `FieldPath` on top of a `FieldPathNode`. */
|
||||
export const FIELD_PATH_PROXY_HANDLER: ProxyHandler<FieldPathNode> = {
|
||||
get(node: FieldPathNode, property: string | symbol) {
|
||||
if (property === PATH) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return node.getChild(property).fieldPathProxy;
|
||||
},
|
||||
};
|
||||
113
packages/forms/signals/src/schema/schema.ts
Normal file
113
packages/forms/signals/src/schema/schema.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* @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 {FieldPath, SchemaFn, SchemaOrSchemaFn} from '../api/types';
|
||||
import {FieldPathNode} from './path_node';
|
||||
|
||||
/**
|
||||
* Keeps track of the path node for the schema function that is currently being compiled. This is
|
||||
* used to detect erroneous references to a path node outside of the context of its schema function.
|
||||
* Do not set this directly, it is a context variable managed by `SchemaImpl.compile`.
|
||||
*/
|
||||
let currentCompilingNode: FieldPathNode | undefined = undefined;
|
||||
|
||||
/**
|
||||
* A cache of all schemas compiled under the current root compilation. This is used to avoid doing
|
||||
* extra work when compiling a schema that reuses references to the same sub-schema. For example:
|
||||
*
|
||||
* ```
|
||||
* const sub = schema(p => ...);
|
||||
* const s = schema(p => {
|
||||
* apply(p.a, sub);
|
||||
* apply(p.b, sub);
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* This also ensures that we don't go into an infinite loop when compiling a schema that references
|
||||
* itself.
|
||||
*
|
||||
* Do not directly add or remove entries from this map, it is a context variable managed by
|
||||
* `SchemaImpl.compile` and `SchemaImpl.rootCompile`.
|
||||
*/
|
||||
const compiledSchemas = new Map<SchemaImpl, FieldPathNode>();
|
||||
|
||||
/**
|
||||
* Implements the `Schema` concept.
|
||||
*/
|
||||
export class SchemaImpl {
|
||||
constructor(private schemaFn: SchemaFn<unknown>) {}
|
||||
|
||||
/**
|
||||
* Compiles this schema within the current root compilation context. If the schema was previously
|
||||
* compiled within this context, we reuse the cached FieldPathNode, otherwise we create a new one
|
||||
* and cache it in the compilation context.
|
||||
*/
|
||||
compile(): FieldPathNode {
|
||||
if (compiledSchemas.has(this)) {
|
||||
return compiledSchemas.get(this)!;
|
||||
}
|
||||
const path = FieldPathNode.newRoot();
|
||||
compiledSchemas.set(this, path);
|
||||
let prevCompilingNode = currentCompilingNode;
|
||||
try {
|
||||
currentCompilingNode = path;
|
||||
this.schemaFn(path.fieldPathProxy);
|
||||
} finally {
|
||||
// Use a try/finally to ensure we restore the previous root upon completion,
|
||||
// even if there are errors while compiling the schema.
|
||||
currentCompilingNode = prevCompilingNode;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a SchemaImpl from the given SchemaOrSchemaFn.
|
||||
*/
|
||||
static create(schema: SchemaImpl | SchemaOrSchemaFn<any>) {
|
||||
if (schema instanceof SchemaImpl) {
|
||||
return schema;
|
||||
}
|
||||
return new SchemaImpl(schema as SchemaFn<unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles the given schema in a fresh compilation context. This clears the cached results of any
|
||||
* previous compilations.
|
||||
*/
|
||||
static rootCompile(schema: SchemaImpl | SchemaOrSchemaFn<any> | undefined): FieldPathNode {
|
||||
try {
|
||||
compiledSchemas.clear();
|
||||
if (schema === undefined) {
|
||||
return FieldPathNode.newRoot();
|
||||
}
|
||||
if (schema instanceof SchemaImpl) {
|
||||
return schema.compile();
|
||||
}
|
||||
return new SchemaImpl(schema as SchemaFn<unknown>).compile();
|
||||
} finally {
|
||||
// Use a try/finally to ensure we properly reset the compilation context upon completion,
|
||||
// even if there are errors while compiling the schema.
|
||||
compiledSchemas.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if the given value is a schema or schema function. */
|
||||
export function isSchemaOrSchemaFn(value: unknown): value is SchemaOrSchemaFn<unknown> {
|
||||
return value instanceof SchemaImpl || typeof value === 'function';
|
||||
}
|
||||
|
||||
/** Checks that a path node belongs to the schema function currently being compiled. */
|
||||
export function assertPathIsCurrent(path: FieldPath<unknown>): void {
|
||||
if (currentCompilingNode !== FieldPathNode.unwrapFieldPath(path).root) {
|
||||
throw new Error(
|
||||
`A FieldPath can only be used directly within the Schema that owns it,` +
|
||||
` **not** outside of it or within a sub-schema.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
56
packages/forms/signals/src/util/deep_signal.ts
Normal file
56
packages/forms/signals/src/util/deep_signal.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @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 {computed, Signal, untracked, WritableSignal} from '@angular/core';
|
||||
import {SIGNAL} from '@angular/core/primitives/signals';
|
||||
import {isArray} from './type_guards';
|
||||
|
||||
/**
|
||||
* Creates a writable signal for a specific property on a source writeable signal.
|
||||
* @param source A writeable signal to derive from
|
||||
* @param prop A signal of a property key of the source value
|
||||
* @returns A writeable signal for the given property of the source value.
|
||||
* @template S The source value type
|
||||
* @template K The key type for S
|
||||
*/
|
||||
export function deepSignal<S, K extends keyof S>(
|
||||
source: WritableSignal<S>,
|
||||
prop: Signal<K>,
|
||||
): WritableSignal<S[K]> {
|
||||
// Memoize the property.
|
||||
const read = computed(() => source()[prop()]) as WritableSignal<S[K]>;
|
||||
|
||||
read[SIGNAL] = source[SIGNAL];
|
||||
read.set = (value: S[K]) => {
|
||||
source.update((current) => valueForWrite(current, value, prop()) as S);
|
||||
};
|
||||
|
||||
read.update = (fn: (current: S[K]) => S[K]) => {
|
||||
read.set(fn(untracked(read)));
|
||||
};
|
||||
read.asReadonly = () => read;
|
||||
|
||||
return read;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an updated root value to use when setting a value on a deepSignal with the given path.
|
||||
* @param sourceValue The current value of the deepSignal's source.
|
||||
* @param newPropValue The value being written to the deepSignal's property
|
||||
* @param prop The deepSignal's property key
|
||||
* @returns An updated value for the deepSignal's source
|
||||
*/
|
||||
function valueForWrite(sourceValue: unknown, newPropValue: unknown, prop: PropertyKey): unknown {
|
||||
if (isArray(sourceValue)) {
|
||||
const newValue = [...sourceValue];
|
||||
newValue[prop as number] = newPropValue;
|
||||
return newValue;
|
||||
} else {
|
||||
return {...(sourceValue as object), [prop]: newPropValue};
|
||||
}
|
||||
}
|
||||
75
packages/forms/signals/src/util/private.ts
Normal file
75
packages/forms/signals/src/util/private.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @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 {EffectRef, Injector, InputSignal, ModelSignal, ɵSIGNAL as SIGNAL} from '@angular/core';
|
||||
import {isObject} from './type_guards';
|
||||
|
||||
// TODO: These utilities to be replaced with proper integration into framework.
|
||||
|
||||
export function privateGetComponentInstance(injector: Injector): unknown {
|
||||
assertIsNodeInjector(injector);
|
||||
if (injector._tNode.directiveStart === 0 || injector._tNode.componentOffset === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return injector._lView[injector._tNode.directiveStart + injector._tNode.componentOffset];
|
||||
}
|
||||
|
||||
export function privateSetComponentInput<T>(inputSignal: InputSignal<T>, value: T): void {
|
||||
inputSignal[SIGNAL].applyValueToInputSignal(inputSignal[SIGNAL], value);
|
||||
}
|
||||
|
||||
export function privateIsSignalInput(value: unknown): value is InputSignal<unknown> {
|
||||
return isInputSignal(value);
|
||||
}
|
||||
|
||||
export function privateIsModelInput<T>(value: unknown): value is ModelSignal<T> {
|
||||
return isInputSignal(value) && isObject(value) && 'subscribe' in value;
|
||||
}
|
||||
|
||||
export function privateRunEffect(ref: EffectRef): void {
|
||||
(ref as EffectRefImpl)[SIGNAL].run();
|
||||
}
|
||||
|
||||
function assertIsNodeInjector(injector: Injector): asserts injector is NgNodeInjector {
|
||||
if (!('_tNode' in injector)) {
|
||||
throw new Error('Expected a Node Injector');
|
||||
}
|
||||
}
|
||||
|
||||
function isInputSignal(value: unknown): value is NgInputSignal {
|
||||
if (!isObject(value) || !(SIGNAL in value)) {
|
||||
return false;
|
||||
}
|
||||
const node = value[SIGNAL];
|
||||
return isObject(node) && 'applyValueToInputSignal' in node;
|
||||
}
|
||||
|
||||
interface NgNodeInjector extends Injector {
|
||||
_tNode: TNode;
|
||||
_lView: Array<unknown>;
|
||||
}
|
||||
|
||||
interface TNode {
|
||||
directiveStart: number;
|
||||
componentOffset: number;
|
||||
}
|
||||
|
||||
interface NgInputSignal {
|
||||
[SIGNAL]: NgInputSignalNode;
|
||||
}
|
||||
|
||||
interface NgInputSignalNode {
|
||||
applyValueToInputSignal(node: NgInputSignalNode, value: unknown): void;
|
||||
}
|
||||
|
||||
interface EffectRefImpl extends EffectRef {
|
||||
readonly [SIGNAL]: {
|
||||
run(): void;
|
||||
};
|
||||
}
|
||||
21
packages/forms/signals/src/util/type_guards.ts
Normal file
21
packages/forms/signals/src/util/type_guards.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* A version of `Array.isArray` that handles narrowing of readonly arrays properly.
|
||||
*/
|
||||
export function isArray(value: unknown): value is any[] | readonly any[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value is an object.
|
||||
*/
|
||||
export function isObject(value: unknown): value is Record<PropertyKey, unknown> {
|
||||
return (typeof value === 'object' || typeof value === 'function') && value != null;
|
||||
}
|
||||
26
packages/forms/signals/test/node/BUILD.bazel
Normal file
26
packages/forms/signals/test/node/BUILD.bazel
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
load("//tools:defaults.bzl", "ts_project", "zoneless_jasmine_test")
|
||||
|
||||
ts_project(
|
||||
name = "test_lib",
|
||||
testonly = True,
|
||||
srcs = glob(["**/*.spec.ts"]),
|
||||
deps = [
|
||||
"//:node_modules/zod",
|
||||
"//packages/common/http",
|
||||
"//packages/common/http/testing",
|
||||
"//packages/core",
|
||||
"//packages/core/testing",
|
||||
"//packages/forms",
|
||||
"//packages/forms/signals",
|
||||
"//packages/platform-browser",
|
||||
"//packages/platform-browser/testing",
|
||||
"//packages/private/testing",
|
||||
],
|
||||
)
|
||||
|
||||
zoneless_jasmine_test(
|
||||
name = "test",
|
||||
data = [
|
||||
":test_lib",
|
||||
],
|
||||
)
|
||||
118
packages/forms/signals/test/node/api/hidden.spec.ts
Normal file
118
packages/forms/signals/test/node/api/hidden.spec.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {form, hidden, validate, customError} from '@angular/forms/signals';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
|
||||
describe('hidden', () => {
|
||||
it('should initially be false', () => {
|
||||
const cat = signal({name: 'Pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
hidden(p, ({value}) => {
|
||||
return value.name === 'hidden-cat';
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().hidden()).toBe(false);
|
||||
expect(f.name().hidden()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when condition is met', () => {
|
||||
const cat = signal({name: 'Pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
hidden(p.name, ({value}) => {
|
||||
return value() === 'hidden-cat';
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.name().value.set('hidden-cat');
|
||||
expect(f.name().hidden()).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates the value down', () => {
|
||||
const cat = signal({name: 'Pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
hidden(p, ({value}) => {
|
||||
return value().name === 'hidden-cat';
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.name().value.set('hidden-cat');
|
||||
expect(f.name().hidden()).toBe(true);
|
||||
expect(f().hidden()).toBe(true);
|
||||
});
|
||||
|
||||
it('disables validation for the field', () => {
|
||||
const cat = signal({name: 'Pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
hidden(p.name, ({value}) => {
|
||||
return value() === 'hidden-cat';
|
||||
});
|
||||
|
||||
validate(p.name, () => {
|
||||
return customError({kind: 'dog'});
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().valid()).withContext('Name is intially invalid').toBeFalse();
|
||||
expect(f().valid()).withContext('Form is intially invalid').toBeFalse();
|
||||
|
||||
f.name().value.set('hidden-cat');
|
||||
expect(f.name().hidden()).toBeTrue();
|
||||
expect(f.name().valid()).toBeTrue();
|
||||
expect(f().valid()).toBeTrue();
|
||||
|
||||
f.name().value.set('visible-cat');
|
||||
expect(f.name().valid()).toBeFalse();
|
||||
expect(f().valid()).toBeFalse();
|
||||
});
|
||||
|
||||
xit('disables touch state propagation?', () => {
|
||||
const cat = signal({name: 'Pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
hidden(p.name, ({value}) => {
|
||||
return value() === 'hidden-cat';
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().touched()).withContext('Name is intially untouched').toBeFalse();
|
||||
expect(f().touched()).withContext('Form is intially intouched').toBeFalse();
|
||||
|
||||
f.name().markAsTouched();
|
||||
expect(f.name().touched()).toBeTrue();
|
||||
expect(f().touched()).toBeTrue();
|
||||
|
||||
f.name().value.set('hidden-cat');
|
||||
|
||||
expect(f.name().touched()).withContext('hidden name is not touched').toBeFalse();
|
||||
expect(f().touched())
|
||||
.withContext('form with a hidden touched field is not touched')
|
||||
.toBeFalse();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {email, form} from '../../../../public_api';
|
||||
import {customError, emailError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('email validator', () => {
|
||||
it('returns requiredTrue error when the value is false', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', email: 'cat@cat.meow'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
email(p.email);
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.email().errors()).toEqual([]);
|
||||
f.email().value.set('not-real-email');
|
||||
expect(f.email().errors()).toEqual([emailError({field: f.email})]);
|
||||
});
|
||||
|
||||
it('supports custom errors', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', email: ''});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
email(p.email, {
|
||||
error: (ctx) => customError({kind: `special-email-${ctx.valueOf(p.name)}`}),
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.email().errors()).toEqual([
|
||||
customError({
|
||||
kind: 'special-email-pirojok-the-cat',
|
||||
field: f.email,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports custom error message', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', email: ''});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
email(p.email, {
|
||||
message: 'email error',
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.email().errors()).toEqual([
|
||||
emailError({
|
||||
message: 'email error',
|
||||
field: f.email,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
262
packages/forms/signals/test/node/api/validators/max.spec.ts
Normal file
262
packages/forms/signals/test/node/api/validators/max.spec.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {MAX, form, max} from '../../../../public_api';
|
||||
import {customError, maxError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('max validator', () => {
|
||||
it('returns max error when the value is larger', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
});
|
||||
|
||||
it('is inclusive', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no errors when the value is smaller', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns custom errors when provided', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({kind: 'special-max', message: value().toString()});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([
|
||||
customError({
|
||||
kind: 'special-max',
|
||||
message: '6',
|
||||
field: f.age,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports custom error messgaes', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5, {
|
||||
message: 'max error',
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([
|
||||
maxError(5, {
|
||||
message: 'max error',
|
||||
field: f.age,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats NaN as no maximum', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, NaN);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('custom properties', () => {
|
||||
it('stores the MAX property on max', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({kind: 'special-max', message: value().toString()});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().property(MAX)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two maxes preferring the smaller option', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 12});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, 10);
|
||||
max(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.age().value.set(12);
|
||||
expect(f.age().errors()).toEqual([maxError(10, {field: f.age}), maxError(5, {field: f.age})]);
|
||||
f.age().value.set(7);
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
f.age().value.set(3);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
|
||||
expect(f.age().property(MAX)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two maxes _dynamically_ preferring the smaller option', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 12});
|
||||
const maxSignal = signal(10);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, maxSignal);
|
||||
max(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.age().value.set(12);
|
||||
expect(f.age().errors()).toEqual([maxError(10, {field: f.age}), maxError(5, {field: f.age})]);
|
||||
f.age().value.set(7);
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
f.age().value.set(3);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
|
||||
expect(f.age().property(MAX)()).toBe(5);
|
||||
|
||||
maxSignal.set(2);
|
||||
f.age().value.set(3);
|
||||
expect(f.age().errors()).toEqual([maxError(2, {field: f.age})]);
|
||||
expect(f.age().property(MAX)()).toBe(2);
|
||||
});
|
||||
|
||||
it('merges two maxes _dynamically_ ignores undefined', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 20}); // Age is higher than both maxes
|
||||
const maxSignal = signal<number | undefined>(10);
|
||||
const maxSignal2 = signal<number | undefined>(15);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, maxSignal);
|
||||
max(p.age, maxSignal2);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
// Initially, age 20 is greater than both 10 and 15
|
||||
expect(f.age().errors()).toEqual([
|
||||
maxError(10, {field: f.age}),
|
||||
maxError(15, {field: f.age}),
|
||||
]);
|
||||
|
||||
// Set the first max threshold to undefined
|
||||
maxSignal.set(undefined);
|
||||
// Now, age 20 is only greater than 15
|
||||
expect(f.age().errors()).toEqual([maxError(15, {field: f.age})]);
|
||||
|
||||
// Set the second max threshold to undefined
|
||||
maxSignal2.set(undefined);
|
||||
// No max constraints are active
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic values', () => {
|
||||
it('handles dynamic value', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const maxValue = signal(5);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, maxValue);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
maxValue.set(7);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('disables validation on undefined', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
const maxValue = signal<number | undefined>(5);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, maxValue);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
maxValue.set(undefined);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
maxValue.set(5);
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
});
|
||||
|
||||
it('handles dynamic value based on other field', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 6});
|
||||
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
max(p.age, ({valueOf}) => {
|
||||
return valueOf(p.name) === 'pirojok-the-cat' ? 5 : 10;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([maxError(5, {field: f.age})]);
|
||||
|
||||
f.name().value.set('other cat');
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {MAX_LENGTH, form, maxLength} from '../../../../public_api';
|
||||
import {customError, maxLengthError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('maxLength validator', () => {
|
||||
it('returns maxLength error when the length is larger for strings', () => {
|
||||
const data = signal({text: 'abcde'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 3);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([maxLengthError(3, {field: f.text})]);
|
||||
});
|
||||
|
||||
it('returns maxLength error when the length is larger for arrays', () => {
|
||||
const data = signal({list: [1, 2, 3, 4, 5]});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.list, 3);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.list().errors()).toEqual([maxLengthError(3, {field: f.list})]);
|
||||
});
|
||||
|
||||
it('is inclusive (no error if length equals maxLength)', () => {
|
||||
const data = signal({text: 'abcd'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 4);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no errors when the length is smaller', () => {
|
||||
const data = signal({text: 'abc'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns custom errors when provided', () => {
|
||||
const data = signal({text: 'abcdef'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({
|
||||
kind: 'special-maxLength',
|
||||
message: `Length is ${value().length}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([
|
||||
customError({
|
||||
kind: 'special-maxLength',
|
||||
message: 'Length is 6',
|
||||
field: f.text,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports custom error message', () => {
|
||||
const data = signal({text: 'abcdef'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 5, {
|
||||
message: ({value}) => `${value()} is an error!`,
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([
|
||||
maxLengthError(5, {
|
||||
message: 'abcdef is an error!',
|
||||
field: f.text,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('works with sets', () => {
|
||||
const data = signal(new Set([1, 2, 3, 4]));
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p, 3);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().errors()).toEqual([maxLengthError(3, {field: f})]);
|
||||
});
|
||||
|
||||
describe('custom properties', () => {
|
||||
it('stores the MAX_LENGTH property on maxLength', () => {
|
||||
const data = signal({text: 'abcdef'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({
|
||||
kind: 'special-maxLength',
|
||||
message: `Length is ${value().length}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().property(MAX_LENGTH)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two maxLengths preferring the smaller option', () => {
|
||||
const data = signal({text: 'abcdefghijklmno'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, 10);
|
||||
maxLength(p.text, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.text().value.set('abcdefghijklmno');
|
||||
expect(f.text().errors()).toEqual([
|
||||
maxLengthError(10, {field: f.text}),
|
||||
maxLengthError(5, {field: f.text}),
|
||||
]);
|
||||
|
||||
f.text().value.set('abcdefg');
|
||||
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
|
||||
|
||||
f.text().value.set('abc');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
|
||||
expect(f.text().property(MAX_LENGTH)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two maxLengths _dynamically_ preferring the smaller option', () => {
|
||||
const data = signal({text: 'abcdefghijklmno'});
|
||||
const maxLengthSignal = signal(10);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, maxLengthSignal);
|
||||
maxLength(p.text, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.text().value.set('abcdefghijklmno');
|
||||
expect(f.text().errors()).toEqual([
|
||||
maxLengthError(10, {field: f.text}),
|
||||
maxLengthError(5, {field: f.text}),
|
||||
]);
|
||||
|
||||
f.text().value.set('abcdefg');
|
||||
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
|
||||
|
||||
f.text().value.set('abc');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
|
||||
maxLengthSignal.set(2);
|
||||
|
||||
expect(f.text().errors()).toEqual([maxLengthError(2, {field: f.text})]);
|
||||
expect(f.text().property(MAX_LENGTH)()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic values', () => {
|
||||
it('handles dynamic maxLength value', () => {
|
||||
const data = signal({text: 'abcdef'});
|
||||
const dynamicMaxLength = signal(5);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, dynamicMaxLength);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
|
||||
dynamicMaxLength.set(7);
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
it('disables validation on undefined value', () => {
|
||||
const data = signal({text: 'abcdef'});
|
||||
const dynamicMaxLength = signal<number | undefined>(5);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, dynamicMaxLength);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([maxLengthError(5, {field: f.text})]);
|
||||
dynamicMaxLength.set(undefined);
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles dynamic maxLength value based on other field', () => {
|
||||
const data = signal({text: 'longtextvalue', category: 'A'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
maxLength(p.text, ({valueOf}) => {
|
||||
return valueOf(p.category) === 'A' ? 8 : 15;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([maxLengthError(8, {field: f.text})]);
|
||||
|
||||
f.category().value.set('B');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
249
packages/forms/signals/test/node/api/validators/min.spec.ts
Normal file
249
packages/forms/signals/test/node/api/validators/min.spec.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {MIN, form, min} from '../../../../public_api';
|
||||
import {customError, minError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('min validator', () => {
|
||||
it('returns min error when the value is smaller', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
|
||||
});
|
||||
|
||||
it('is inclusive', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 4);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no errors when the value is larger', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 10});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns custom errors when provided', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({kind: 'special-min', message: value().toString()});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([
|
||||
customError({
|
||||
kind: 'special-min',
|
||||
message: '3',
|
||||
field: f.age,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports custom error messages', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5, {
|
||||
message: 'min error!!',
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([
|
||||
minError(5, {
|
||||
message: 'min error!!',
|
||||
field: f.age,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats NaN as no minimum', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, NaN);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('custom properties', () => {
|
||||
it('stores the MIN property on min', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({kind: 'special-min', message: value().toString()});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().property(MIN)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two mins preferring the larger option', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, 5);
|
||||
min(p.age, 10);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.age().value.set(3);
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age}), minError(10, {field: f.age})]);
|
||||
f.age().value.set(7);
|
||||
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
|
||||
f.age().value.set(15);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
|
||||
expect(f.age().property(MIN)()).toBe(10);
|
||||
});
|
||||
|
||||
it('merges two mins _dynamically_ preferring the larger option', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const minSignal = signal(5);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, minSignal);
|
||||
min(p.age, 10);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.age().value.set(3);
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age}), minError(10, {field: f.age})]);
|
||||
f.age().value.set(7);
|
||||
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
|
||||
f.age().value.set(15);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
minSignal.set(30);
|
||||
expect(f.age().errors()).toEqual([minError(30, {field: f.age})]);
|
||||
expect(f.age().property(MIN)()).toBe(30);
|
||||
});
|
||||
|
||||
it('merges two mins _dynamically_ ignores undefined', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 3});
|
||||
const minSignal = signal<number | undefined>(15);
|
||||
const minSignal2 = signal<number | undefined>(10);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, minSignal);
|
||||
min(p.age, minSignal2);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([
|
||||
minError(15, {field: f.age}),
|
||||
minError(10, {field: f.age}),
|
||||
]);
|
||||
minSignal.set(undefined);
|
||||
expect(f.age().errors()).toEqual([minError(10, {field: f.age})]);
|
||||
minSignal2.set(undefined);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic values', () => {
|
||||
it('disables validation on undefined', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const minValue = signal<number | undefined>(5);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, minValue);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
|
||||
minValue.set(undefined);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
minValue.set(5);
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
|
||||
});
|
||||
|
||||
it('handles dynamic value', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
const minValue = signal(5);
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, minValue);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
|
||||
minValue.set(2);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles dynamic value based on other field', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 4});
|
||||
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
min(p.age, ({valueOf}) => {
|
||||
return valueOf(p.name) === 'pirojok-the-cat' ? 5 : 0;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.age().errors()).toEqual([minError(5, {field: f.age})]);
|
||||
f.name().value.set('other cat');
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {MIN_LENGTH, form, minLength} from '../../../../public_api';
|
||||
import {customError, minLengthError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('minLength validator', () => {
|
||||
it('returns minLength error when the length is smaller for strings', () => {
|
||||
const data = signal({text: 'abc'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
|
||||
});
|
||||
|
||||
it('returns minLength error when the length is smaller for arrays', () => {
|
||||
const data = signal({list: [1, 2, 3]});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.list, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.list().errors()).toEqual([minLengthError(5, {field: f.list})]);
|
||||
});
|
||||
|
||||
it('is inclusive (no error if length equals minLength)', () => {
|
||||
const data = signal({text: 'abcd'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 4);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no errors when the length is larger', () => {
|
||||
const data = signal({text: 'abcdefghij'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns custom errors when provided', () => {
|
||||
const data = signal({text: 'ab'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({
|
||||
kind: 'special-minLength',
|
||||
message: `Length is ${value().length}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([
|
||||
customError({
|
||||
kind: 'special-minLength',
|
||||
message: 'Length is 2',
|
||||
field: f.text,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports custom error messages', () => {
|
||||
const data = signal({text: 'ab'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5, {
|
||||
message: ({value}) => `${value()} is error!`,
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([
|
||||
minLengthError(5, {
|
||||
message: 'ab is error!',
|
||||
field: f.text,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('works with sets', () => {
|
||||
const data = signal(new Set([1, 2, 3, 4]));
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p, 5);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().errors()).toEqual([minLengthError(5, {field: f})]);
|
||||
});
|
||||
|
||||
describe('custom properties', () => {
|
||||
it('stores the MIN_LENGTH property on minLength', () => {
|
||||
const data = signal({text: 'ab'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5, {
|
||||
error: ({value}) => {
|
||||
return customError({
|
||||
kind: 'special-minLength',
|
||||
message: `Length is ${value().length}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().property(MIN_LENGTH)()).toBe(5);
|
||||
});
|
||||
|
||||
it('merges two minLengths preferring the larger option', () => {
|
||||
const data = signal({text: 'ab'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, 5);
|
||||
minLength(p.text, 10);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.text().value.set('ab');
|
||||
expect(f.text().errors()).toEqual([
|
||||
minLengthError(5, {field: f.text}),
|
||||
minLengthError(10, {field: f.text}),
|
||||
]);
|
||||
|
||||
f.text().value.set('abcdefg');
|
||||
expect(f.text().errors()).toEqual([minLengthError(10, {field: f.text})]);
|
||||
|
||||
f.text().value.set('abcdefghijklmno');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
|
||||
expect(f.text().property(MIN_LENGTH)()).toBe(10);
|
||||
});
|
||||
|
||||
it('merges two minLengths _dynamically_ preferring the larger option', () => {
|
||||
const data = signal({text: 'ab'});
|
||||
const minLengthSignal = signal(5);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, minLengthSignal);
|
||||
minLength(p.text, 10);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f.text().value.set('ab');
|
||||
expect(f.text().errors()).toEqual([
|
||||
minLengthError(5, {field: f.text}),
|
||||
minLengthError(10, {field: f.text}),
|
||||
]);
|
||||
|
||||
f.text().value.set('abcdefg');
|
||||
expect(f.text().errors()).toEqual([minLengthError(10, {field: f.text})]);
|
||||
|
||||
f.text().value.set('abcdefghijklmno');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
|
||||
minLengthSignal.set(20);
|
||||
|
||||
expect(f.text().errors()).toEqual([minLengthError(20, {field: f.text})]);
|
||||
expect(f.text().property(MIN_LENGTH)()).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic values', () => {
|
||||
it('handles dynamic minLength value', () => {
|
||||
const data = signal({text: 'abcd'});
|
||||
const dynamicMinLength = signal(5);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, dynamicMinLength);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
|
||||
dynamicMinLength.set(3);
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('disables validation on undefined value', () => {
|
||||
const data = signal({text: 'abcd'});
|
||||
const dynamicMinLength = signal<number | undefined>(5);
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, dynamicMinLength);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([minLengthError(5, {field: f.text})]);
|
||||
dynamicMinLength.set(undefined);
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles dynamic minLength value based on other field', () => {
|
||||
const data = signal({text: 'short', category: 'A'});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
minLength(p.text, ({valueOf}) => {
|
||||
return valueOf(p.category) === 'A' ? 8 : 3;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.text().errors()).toEqual([minLengthError(8, {field: f.text})]);
|
||||
|
||||
f.category().value.set('B');
|
||||
expect(f.text().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
121
packages/forms/signals/test/node/api/validators/pattern.spec.ts
Normal file
121
packages/forms/signals/test/node/api/validators/pattern.spec.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {PATTERN, form, pattern} from '../../../../public_api';
|
||||
import {customError, patternError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('pattern validator', () => {
|
||||
it('validates whether a value matches the pattern', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, /pir.*jok/);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([patternError(/pir.*jok/, {field: f.name})]);
|
||||
});
|
||||
|
||||
it('supports custom error', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, /pir.*jok/, {error: customError()});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([customError({field: f.name})]);
|
||||
});
|
||||
|
||||
it('supports custom error message', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, /pir.*jok/, {message: 'pattern error'});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([
|
||||
patternError(/pir.*jok/, {message: 'pattern error', field: f.name}),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('custom properties', () => {
|
||||
it('sets the PATTERN property', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, /pir.*jok/);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/]);
|
||||
});
|
||||
|
||||
it('merges the PATTERN property in an array', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, /pir.*jok/);
|
||||
pattern(p.name, /pelmeni/);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().property(PATTERN)()).toEqual([/pir.*jok/, /pelmeni/]);
|
||||
});
|
||||
|
||||
it('PATTERN property defaults to empty list', () => {
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, () => undefined);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f.name().property(PATTERN)()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dynamic values', () => {
|
||||
it('updates validation result as the pattern changes', () => {
|
||||
const patternSignal = signal<RegExp | undefined>(/pir.*jok/);
|
||||
const cat = signal({name: 'pelmeni-the-cat'});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
pattern(p.name, () => patternSignal());
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([patternError(/pir.*jok/, {field: f.name})]);
|
||||
|
||||
patternSignal.set(/p.*/);
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
patternSignal.set(/meow/);
|
||||
expect(f.name().errors()).toEqual([patternError(/meow/, {field: f.name})]);
|
||||
|
||||
patternSignal.set(undefined);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
111
packages/forms/signals/test/node/api/validators/required.spec.ts
Normal file
111
packages/forms/signals/test/node/api/validators/required.spec.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {form, required} from '../../../../public_api';
|
||||
import {customError, requiredError} from '../../../../src/api/validation_errors';
|
||||
|
||||
describe('required validator', () => {
|
||||
it('returns required Error when the value is not present', () => {
|
||||
const cat = signal({name: ''});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
required(p.name);
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
|
||||
f.name().value.set('pirojok-the-cat');
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports custom errors', () => {
|
||||
const cat = signal({name: '', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
required(p.name, {
|
||||
error: (ctx) => customError({kind: `required-${ctx.valueOf(p.age)}`}),
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([customError({kind: 'required-5', field: f.name})]);
|
||||
f.name().value.set('pirojok-the-cat');
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports custom error messages', () => {
|
||||
const cat = signal({name: '', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
required(p.name, {
|
||||
message: 'required error',
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([requiredError({message: 'required error', field: f.name})]);
|
||||
f.name().value.set('pirojok-the-cat');
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('supports custom emptyPredicate', () => {
|
||||
const cat = signal({name: ''});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
required(p.name, {
|
||||
emptyPredicate(value) {
|
||||
return value === 'empty';
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
f.name().value.set('empty');
|
||||
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
|
||||
});
|
||||
|
||||
it('supports custom condition', () => {
|
||||
const cat = signal({name: '', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
required(p.name, {
|
||||
when({valueOf}) {
|
||||
return valueOf(p.age) > 10;
|
||||
},
|
||||
});
|
||||
},
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
f.age().value.set(15);
|
||||
expect(f.name().errors()).toEqual([requiredError({field: f.name})]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* @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, Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import * as z from 'zod';
|
||||
import {form, schema} from '../../../../public_api';
|
||||
import {validateStandardSchema} from '../../../../src/api/validators/standard_schema';
|
||||
|
||||
interface Flight {
|
||||
id: number;
|
||||
from: string;
|
||||
to: string;
|
||||
date: string;
|
||||
delayed: boolean;
|
||||
delay: number;
|
||||
delayReason: string;
|
||||
}
|
||||
|
||||
interface Trip {
|
||||
departure: Flight;
|
||||
return: Flight;
|
||||
}
|
||||
|
||||
describe('standard schema integration', () => {
|
||||
it('should perform sync validation using a standard schema', async () => {
|
||||
const injector = TestBed.inject(Injector);
|
||||
|
||||
const zodName = z.object({
|
||||
first: z.string().min(2),
|
||||
last: z.string().min(3),
|
||||
});
|
||||
|
||||
const nameForm = form(
|
||||
signal({first: '', last: ''}),
|
||||
(p) => {
|
||||
validateStandardSchema(p, zodName);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(nameForm.first().errors()).toEqual([
|
||||
jasmine.objectContaining({
|
||||
kind: 'standardSchema',
|
||||
issue: jasmine.objectContaining({
|
||||
message: 'Too small: expected string to have >=2 characters',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(nameForm.last().errors()).toEqual([
|
||||
jasmine.objectContaining({
|
||||
kind: 'standardSchema',
|
||||
issue: jasmine.objectContaining({
|
||||
message: 'Too small: expected string to have >=3 characters',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should perform async validation using a standard schema', async () => {
|
||||
const injector = TestBed.inject(Injector);
|
||||
|
||||
const zodNameAsync = z
|
||||
.object({
|
||||
first: z.string().min(2),
|
||||
last: z.string().min(3),
|
||||
})
|
||||
.refine(() => Promise.resolve());
|
||||
|
||||
const nameForm = form(
|
||||
signal({first: '', last: ''}),
|
||||
(p) => {
|
||||
validateStandardSchema(p, zodNameAsync);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(nameForm.first().errors()).toEqual([]);
|
||||
expect(nameForm.last().errors()).toEqual([]);
|
||||
|
||||
await TestBed.inject(ApplicationRef).whenStable();
|
||||
|
||||
expect(nameForm.first().errors()).toEqual([
|
||||
jasmine.objectContaining({
|
||||
kind: 'standardSchema',
|
||||
issue: jasmine.objectContaining({
|
||||
message: 'Too small: expected string to have >=2 characters',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(nameForm.last().errors()).toEqual([
|
||||
jasmine.objectContaining({
|
||||
kind: 'standardSchema',
|
||||
issue: jasmine.objectContaining({
|
||||
message: 'Too small: expected string to have >=3 characters',
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support a partial schema', () => {
|
||||
const zodFlight = z.object({
|
||||
from: z.string().min(3),
|
||||
to: z.string().min(3),
|
||||
});
|
||||
|
||||
const s = schema<Flight>((p) => {
|
||||
validateStandardSchema(p, zodFlight);
|
||||
});
|
||||
|
||||
// Just expect schema to be defined, really just interested in testing the typing.
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support zod looseObject', () => {
|
||||
const zodFlight = z.looseObject({
|
||||
from: z.string().min(3),
|
||||
to: z.string().min(3),
|
||||
});
|
||||
|
||||
const s = schema<Flight>((p) => {
|
||||
validateStandardSchema(p, zodFlight);
|
||||
});
|
||||
|
||||
// Just expect schema to be defined, really just interested in testing the typing.
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support zod looseObject on child', () => {
|
||||
const zodFlight = z.looseObject({
|
||||
from: z.string().min(3),
|
||||
to: z.string().min(3),
|
||||
});
|
||||
|
||||
const zodTrip = z.object({
|
||||
departure: zodFlight,
|
||||
return: zodFlight,
|
||||
});
|
||||
|
||||
const s = schema<Trip>((p) => {
|
||||
validateStandardSchema(p, zodTrip);
|
||||
});
|
||||
|
||||
// Just expect schema to be defined, really just interested in testing the typing.
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it('should type error on incompatible zod schema', () => {
|
||||
const zodFlight = z.looseObject({
|
||||
from: z.string().min(3),
|
||||
to: z.string().min(3),
|
||||
invalid: z.string(),
|
||||
});
|
||||
|
||||
const s = schema<Flight>((p) => {
|
||||
// @ts-expect-error
|
||||
validateStandardSchema(p, zodFlight);
|
||||
});
|
||||
|
||||
// Just expect schema to be defined, really just interested in testing the typing.
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not allow unknown path', () => {
|
||||
const zodName = z.object({
|
||||
first: z.string().min(2),
|
||||
last: z.string().min(3),
|
||||
});
|
||||
|
||||
const s = schema((p) => {
|
||||
//@ts-expect-error
|
||||
validateStandardSchema(p, zodName);
|
||||
});
|
||||
|
||||
// Just expect schema to be defined, really just interested in testing the typing.
|
||||
expect(s).toBeDefined();
|
||||
});
|
||||
});
|
||||
224
packages/forms/signals/test/node/api/when.spec.ts
Normal file
224
packages/forms/signals/test/node/api/when.spec.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* @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 {Injector, Signal, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
SchemaOrSchemaFn,
|
||||
applyEach,
|
||||
applyWhen,
|
||||
applyWhenValue,
|
||||
form,
|
||||
validate,
|
||||
} from '../../../public_api';
|
||||
import {customError, requiredError} from '../../../src/api/validation_errors';
|
||||
|
||||
export interface User {
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
const needsLastNamePredicate = ({value}: {value: Signal<{needLastName: boolean}>}) =>
|
||||
value().needLastName;
|
||||
|
||||
describe('when', () => {
|
||||
it('validates child field according to condition', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhen(path, needsLastNamePredicate, (namePath) => {
|
||||
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f().value.set({first: 'meow', needLastName: false, last: ''});
|
||||
expect(f.last().errors()).toEqual([]);
|
||||
f().value.set({first: 'meow', needLastName: true, last: ''});
|
||||
expect(f.last().errors()).toEqual([requiredError({field: f.last})]);
|
||||
});
|
||||
|
||||
it('Disallows using non-local paths', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhen(path, needsLastNamePredicate, (/* UNUSED */) => {
|
||||
expect(() => {
|
||||
validate(path.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
|
||||
}).toThrowError();
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
});
|
||||
|
||||
it('supports merging two array schemas', () => {
|
||||
const data = signal({needLastName: true, items: [{first: '', last: ''}]});
|
||||
|
||||
const s: SchemaOrSchemaFn<User> = (namePath) => {
|
||||
validate(namePath.last, ({value}) => {
|
||||
return value().length > 0 ? undefined : customError({kind: 'required1'});
|
||||
});
|
||||
};
|
||||
|
||||
const s2: SchemaOrSchemaFn<User> = (namePath) => {
|
||||
validate(namePath.last, ({value}) => {
|
||||
return value.length > 0 ? undefined : customError({kind: 'required2'});
|
||||
});
|
||||
};
|
||||
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyEach(path.items, s);
|
||||
applyWhen(path, needsLastNamePredicate, (names) => {
|
||||
applyEach(names.items, s2);
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
f.needLastName().value.set(true);
|
||||
expect(f.items[0].last().errors()).toEqual([
|
||||
customError({kind: 'required1', field: f.items[0].last}),
|
||||
customError({kind: 'required2', field: f.items[0].last}),
|
||||
]);
|
||||
f.needLastName().value.set(false);
|
||||
expect(f.items[0].last().errors()).toEqual([
|
||||
customError({kind: 'required1', field: f.items[0].last}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a schema', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
const s: SchemaOrSchemaFn<User> = (namePath) => {
|
||||
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
|
||||
};
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhen(path, needsLastNamePredicate, s);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f().value.set({first: 'meow', needLastName: false, last: ''});
|
||||
expect(f.last().errors()).toEqual([]);
|
||||
f().value.set({first: 'meow', needLastName: true, last: ''});
|
||||
expect(f.last().errors()).toEqual([requiredError({field: f.last})]);
|
||||
});
|
||||
|
||||
it('supports mix of conditional and non conditional validators', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
validate(path.last, ({value}) =>
|
||||
value().length > 4 ? undefined : customError({kind: 'short'}),
|
||||
);
|
||||
|
||||
applyWhen(path, needsLastNamePredicate, (namePath /* Path */) => {
|
||||
validate(namePath.last, ({value}) => (value().length > 0 ? undefined : requiredError()));
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
f().value.set({first: 'meow', needLastName: false, last: ''});
|
||||
expect(f.last().errors()).toEqual([customError({kind: 'short', field: f.last})]);
|
||||
f().value.set({first: 'meow', needLastName: true, last: ''});
|
||||
expect(f.last().errors()).toEqual([
|
||||
customError({kind: 'short', field: f.last}),
|
||||
requiredError({field: f.last}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports array schema', () => {
|
||||
const data = signal({needLastName: true, items: [{first: '', last: ''}]});
|
||||
const s: SchemaOrSchemaFn<User> = (i) => {
|
||||
validate(i.last, ({value}) => {
|
||||
return value().length > 0 ? undefined : requiredError();
|
||||
});
|
||||
};
|
||||
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhen(path, needsLastNamePredicate, (names /* Path */) => {
|
||||
applyEach(names.items, s);
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.items[0].last().errors()).toEqual([requiredError({field: f.items[0].last})]);
|
||||
f.needLastName().value.set(false);
|
||||
expect(f.items[0].last().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyWhenValue', () => {
|
||||
it('accepts non-narrowing predicate', () => {
|
||||
const data = signal<{numOrNull: number | null}>({numOrNull: null});
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhenValue(
|
||||
path.numOrNull,
|
||||
(value) => value === null || value > 0,
|
||||
(num) => {
|
||||
validate(num, ({value}) =>
|
||||
(value() ?? 0) < 10 ? customError({kind: 'too-small'}) : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
|
||||
f.numOrNull().value.set(5);
|
||||
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
|
||||
f.numOrNull().value.set(null);
|
||||
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
|
||||
f.numOrNull().value.set(15);
|
||||
expect(f.numOrNull().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts narrowing-predicate and schema for narrowed type', () => {
|
||||
const data = signal<{numOrNull: number | null}>({numOrNull: null});
|
||||
const f = form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhenValue(
|
||||
path.numOrNull,
|
||||
(value) => value !== null,
|
||||
(num) => {
|
||||
validate(num, ({value}) =>
|
||||
value() < 10 ? customError({kind: 'too-small'}) : undefined,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.numOrNull().errors()).toEqual([]);
|
||||
f.numOrNull().value.set(5);
|
||||
expect(f.numOrNull().errors()).toEqual([customError({kind: 'too-small', field: f.numOrNull})]);
|
||||
f.numOrNull().value.set(null);
|
||||
expect(f.numOrNull().errors()).toEqual([]);
|
||||
f.numOrNull().value.set(15);
|
||||
expect(f.numOrNull().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
54
packages/forms/signals/test/node/dynamic.spec.ts
Normal file
54
packages/forms/signals/test/node/dynamic.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {form} from '../../public_api';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('dynamic data patterns', () => {
|
||||
it('returns `undefined` for declared fields with an undefined value', () => {
|
||||
const model = signal({data: undefined as string | undefined});
|
||||
const f = form(model, noop, {injector: TestBed.inject(Injector)});
|
||||
expect(f.data).toBe(undefined);
|
||||
|
||||
// @ts-expect-error: 2722
|
||||
expect(() => f.data()).toThrow();
|
||||
});
|
||||
|
||||
it('supports non-null assertions for declared fields with a potentially undefined value', () => {
|
||||
const model = signal({data: 'test' as string | undefined});
|
||||
const f = form(model, noop, {injector: TestBed.inject(Injector)});
|
||||
|
||||
expect(f.data).not.toBeUndefined();
|
||||
expect(f.data!().value()).toBe('test');
|
||||
|
||||
// Asserts that the type of `value()` is indeed `string` and excludes `undefined`.
|
||||
let value: string = f.data!().value();
|
||||
});
|
||||
|
||||
describe('tracking', () => {
|
||||
it('should write to the right key after a move', () => {
|
||||
const data = signal([
|
||||
{name: 'Alex', counter: 0},
|
||||
{name: 'Miles', counter: 0},
|
||||
]);
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
const c0 = f[0].counter();
|
||||
// Swap
|
||||
data.update(([v0, v1]) => [v1, v0]);
|
||||
|
||||
c0.value.set(1);
|
||||
expect(data()[0].name).toBe('Miles');
|
||||
expect(data()[0].counter).toBe(0);
|
||||
expect(data()[1].name).toBe('Alex');
|
||||
expect(data()[1].counter).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
148
packages/forms/signals/test/node/field_context.spec.ts
Normal file
148
packages/forms/signals/test/node/field_context.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* @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 {Injector, signal, WritableSignal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {applyEach, FieldContext, FieldPath, form, PathKind, validate} from '../../public_api';
|
||||
|
||||
function testContext<T>(
|
||||
s: WritableSignal<T>,
|
||||
callback: (ctx: FieldContext<T>, p: FieldPath<T>) => void,
|
||||
) {
|
||||
const isCalled = jasmine.createSpy();
|
||||
|
||||
TestBed.runInInjectionContext(() => {
|
||||
const f = form(s, (p) => {
|
||||
validate(p, (ctx) => {
|
||||
callback(ctx, p);
|
||||
isCalled();
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
f().errors();
|
||||
});
|
||||
|
||||
expect(isCalled).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe('Field Context', () => {
|
||||
it('value', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx) => {
|
||||
expect(ctx.value().name).toEqual('pirojok-the-cat');
|
||||
});
|
||||
});
|
||||
|
||||
it('state', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx) => {
|
||||
expect(ctx.state.value().name).toEqual('pirojok-the-cat');
|
||||
});
|
||||
});
|
||||
|
||||
it('field', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx) => {
|
||||
expect(ctx.field.name().value()).toEqual('pirojok-the-cat');
|
||||
expect(ctx.field.age().value()).toEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
it('key', () => {
|
||||
const keys: string[] = [];
|
||||
const recordKey = ({key}: FieldContext<unknown, PathKind.Child>) => {
|
||||
try {
|
||||
keys.push(key());
|
||||
} catch (e) {
|
||||
keys.push((e as Error).message);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
// @ts-expect-error
|
||||
validate(p, recordKey);
|
||||
validate(p.name, recordKey);
|
||||
validate(p.age, recordKey);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
f().valid();
|
||||
expect(keys).toEqual([
|
||||
'RuntimeError: the top-level field in the form has no parent',
|
||||
'name',
|
||||
'age',
|
||||
]);
|
||||
});
|
||||
|
||||
it('index', () => {
|
||||
const indices: (string | number)[] = [];
|
||||
const recordIndex = ({index}: FieldContext<unknown, PathKind.Item>) => {
|
||||
try {
|
||||
indices.push(index());
|
||||
} catch (e) {
|
||||
indices.push((e as Error).message);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const pets = signal({
|
||||
cats: [
|
||||
{name: 'pirojok-the-cat', age: 5},
|
||||
{name: 'mielo', age: 10},
|
||||
],
|
||||
owner: 'joe',
|
||||
});
|
||||
const f = form(
|
||||
pets,
|
||||
(p) => {
|
||||
// @ts-expect-error
|
||||
validate(p, recordIndex);
|
||||
applyEach(p.cats, (cat) => {
|
||||
validate(cat, recordIndex);
|
||||
});
|
||||
// @ts-expect-error
|
||||
validate(p.owner, recordIndex);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
f().valid();
|
||||
expect(indices).toEqual([
|
||||
'RuntimeError: the top-level field in the form has no parent',
|
||||
0,
|
||||
1,
|
||||
'RuntimeError: cannot access index, parent field is not an array',
|
||||
]);
|
||||
});
|
||||
|
||||
it('valueOf', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx, p) => {
|
||||
expect(ctx.valueOf(p.name)).toEqual('pirojok-the-cat');
|
||||
expect(ctx.valueOf(p.age)).toEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
it('stateOf', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx, p) => {
|
||||
expect(ctx.stateOf(p.name).value()).toEqual('pirojok-the-cat');
|
||||
expect(ctx.stateOf(p.age).value()).toEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
it('fieldOf', () => {
|
||||
const cat = signal({name: 'pirojok-the-cat', age: 5});
|
||||
testContext(cat, (ctx, p) => {
|
||||
expect(ctx.fieldOf(p.name)().value()).toEqual('pirojok-the-cat');
|
||||
expect(ctx.fieldOf(p.age)().value()).toEqual(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
996
packages/forms/signals/test/node/field_node.spec.ts
Normal file
996
packages/forms/signals/test/node/field_node.spec.ts
Normal file
|
|
@ -0,0 +1,996 @@
|
|||
/**
|
||||
* @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 {computed, Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
apply,
|
||||
applyEach,
|
||||
customError,
|
||||
disabled,
|
||||
FieldPath,
|
||||
form,
|
||||
readonly,
|
||||
required,
|
||||
REQUIRED,
|
||||
requiredError,
|
||||
Schema,
|
||||
schema,
|
||||
SchemaOrSchemaFn,
|
||||
validate,
|
||||
validateTree,
|
||||
ValidationError,
|
||||
} from '../../public_api';
|
||||
import {SchemaImpl} from '../../src/schema/schema';
|
||||
|
||||
describe('FieldNode', () => {
|
||||
it('can get a child of a key that exists', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f.a).toBeDefined();
|
||||
expect(f.a().value()).toBe(1);
|
||||
});
|
||||
|
||||
describe('instances', () => {
|
||||
it('should get the same instance when asking for a child multiple times', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
const child = f.a;
|
||||
expect(f.a).toBe(child);
|
||||
});
|
||||
|
||||
it('should get the same instance when asking for a child multiple times', () => {
|
||||
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
const child = f.a;
|
||||
value.set({a: 3});
|
||||
expect(f.a).toBe(child);
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot get a child of a key that does not exist', () => {
|
||||
const f = form(
|
||||
signal<{a: number; b: number; c?: number}>({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{
|
||||
injector: TestBed.inject(Injector),
|
||||
},
|
||||
);
|
||||
expect(f.c).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can get a child inside of a computed', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
const childA = computed(() => f.a);
|
||||
expect(childA()).toBeDefined();
|
||||
});
|
||||
|
||||
it('can get a child inside of a computed', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
const childA = computed(() => f.a);
|
||||
expect(childA()).toBeDefined();
|
||||
});
|
||||
|
||||
describe('dirty', () => {
|
||||
it('is not dirty initially', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().dirty()).toBe(false);
|
||||
expect(f.a().dirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('can be marked as dirty', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().dirty()).toBe(false);
|
||||
|
||||
f().markAsDirty();
|
||||
expect(f().dirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('can be reset', () => {
|
||||
const model = signal({a: 1, b: 2});
|
||||
const f = form(model, {injector: TestBed.inject(Injector)});
|
||||
f().markAsDirty();
|
||||
expect(f().dirty()).toBe(true);
|
||||
|
||||
f().reset();
|
||||
expect(f().dirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates from the children', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().dirty()).toBe(false);
|
||||
|
||||
f.a().markAsDirty();
|
||||
expect(f().dirty()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not propagate down', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().dirty()).toBe(false);
|
||||
f().markAsDirty();
|
||||
expect(f.a().dirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not consider children that get removed', () => {
|
||||
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
expect(f().dirty()).toBe(false);
|
||||
|
||||
f.b!().markAsDirty();
|
||||
expect(f().dirty()).toBe(true);
|
||||
|
||||
value.set({a: 2});
|
||||
expect(f().dirty()).toBe(false);
|
||||
expect(f.b).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('touched', () => {
|
||||
it('is untouched initially', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().touched()).toBe(false);
|
||||
});
|
||||
|
||||
it('can be marked as touched', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().touched()).toBe(false);
|
||||
|
||||
f().markAsTouched();
|
||||
expect(f().touched()).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates from the children', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().touched()).toBe(false);
|
||||
|
||||
f.a().markAsTouched();
|
||||
expect(f().touched()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not propagate down', () => {
|
||||
const f = form(
|
||||
signal({
|
||||
a: 1,
|
||||
b: 2,
|
||||
}),
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().touched()).toBe(false);
|
||||
f().markAsTouched();
|
||||
expect(f.a().touched()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not consider children that get removed', () => {
|
||||
const value = signal<{a: number; b?: number}>({a: 1, b: 2});
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
expect(f().touched()).toBe(false);
|
||||
|
||||
f.b!().markAsTouched();
|
||||
expect(f().touched()).toBe(true);
|
||||
|
||||
value.set({a: 2});
|
||||
expect(f().touched()).toBe(false);
|
||||
expect(f.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can be reset', () => {
|
||||
const model = signal({a: 1, b: 2});
|
||||
const f = form(model, {injector: TestBed.inject(Injector)});
|
||||
f().markAsTouched();
|
||||
expect(f().touched()).toBe(true);
|
||||
|
||||
f().reset();
|
||||
expect(f().touched()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrays', () => {
|
||||
it('should only have child nodes for elements that exist', () => {
|
||||
const f = form(signal([1, 2]), {injector: TestBed.inject(Injector)});
|
||||
expect(f[0]).toBeDefined();
|
||||
expect(f[1]).toBeDefined();
|
||||
expect(f[2]).not.toBeDefined();
|
||||
expect(f['length']).toBe(2);
|
||||
});
|
||||
|
||||
it('should get the element node', () => {
|
||||
const f = form(
|
||||
signal({names: [{name: 'Alex'}, {name: 'Miles'}]}),
|
||||
(p) => {
|
||||
applyEach(p.names, (a) => {
|
||||
disabled(a.name, ({value, fieldOf}) => {
|
||||
const el = fieldOf(a);
|
||||
expect(el().value().name).toBe(value());
|
||||
expect([...fieldOf(p).names].findIndex((e: any) => e === el)).not.toBe(-1);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f.names[0].name().disabled()).toBe(true);
|
||||
expect(f.names[1].name().disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support element-level logic', () => {
|
||||
const f = form(
|
||||
signal([1, 2, 3]),
|
||||
(p) => {
|
||||
applyEach(p, (a) => {
|
||||
a;
|
||||
disabled(a, ({value}) => value() % 2 === 0);
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f[0]().disabled()).toBe(false);
|
||||
expect(f[1]().disabled()).toBe(true);
|
||||
expect(f[2]().disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should support dynamic elements', () => {
|
||||
const model = signal([1, 2, 3]);
|
||||
const f = form(
|
||||
model,
|
||||
(p) => {
|
||||
applyEach(p, (el) => {
|
||||
// Disabled if even.
|
||||
disabled(el, ({value}) => value() % 2 === 0);
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
model.update((v) => [...v, 4]);
|
||||
expect(f[3]().disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support removing elements', () => {
|
||||
const value = signal([1, 2, 3]);
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
f[2]().markAsTouched();
|
||||
expect(f().touched()).toBe(true);
|
||||
|
||||
value.set([1, 2]);
|
||||
expect(f().touched()).toBe(false);
|
||||
});
|
||||
|
||||
describe('tracking', () => {
|
||||
it('maintains identity across value moves', () => {
|
||||
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
const alex = f[0];
|
||||
const kirill = f[1];
|
||||
|
||||
value.update((old) => [old[1], old[0]]);
|
||||
|
||||
expect(f[0] === kirill).toBeTrue();
|
||||
expect(f[1] === alex).toBeTrue();
|
||||
});
|
||||
|
||||
it('maintains identity across value update', () => {
|
||||
const value = signal([{name: 'Alex'}, {name: 'Kirill'}]);
|
||||
const f = form(value, {injector: TestBed.inject(Injector)});
|
||||
const alex = f[0];
|
||||
const kirill = f[1];
|
||||
|
||||
value.update((old) => [old[1], {...old[0], name: 'Pawel'}]);
|
||||
|
||||
expect(f[0] === kirill).toBeTrue();
|
||||
expect(f[1] === alex).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('names', () => {
|
||||
it('auto-generates a name for the form', () => {
|
||||
const f = form(signal({}), {injector: TestBed.inject(Injector)});
|
||||
expect(f().name()).toMatch(/^a.form\d+$/);
|
||||
});
|
||||
|
||||
it('uses a specific name for the form when given', () => {
|
||||
const f = form(signal({}), {injector: TestBed.inject(Injector), name: 'test'});
|
||||
expect(f().name()).toBe('test');
|
||||
});
|
||||
|
||||
it('derives child field names from parents', () => {
|
||||
const f = form(signal({user: {firstName: 'Alex'}}), {
|
||||
injector: TestBed.inject(Injector),
|
||||
name: 'test',
|
||||
});
|
||||
expect(f.user().name()).toBe('test.user');
|
||||
expect(f.user.firstName().name()).toBe('test.user.firstName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled', () => {
|
||||
it('should allow logic to make a node disabled', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
disabled(p.a, ({value}) => value() !== 2);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
const a = f.a;
|
||||
expect(f().disabled()).toBe(false);
|
||||
expect(a().disabled()).toBe(true);
|
||||
expect(a().disabledReasons()).toEqual([{field: f.a}]);
|
||||
|
||||
a().value.set(2);
|
||||
expect(f().disabled()).toBe(false);
|
||||
expect(a().disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should disable with reason', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
disabled(p.a, () => 'a cannot be changed');
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().disabled()).toBe(true);
|
||||
expect(f.a().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f.a,
|
||||
message: 'a cannot be changed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not have disabled reason if not disabled', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
disabled(p.a, ({value}) => (value() > 5 ? 'a cannot be changed' : false));
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().disabled()).toBe(false);
|
||||
expect(f.a().disabledReasons()).toEqual([]);
|
||||
|
||||
f.a().value.set(6);
|
||||
|
||||
expect(f.a().disabled()).toBe(true);
|
||||
expect(f.a().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f.a,
|
||||
message: 'a cannot be changed',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('disabled reason should propagate to children', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
disabled(p, () => 'form unavailable');
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().disabled()).toBe(true);
|
||||
expect(f().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f,
|
||||
message: 'form unavailable',
|
||||
},
|
||||
]);
|
||||
expect(f.a().disabled()).toBe(true);
|
||||
expect(f.a().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f,
|
||||
message: 'form unavailable',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should disable unconditionally', () => {
|
||||
const f = form(
|
||||
signal({a: '', b: ''}),
|
||||
(p) => {
|
||||
disabled(p.a);
|
||||
disabled(p.b, 'disabled!');
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f.a,
|
||||
},
|
||||
]);
|
||||
expect(f.b().disabledReasons()).toEqual([
|
||||
{
|
||||
field: f.b,
|
||||
message: 'disabled!',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readonly', () => {
|
||||
it('should allow logic to make a field readonly', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
readonly(p.a);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().readonly()).toBe(false);
|
||||
expect(f.a().readonly()).toBe(true);
|
||||
expect(f.b().readonly()).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow logic to make a field conditionally readonly', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
readonly(p.a, ({value}) => value() > 10);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().readonly()).toBe(false);
|
||||
|
||||
f.a().value.set(11);
|
||||
expect(f.a().readonly()).toBe(true);
|
||||
});
|
||||
|
||||
it('should make children of readonly parent readonly', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
readonly(p);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().readonly()).toBe(true);
|
||||
expect(f.a().readonly()).toBe(true);
|
||||
expect(f.b().readonly()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not validate readonly fields', () => {
|
||||
const isReadonly = signal(false);
|
||||
const f = form(
|
||||
signal(''),
|
||||
(p) => {
|
||||
readonly(p, isReadonly);
|
||||
required(p);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().property(REQUIRED)()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().readonly()).toBe(false);
|
||||
|
||||
isReadonly.set(true);
|
||||
expect(f().property(REQUIRED)()).toBe(true);
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().readonly()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should validate field', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
validate(p.a, ({value}) => {
|
||||
if (value() > 10) {
|
||||
return customError({kind: 'too-damn-high'});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().errors()).toEqual([]);
|
||||
expect(f.a().valid()).toBe(true);
|
||||
expect(f.a().errors()).toEqual([]);
|
||||
expect(f().valid()).toBe(true);
|
||||
|
||||
f.a().value.set(11);
|
||||
expect(f.a().errors()).toEqual([customError({kind: 'too-damn-high', field: f.a})]);
|
||||
expect(f.a().valid()).toBe(false);
|
||||
expect(f().errors()).toEqual([]);
|
||||
expect(f().valid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate with multiple errors', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
validate(p.a, ({value}) => {
|
||||
if (value() > 10) {
|
||||
return [customError({kind: 'too-damn-high'}), customError({kind: 'bad'})];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().errors()).toEqual([]);
|
||||
expect(f.a().valid()).toBe(true);
|
||||
|
||||
f.a().value.set(11);
|
||||
expect(f.a().errors()).toEqual([
|
||||
customError({kind: 'too-damn-high', field: f.a}),
|
||||
customError({kind: 'bad', field: f.a}),
|
||||
]);
|
||||
expect(f.a().valid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate required field', () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
required(name.first);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
|
||||
expect(f.first().valid()).toBe(false);
|
||||
expect(f.first().property(REQUIRED)()).toBe(true);
|
||||
|
||||
f.first().value.set('Bob');
|
||||
|
||||
expect(f.first().errors()).toEqual([]);
|
||||
expect(f.first().valid()).toBe(true);
|
||||
expect(f.first().property(REQUIRED)()).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate conditionally required field', () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.first().errors()).toEqual([]);
|
||||
expect(f.first().valid()).toBe(true);
|
||||
expect(f.first().property(REQUIRED)()).toBe(false);
|
||||
|
||||
f.last().value.set('Loblaw');
|
||||
|
||||
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
|
||||
expect(f.first().valid()).toBe(false);
|
||||
expect(f.first().property(REQUIRED)()).toBe(true);
|
||||
|
||||
f.first().value.set('Bob');
|
||||
|
||||
expect(f.first().errors()).toEqual([]);
|
||||
expect(f.first().valid()).toBe(true);
|
||||
expect(f.first().property(REQUIRED)()).toBe(true);
|
||||
});
|
||||
|
||||
it('should support custom empty predicate', () => {
|
||||
const data = signal({name: '', quantity: 0});
|
||||
const f = form(
|
||||
data,
|
||||
(item) => {
|
||||
required(item.quantity, {emptyPredicate: (value) => value === 0});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.quantity().property(REQUIRED)()).toBe(true);
|
||||
expect(f.quantity().errors()).toEqual([requiredError({field: f.quantity})]);
|
||||
|
||||
f.quantity().value.set(1);
|
||||
expect(f.quantity().property(REQUIRED)()).toBe(true);
|
||||
expect(f.quantity().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should link required error messages to their predicate', () => {
|
||||
const data = signal({country: '', amount: 0, name: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(tx) => {
|
||||
required(tx.name, {
|
||||
when: ({valueOf}) => valueOf(tx.country) === 'USA',
|
||||
error: requiredError({message: 'Name is required in your country'}),
|
||||
});
|
||||
required(tx.name, {
|
||||
when: ({valueOf}) => valueOf(tx.amount) >= 1000,
|
||||
error: requiredError({
|
||||
message: 'Name is required for large transactions',
|
||||
}),
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
|
||||
f.country().value.set('USA');
|
||||
expect(f.name().errors()).toEqual([
|
||||
requiredError({
|
||||
message: 'Name is required in your country',
|
||||
field: f.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
f.amount().value.set(1000);
|
||||
expect(f.name().errors()).toEqual([
|
||||
requiredError({
|
||||
message: 'Name is required in your country',
|
||||
field: f.name,
|
||||
}),
|
||||
requiredError({
|
||||
message: 'Name is required for large transactions',
|
||||
field: f.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
f.country().value.set('Canada');
|
||||
expect(f.name().errors()).toEqual([
|
||||
requiredError({
|
||||
message: 'Name is required for large transactions',
|
||||
field: f.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
f.amount().value.set(100);
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow validate logic to return null to indicate no error', () => {
|
||||
const f = form(
|
||||
signal({a: 1, b: 2}),
|
||||
(p) => {
|
||||
validate(p.a, ({value}) => (value() > 1 ? customError() : null));
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.a().errors()).toEqual([]);
|
||||
expect(f.a().valid()).toBe(true);
|
||||
|
||||
f.a().value.set(2);
|
||||
expect(f.a().errors()).toEqual([customError({field: f.a})]);
|
||||
expect(f.a().valid()).toBe(false);
|
||||
});
|
||||
|
||||
describe('tree validation', () => {
|
||||
it('should push errors to children', () => {
|
||||
const cat = signal({name: 'Fluffy', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
validateTree(p, ({value, fieldOf}) => {
|
||||
const errors: ValidationError[] = [];
|
||||
if (value().name.length > 8) {
|
||||
errors.push(customError({kind: 'long_name', field: fieldOf(p.name)}));
|
||||
}
|
||||
if (value().age < 0) {
|
||||
errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)}));
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
|
||||
f.age().value.set(-10);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
expect(f.age().errors()).toEqual([customError({kind: 'temporal_anomaly', field: f.age})]);
|
||||
|
||||
cat.set({name: 'Fluffy McFluffington', age: 10});
|
||||
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: f.name})]);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should push errors to children async', () => {
|
||||
const cat = signal({name: 'Fluffy', age: 5});
|
||||
const f = form(
|
||||
cat,
|
||||
(p) => {
|
||||
validateTree(p, ({value, fieldOf}) => {
|
||||
const errors: ValidationError[] = [];
|
||||
if (value().name.length > 8) {
|
||||
errors.push(customError({kind: 'long_name', field: fieldOf(p.name)}));
|
||||
}
|
||||
if (value().age < 0) {
|
||||
errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)}));
|
||||
}
|
||||
return errors;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
|
||||
f.age().value.set(-10);
|
||||
|
||||
expect(f.name().errors()).toEqual([]);
|
||||
expect(f.age().errors()).toEqual([customError({kind: 'temporal_anomaly', field: f.age})]);
|
||||
|
||||
cat.set({name: 'Fluffy McFluffington', age: 10});
|
||||
expect(f.name().errors()).toEqual([customError({kind: 'long_name', field: f.name})]);
|
||||
expect(f.age().errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorSummary', () => {
|
||||
it('should be empty', () => {
|
||||
const data = signal({});
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
|
||||
expect(f().errorSummary()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should contain errors from current field', () => {
|
||||
const data = signal('');
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
required(p);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().errorSummary()).toEqual([requiredError({field: f})]);
|
||||
});
|
||||
|
||||
it('should contain errors from child fields', () => {
|
||||
const name = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
name,
|
||||
(p) => {
|
||||
required(p.first);
|
||||
required(p.last);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().errorSummary()).toEqual([
|
||||
requiredError({field: f.first}),
|
||||
requiredError({field: f.last}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should accumulate errors of all descendants', () => {
|
||||
const data = signal({
|
||||
child: {
|
||||
child: {},
|
||||
},
|
||||
});
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
validate(p, () => customError({kind: 'root'}));
|
||||
validate(p.child, () => customError({kind: 'child'}));
|
||||
validate(p.child.child, () => customError({kind: 'grandchild'}));
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.child.child().errorSummary()).toEqual([
|
||||
customError({kind: 'grandchild', field: f.child.child}),
|
||||
]);
|
||||
expect(f.child().errorSummary()).toEqual([
|
||||
customError({kind: 'child', field: f.child}),
|
||||
customError({kind: 'grandchild', field: f.child.child}),
|
||||
]);
|
||||
expect(f().errorSummary()).toEqual([
|
||||
customError({kind: 'root', field: f}),
|
||||
customError({kind: 'child', field: f.child}),
|
||||
customError({kind: 'grandchild', field: f.child.child}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composition', () => {
|
||||
it('should apply schema to field', () => {
|
||||
interface Address {
|
||||
street: string;
|
||||
city: string;
|
||||
}
|
||||
|
||||
const addressSchema: SchemaOrSchemaFn<Address> = (p) => {
|
||||
disabled(p.street, () => true);
|
||||
};
|
||||
|
||||
const data = signal<{name: string; address: Address}>({
|
||||
name: '',
|
||||
address: {street: '', city: ''},
|
||||
});
|
||||
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
apply(p.address, addressSchema);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.address.street().disabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('predefined schema', () => {
|
||||
it('should compile schema once per form', () => {
|
||||
const opts = {injector: TestBed.inject(Injector)};
|
||||
const subFn = jasmine.createSpy('schemaFn');
|
||||
const sub: Schema<string> = schema(subFn);
|
||||
const s = schema((p: FieldPath<{a: string; b: string}>) => {
|
||||
apply(p.a, sub);
|
||||
apply(p.b, sub);
|
||||
});
|
||||
expect(subFn).toHaveBeenCalledTimes(0);
|
||||
|
||||
form(signal({a: '', b: ''}), s, opts);
|
||||
expect(subFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
form(signal({a: '', b: ''}), s, opts);
|
||||
expect(subFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should resolve predefined schema paths within the local context', () => {
|
||||
const s = schema<{a: string; b: string}>((p) => {
|
||||
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
|
||||
});
|
||||
|
||||
const f = form(
|
||||
signal({first: {a: '', b: ''}, second: {a: 'disable-b', b: ''}}),
|
||||
(p) => {
|
||||
apply(p.first, s);
|
||||
apply(p.second, s);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.first.b().disabled()).toBe(false);
|
||||
expect(f.second.b().disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should resolve predefined schema paths deeply nested within the schema', () => {
|
||||
const s = schema<{a: string; b: string}>((p) => {
|
||||
disabled(p.b, ({valueOf}) => valueOf(p.a) === 'disable-b');
|
||||
});
|
||||
|
||||
const f = form(
|
||||
signal({first: {second: {a: 'disable-b', b: ''}}}),
|
||||
(p) => {
|
||||
apply(p.first, (p) => {
|
||||
apply(p.second, s);
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f.first.second.b().disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should error on resolving predefined schema path that is not part of the form', () => {
|
||||
let otherP: FieldPath<any>;
|
||||
const s = schema<string>((p) => (otherP = p));
|
||||
SchemaImpl.rootCompile(s);
|
||||
|
||||
const f = form(
|
||||
signal(''),
|
||||
(p) => {
|
||||
disabled(p, ({fieldOf}) => {
|
||||
fieldOf(otherP);
|
||||
return true;
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(() => f().disabled()).toThrowError('Path is not part of this field tree.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should propagate to descendants', () => {
|
||||
const model = signal({a: {b: 2}});
|
||||
const f = form(model, {injector: TestBed.inject(Injector)});
|
||||
|
||||
f.a.b().markAsDirty();
|
||||
expect(f().dirty()).toBe(true);
|
||||
expect(f.a().dirty()).toBe(true);
|
||||
expect(f.a.b().dirty()).toBe(true);
|
||||
|
||||
f().reset();
|
||||
expect(f().dirty()).toBe(false);
|
||||
expect(f.a().dirty()).toBe(false);
|
||||
expect(f.a.b().dirty()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
34
packages/forms/signals/test/node/field_proxy.spec.ts
Normal file
34
packages/forms/signals/test/node/field_proxy.spec.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {form} from '../../public_api';
|
||||
|
||||
describe('Field proxy', () => {
|
||||
it('should not forward methods through the proxy', () => {
|
||||
const f = form(signal(new Date()), {injector: TestBed.inject(Injector)});
|
||||
// @ts-expect-error
|
||||
expect(f.getDate).toBe(undefined as any);
|
||||
});
|
||||
|
||||
it('should allow spreading field arrays', () => {
|
||||
const f = form(signal([0, 1, 2]), {injector: TestBed.inject(Injector)});
|
||||
expect([...f].map((i) => i().value())).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('should not allow mutation of the field structure', () => {
|
||||
const f = form(signal({arr: [0, 1]}), {injector: TestBed.inject(Injector)});
|
||||
// Just to have an expectation, really this test is just to check the typings below.
|
||||
expect(f).toBeDefined();
|
||||
// @ts-expect-error
|
||||
f.arr = f.arr;
|
||||
// @ts-expect-error
|
||||
f.arr[0] = f.arr[0];
|
||||
});
|
||||
});
|
||||
85
packages/forms/signals/test/node/form.spec.ts
Normal file
85
packages/forms/signals/test/node/form.spec.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* @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 {inject, Injector, runInInjectionContext, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {form, required, schema, validate} from '../../public_api';
|
||||
|
||||
describe('form', () => {
|
||||
describe('injection context', () => {
|
||||
it('throws when there is no injection context', () => {
|
||||
const model = signal(123);
|
||||
expect(() => form(model)).toThrowError();
|
||||
});
|
||||
|
||||
it('is not present in rules', () => {
|
||||
const injector = TestBed.inject(Injector);
|
||||
|
||||
const model = signal(123);
|
||||
const f = form(
|
||||
model,
|
||||
(p) => {
|
||||
validate(p, () => {
|
||||
expect(() => {
|
||||
inject(Injector);
|
||||
}).toThrow();
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
// Make sure the validation runs
|
||||
f().valid();
|
||||
});
|
||||
|
||||
it('uses provided provided injection context to run the form', () => {
|
||||
const injector = TestBed.inject(Injector);
|
||||
|
||||
const model = signal(123);
|
||||
form(
|
||||
model,
|
||||
() => {
|
||||
expect(inject(Injector)).toBe(injector);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
});
|
||||
|
||||
it('uses provided provided injection context over the one it is run in', () => {
|
||||
const injector = TestBed.inject(Injector);
|
||||
const injector2 = Injector.create({providers: [], parent: injector});
|
||||
|
||||
const model = signal(123);
|
||||
|
||||
runInInjectionContext(injector2, () => {
|
||||
form(
|
||||
model,
|
||||
() => {
|
||||
expect(inject(Injector)).toBe(injector);
|
||||
},
|
||||
{injector: injector},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should infer schema type', () => {
|
||||
runInInjectionContext(TestBed.inject(Injector), () => {
|
||||
const f = form(
|
||||
signal<{x: string}>({x: ''}),
|
||||
schema((p) => {
|
||||
// Note: the primary purpose of this test is to verify that the line below does not have
|
||||
// a type error due to `p` being of type `unknown`.
|
||||
required(p.x);
|
||||
}),
|
||||
);
|
||||
expect(f.x().valid()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
422
packages/forms/signals/test/node/logic_node.spec.ts
Normal file
422
packages/forms/signals/test/node/logic_node.spec.ts
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
/**
|
||||
* @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 {signal} from '@angular/core';
|
||||
import {FieldContext, FieldState, customError} from '../../public_api';
|
||||
import {DYNAMIC} from '../../src/schema/logic';
|
||||
import {LogicNodeBuilder} from '../../src/schema/logic_node';
|
||||
|
||||
const fakeFieldContext: FieldContext<unknown> = {
|
||||
fieldOf: () => undefined!,
|
||||
stateOf: <P>() =>
|
||||
({
|
||||
context: undefined,
|
||||
structure: {pathKeys: () => [], parent: undefined},
|
||||
}) as unknown as FieldState<P>,
|
||||
valueOf: () => undefined!,
|
||||
field: undefined!,
|
||||
state: undefined!,
|
||||
value: undefined!,
|
||||
};
|
||||
|
||||
describe('LogicNodeBuilder', () => {
|
||||
it('should build logic', () => {
|
||||
// (p) => {
|
||||
// validate(p, () => ({kind: 'root-err'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'root-err'})]);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'root-err'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build child logic', () => {
|
||||
// (p) => {
|
||||
// validate(p.a, () => ({kind: 'child-err'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'root-err'})]);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'root-err'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build merged logic', () => {
|
||||
// (p) => {
|
||||
// validate(p, () => ({kind: 'err-1'}));
|
||||
// validate(p, () => ({kind: 'err-2'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
||||
builder.mergeIn(builder2);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
customError({kind: 'err-2'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build merged child logic', () => {
|
||||
// (p) => {
|
||||
// validate(p.a, () => ({kind: 'err-1'}));
|
||||
// validate(p.a, () => ({kind: 'err-2'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
||||
builder.mergeIn(builder2);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
customError({kind: 'err-2'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should build logic with predicate', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// validate(p, () => ({kind: 'err-1'}));
|
||||
// });
|
||||
// }
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
|
||||
pred.set(false);
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should apply predicate to merged in logic', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// apply(p, (p) => {
|
||||
// validate(p, () => ({kind: 'err-1'}));
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
|
||||
const builder3 = LogicNodeBuilder.newRoot();
|
||||
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
builder2.mergeIn(builder3);
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
|
||||
pred.set(false);
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should apply predicate to merged in child logic', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// apply(p, (p) => {
|
||||
// validate(p.a, () => ({kind: 'err-1'}));
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
|
||||
const builder3 = LogicNodeBuilder.newRoot();
|
||||
builder3.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
builder2.mergeIn(builder3);
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
|
||||
pred.set(false);
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should combine predicates', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// applyWhen(p.a, pred2, (a) => {
|
||||
// validate(a, () => ({kind: 'err-1'}));
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred2 = signal(true);
|
||||
const builder3 = LogicNodeBuilder.newRoot();
|
||||
builder3.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
|
||||
pred.set(false);
|
||||
pred2.set(true);
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
|
||||
pred.set(true);
|
||||
pred2.set(false);
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should propagate predicates through deep application', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// validate(p.a.b, () => ({kind: 'err-1'}));
|
||||
// applyWhen(p.a, pred2, (a) => {
|
||||
// validate(a.b, () => ({kind: 'err-2'}));
|
||||
// applyWhen(a.b, pred3, (b) => {
|
||||
// validate(b, () => ({kind: 'err-3'}));
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2
|
||||
.getChild('a')
|
||||
.getChild('b')
|
||||
.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
const pred2 = signal(true);
|
||||
const builder3 = LogicNodeBuilder.newRoot();
|
||||
builder3.getChild('b').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
||||
|
||||
const pred3 = signal(true);
|
||||
const builder4 = LogicNodeBuilder.newRoot();
|
||||
builder4.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
||||
builder3.getChild('b').mergeIn(builder4, {fn: () => pred3(), path: undefined!});
|
||||
builder2.getChild('a').mergeIn(builder3, {fn: () => pred2(), path: undefined!});
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(
|
||||
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
customError({kind: 'err-2'}),
|
||||
customError({kind: 'err-3'}),
|
||||
]);
|
||||
|
||||
pred.set(true);
|
||||
pred2.set(true);
|
||||
pred3.set(false);
|
||||
expect(
|
||||
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([customError({kind: 'err-1'}), customError({kind: 'err-2'})]);
|
||||
|
||||
pred.set(true);
|
||||
pred2.set(false);
|
||||
pred3.set(true);
|
||||
expect(
|
||||
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([customError({kind: 'err-1'})]);
|
||||
|
||||
pred.set(false);
|
||||
pred2.set(true);
|
||||
pred3.set(true);
|
||||
expect(
|
||||
logicNode.getChild('a').getChild('b').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should propagate predicates through deep child access', () => {
|
||||
// (p) => {
|
||||
// applyWhen(p, pred, (p) => {
|
||||
// applyEach(p.items, (i) => {
|
||||
// validate(i.last, () => ({kind: 'err-1'}));
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
|
||||
const pred = signal(true);
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
|
||||
const builder3 = LogicNodeBuilder.newRoot();
|
||||
builder3.getChild('last').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
builder2.getChild('items').getChild(DYNAMIC).mergeIn(builder3);
|
||||
builder.mergeIn(builder2, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(
|
||||
logicNode
|
||||
.getChild('items')
|
||||
.getChild(DYNAMIC)
|
||||
.getChild('last')
|
||||
.logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([customError({kind: 'err-1'})]);
|
||||
|
||||
pred.set(false);
|
||||
expect(
|
||||
logicNode
|
||||
.getChild('items')
|
||||
.getChild(DYNAMIC)
|
||||
.getChild('last')
|
||||
.logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve ordering across merges', () => {
|
||||
// (p) => {
|
||||
// validate(p, () => ({kind: 'err-1'}));
|
||||
// apply(p, (p) => {
|
||||
// validate(p, () => ({kind: 'err-2'}));
|
||||
// })
|
||||
// validate(p, () => ({kind: 'err-3'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2.addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
||||
builder.mergeIn(builder2);
|
||||
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
customError({kind: 'err-2'}),
|
||||
customError({kind: 'err-3'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve child ordering across merges', () => {
|
||||
// (p) => {
|
||||
// validate(p.a, () => ({kind: 'err-1'}));
|
||||
// apply(p, (p) => {
|
||||
// validate(p.a, () => ({kind: 'err-2'}));
|
||||
// })
|
||||
// validate(p.a, () => ({kind: 'err-3'}));
|
||||
// };
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
|
||||
const builder2 = LogicNodeBuilder.newRoot();
|
||||
builder2.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-2'})]);
|
||||
builder.mergeIn(builder2);
|
||||
|
||||
builder.getChild('a').addSyncErrorRule(() => [customError({kind: 'err-3'})]);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.getChild('a').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
customError({kind: 'err-2'}),
|
||||
customError({kind: 'err-3'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support circular logic structures', () => {
|
||||
// const s = schema((p) => {
|
||||
// validate(p, () => ({kind: 'err-1'})),
|
||||
// apply(p.next, s);
|
||||
// }));
|
||||
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
builder.getChild('next').mergeIn(builder);
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
expect(
|
||||
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([customError({kind: 'err-1'})]);
|
||||
});
|
||||
|
||||
it('should support circular logic structures with predicate', () => {
|
||||
// const s = schema((p) => {
|
||||
// validate(p, () => ({kind: 'err-1'})),
|
||||
// applyWhen(p.next, pred, s);
|
||||
// }));
|
||||
|
||||
const pred = signal(true);
|
||||
const builder = LogicNodeBuilder.newRoot();
|
||||
builder.addSyncErrorRule(() => [customError({kind: 'err-1'})]);
|
||||
builder.getChild('next').mergeIn(builder, {fn: () => pred(), path: undefined!});
|
||||
|
||||
const logicNode = builder.build();
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
expect(
|
||||
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([customError({kind: 'err-1'})]);
|
||||
|
||||
// TODO: test that verifies that the same predicate can resolve with a different field context
|
||||
// on `.next` vs on `.next.next`
|
||||
pred.set(false);
|
||||
expect(logicNode.logic.syncErrors.compute(fakeFieldContext)).toEqual([
|
||||
customError({kind: 'err-1'}),
|
||||
]);
|
||||
expect(logicNode.getChild('next').logic.syncErrors.compute(fakeFieldContext)).toEqual([]);
|
||||
expect(
|
||||
logicNode.getChild('next').getChild('next').logic.syncErrors.compute(fakeFieldContext),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
102
packages/forms/signals/test/node/path.spec.ts
Normal file
102
packages/forms/signals/test/node/path.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
apply,
|
||||
applyEach,
|
||||
applyWhen,
|
||||
customError,
|
||||
form,
|
||||
requiredError,
|
||||
validate,
|
||||
} from '../../public_api';
|
||||
|
||||
describe('path', () => {
|
||||
describe('Active path', () => {
|
||||
it('Disallows using parent paths for applyWhen', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
form(
|
||||
data,
|
||||
(path) => {
|
||||
applyWhen(
|
||||
path,
|
||||
({value}) => value().needLastName,
|
||||
(/* UNUSED */) => {
|
||||
expect(() => {
|
||||
validate(path.last, ({value}) =>
|
||||
value().length > 0 ? undefined : requiredError(),
|
||||
);
|
||||
}).toThrowError();
|
||||
},
|
||||
);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
});
|
||||
|
||||
it('Disallows using parent paths for apply', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
form(
|
||||
data,
|
||||
(path) => {
|
||||
apply(path, () => {
|
||||
expect(() => {
|
||||
validate(path.last, () => {
|
||||
return customError();
|
||||
});
|
||||
}).toThrowError();
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
});
|
||||
|
||||
it('Disallows using the same path', () => {
|
||||
const data = signal({first: '', needLastName: false, last: ''});
|
||||
|
||||
form(
|
||||
data,
|
||||
(path) => {
|
||||
apply(path, () => {
|
||||
expect(() => {
|
||||
validate(path, () => {
|
||||
return customError();
|
||||
});
|
||||
}).toThrowError();
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
});
|
||||
|
||||
it('Disallows using parent paths for apply', () => {
|
||||
const data = signal({
|
||||
needLastName: false,
|
||||
items: [{first: '', last: ''}],
|
||||
});
|
||||
|
||||
form(
|
||||
data,
|
||||
(path) => {
|
||||
applyEach(path.items, () => {
|
||||
expect(() => {
|
||||
validate(path.needLastName, () => {
|
||||
return customError();
|
||||
});
|
||||
}).toThrowError();
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
133
packages/forms/signals/test/node/recursive_logic.spec.ts
Normal file
133
packages/forms/signals/test/node/recursive_logic.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* @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 {computed, Injector, signal, type Signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {customError} from '../../public_api';
|
||||
import {disabled, validate} from '../../src/api/logic';
|
||||
import {applyEach, applyWhen, applyWhenValue, form, schema} from '../../src/api/structure';
|
||||
import type {Field, Schema} from '../../src/api/types';
|
||||
|
||||
interface TreeData {
|
||||
level: number;
|
||||
next: TreeData | null;
|
||||
}
|
||||
|
||||
function narrowed<TValue, TNarrowed extends TValue>(
|
||||
field: Field<TValue> | undefined,
|
||||
guard: (value: TValue) => value is TNarrowed,
|
||||
): Signal<Field<TNarrowed> | undefined> {
|
||||
return computed(
|
||||
() => field && (guard(field().value()) ? (field as Field<TNarrowed>) : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function isNonNull<T>(t: T): t is NonNullable<T> {
|
||||
return t !== null;
|
||||
}
|
||||
|
||||
describe('recursive schema logic', () => {
|
||||
it('should support recursive logic', () => {
|
||||
const s = schema<TreeData>((p) => {
|
||||
disabled(p.level, ({valueOf}) => {
|
||||
return valueOf(p.level) % 2 === 0;
|
||||
});
|
||||
applyWhenValue(p.next, isNonNull, s);
|
||||
});
|
||||
const f = form<TreeData>(
|
||||
signal({level: 0, next: {level: 1, next: {level: 2, next: {level: 3, next: null}}}}),
|
||||
s,
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f.level().disabled()).toBe(true);
|
||||
expect(narrowed(f.next, isNonNull)()?.level().disabled()).toBe(false);
|
||||
expect(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.level().disabled()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
narrowed(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.next, isNonNull)()
|
||||
?.level()
|
||||
.disabled(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should support co-recursive logic', () => {
|
||||
const s1: Schema<TreeData> = schema((p) => {
|
||||
disabled(p.level, ({valueOf}) => valueOf(p.level) % 2 === 0);
|
||||
applyWhenValue(p.next, isNonNull, s2);
|
||||
});
|
||||
const s2: Schema<TreeData> = schema((p) => {
|
||||
disabled(p.level, ({valueOf}) => valueOf(p.level) % 2 === 0);
|
||||
applyWhenValue(p.next, isNonNull, s1);
|
||||
});
|
||||
const f = form<TreeData>(
|
||||
signal({
|
||||
level: 0,
|
||||
next: {level: 1, next: {level: 2, next: {level: 3, next: null!}}},
|
||||
}),
|
||||
s1,
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f.level().disabled()).toBe(true);
|
||||
expect(narrowed(f.next, isNonNull)()?.level().disabled()).toBe(false);
|
||||
expect(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.level().disabled()).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
narrowed(narrowed(narrowed(f.next, isNonNull)()?.next, isNonNull)()?.next, isNonNull)()
|
||||
?.level()
|
||||
.disabled(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should support recursive logic with arrays', () => {
|
||||
interface Dom {
|
||||
tag: string;
|
||||
children: Dom[];
|
||||
}
|
||||
|
||||
const domSchema = schema<Dom>((p) => {
|
||||
applyEach(p.children, domSchema);
|
||||
applyWhen(
|
||||
p.children,
|
||||
({valueOf}) => valueOf(p.tag) === 'table',
|
||||
(children) => {
|
||||
applyEach(children, (c) => {
|
||||
validate(c.tag, ({value}) =>
|
||||
value() !== 'tr' ? customError({kind: 'invalid-child'}) : undefined,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
applyWhen(
|
||||
p.children,
|
||||
({valueOf}) => valueOf(p.tag) === 'tr',
|
||||
(children) => {
|
||||
applyEach(children, (c) => {
|
||||
validate(c.tag, ({value}) =>
|
||||
value() !== 'td' ? customError({kind: 'invalid-child'}) : undefined,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const data = signal<Dom>({tag: 'div', children: [{tag: 'span', children: []}]});
|
||||
const f = form(data, domSchema, {injector: TestBed.inject(Injector)});
|
||||
expect(f().valid()).toBe(true);
|
||||
|
||||
data.set({tag: 'table', children: [{tag: 'span', children: []}]});
|
||||
expect(f().valid()).toBe(false);
|
||||
|
||||
data.set({tag: 'table', children: [{tag: 'tr', children: [{tag: 'span', children: []}]}]});
|
||||
expect(f().valid()).toBe(false);
|
||||
|
||||
data.set({tag: 'table', children: [{tag: 'tr', children: [{tag: 'td', children: []}]}]});
|
||||
expect(f().valid()).toBe(true);
|
||||
});
|
||||
});
|
||||
289
packages/forms/signals/test/node/resource.spec.ts
Normal file
289
packages/forms/signals/test/node/resource.spec.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* @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 {provideHttpClient} from '@angular/common/http';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
|
||||
import {ApplicationRef, Injector, resource, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {isNode} from '@angular/private/testing';
|
||||
|
||||
import {
|
||||
applyEach,
|
||||
customError,
|
||||
form,
|
||||
property,
|
||||
required,
|
||||
schema,
|
||||
SchemaOrSchemaFn,
|
||||
validate,
|
||||
validateAsync,
|
||||
validateHttp,
|
||||
} from '../../public_api';
|
||||
|
||||
interface Cat {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Address {
|
||||
street: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
}
|
||||
|
||||
describe('resources', () => {
|
||||
let appRef: ApplicationRef;
|
||||
let backend: HttpTestingController;
|
||||
let injector: Injector;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis['ngServerMode'] = isNode;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis['ngServerMode'] = undefined;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting()]});
|
||||
appRef = TestBed.inject(ApplicationRef);
|
||||
backend = TestBed.inject(HttpTestingController);
|
||||
injector = TestBed.inject(Injector);
|
||||
});
|
||||
|
||||
it('Takes a simple resource which reacts to data changes', async () => {
|
||||
const s: SchemaOrSchemaFn<Cat> = function (p) {
|
||||
const RES = property(p.name, ({value}) => {
|
||||
return resource({
|
||||
params: () => ({x: value()}),
|
||||
loader: async ({params}) => `got: ${params.x}`,
|
||||
});
|
||||
});
|
||||
|
||||
validate(p.name, ({state}) => {
|
||||
const remote = state.property(RES)!;
|
||||
if (remote.hasValue()) {
|
||||
return customError({message: remote.value()});
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cat = signal({name: 'cat'});
|
||||
|
||||
const f = form(cat, s, {injector});
|
||||
|
||||
await appRef.whenStable();
|
||||
expect(f.name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: cat',
|
||||
field: f.name,
|
||||
}),
|
||||
]);
|
||||
|
||||
f.name().value.set('dog');
|
||||
await appRef.whenStable();
|
||||
expect(f.name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: dog',
|
||||
field: f.name,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a resource per entry in an array', async () => {
|
||||
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
|
||||
applyEach(p, (p) => {
|
||||
const RES = property(p.name, ({value}) => {
|
||||
return resource({
|
||||
params: () => ({x: value()}),
|
||||
loader: async ({params}) => `got: ${params.x}`,
|
||||
});
|
||||
});
|
||||
|
||||
validate(p.name, ({state}) => {
|
||||
const remote = state.property(RES)!;
|
||||
if (remote.hasValue()) {
|
||||
return customError({message: remote.value()});
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const cat = signal([{name: 'cat'}, {name: 'dog'}]);
|
||||
|
||||
const f = form(cat, s, {injector});
|
||||
|
||||
await appRef.whenStable();
|
||||
expect(f[0].name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: cat',
|
||||
field: f[0].name,
|
||||
}),
|
||||
]);
|
||||
expect(f[1].name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: dog',
|
||||
field: f[1].name,
|
||||
}),
|
||||
]);
|
||||
|
||||
f[0].name().value.set('bunny');
|
||||
await appRef.whenStable();
|
||||
expect(f[0].name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: bunny',
|
||||
field: f[0].name,
|
||||
}),
|
||||
]);
|
||||
expect(f[1].name().errors()).toEqual([
|
||||
customError({
|
||||
message: 'got: dog',
|
||||
field: f[1].name,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support tree validation for resources', async () => {
|
||||
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value(),
|
||||
factory: (params) =>
|
||||
resource({
|
||||
params,
|
||||
loader: async ({params}) => {
|
||||
return params as Cat[];
|
||||
},
|
||||
}),
|
||||
errors: (cats, {fieldOf}) => {
|
||||
return cats.map((cat, index) =>
|
||||
customError({
|
||||
kind: 'meows_too_much',
|
||||
name: cat.name,
|
||||
field: fieldOf(p)[index],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const cats = signal([{name: 'Fluffy'}, {name: 'Ziggy'}]);
|
||||
const f = form(cats, s, {injector});
|
||||
|
||||
await appRef.whenStable();
|
||||
expect(f[0]().errors()).toEqual([
|
||||
customError({kind: 'meows_too_much', name: 'Fluffy', field: f[0]}),
|
||||
]);
|
||||
expect(f[1]().errors()).toEqual([
|
||||
customError({kind: 'meows_too_much', name: 'Ziggy', field: f[1]}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should support tree validation for resources', async () => {
|
||||
const s: SchemaOrSchemaFn<Cat[]> = function (p) {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value(),
|
||||
factory: (params) =>
|
||||
resource({
|
||||
params,
|
||||
loader: async ({params}) => {
|
||||
return params as Cat[];
|
||||
},
|
||||
}),
|
||||
errors: (cats, {fieldOf}) => {
|
||||
return customError({
|
||||
kind: 'meows_too_much',
|
||||
name: cats[0].name,
|
||||
field: fieldOf(p)[0],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const cats = signal([{name: 'Fluffy'}, {name: 'Ziggy'}]);
|
||||
const f = form(cats, s, {injector});
|
||||
|
||||
await appRef.whenStable();
|
||||
expect(f[0]().errors()).toEqual([
|
||||
customError({kind: 'meows_too_much', name: 'Fluffy', field: f[0]}),
|
||||
]);
|
||||
expect(f[1]().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should support shorthand http validation', async () => {
|
||||
const usernameForm = form(
|
||||
signal('unique-user'),
|
||||
(p) => {
|
||||
validateHttp(p, {
|
||||
request: ({value}) => `/api/check?username=${value()}`,
|
||||
errors: (available: boolean) =>
|
||||
available ? undefined : customError({kind: 'username-taken'}),
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
TestBed.tick();
|
||||
const req1 = backend.expectOne('/api/check?username=unique-user');
|
||||
|
||||
expect(usernameForm().valid()).toBe(false);
|
||||
expect(usernameForm().invalid()).toBe(false);
|
||||
expect(usernameForm().pending()).toBe(true);
|
||||
|
||||
req1.flush(true);
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(usernameForm().valid()).toBe(true);
|
||||
expect(usernameForm().invalid()).toBe(false);
|
||||
expect(usernameForm().pending()).toBe(false);
|
||||
expect(true).toBe(true);
|
||||
|
||||
usernameForm().value.set('taken-user');
|
||||
TestBed.tick();
|
||||
const req2 = backend.expectOne('/api/check?username=taken-user');
|
||||
|
||||
expect(usernameForm().valid()).toBe(false);
|
||||
expect(usernameForm().invalid()).toBe(false);
|
||||
expect(usernameForm().pending()).toBe(true);
|
||||
|
||||
req2.flush(false);
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(usernameForm().valid()).toBe(false);
|
||||
expect(usernameForm().invalid()).toBe(true);
|
||||
expect(usernameForm().pending()).toBe(false);
|
||||
});
|
||||
|
||||
it('should only run async validation when synchronously valid', async () => {
|
||||
const addressModel = signal<Address>({street: '', city: '', zip: ''});
|
||||
const addressSchema = schema<Address>((address) => {
|
||||
required(address.street);
|
||||
validateHttp(address, {
|
||||
request: ({value}) => ({url: '/checkaddress', params: {...value()}}),
|
||||
errors: (message: string, {fieldOf}) =>
|
||||
customError({message, field: fieldOf(address.street)}),
|
||||
});
|
||||
});
|
||||
const addressForm = form(addressModel, addressSchema, {injector});
|
||||
|
||||
TestBed.tick();
|
||||
backend.expectNone(() => true);
|
||||
|
||||
addressForm.street().value.set('123 Main St');
|
||||
|
||||
TestBed.tick();
|
||||
const req = backend.expectOne('/checkaddress?street=123%20Main%20St&city=&zip=');
|
||||
req.flush('Invalid!');
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(addressForm.street().errors()).toEqual([
|
||||
customError({message: 'Invalid!', field: addressForm.street}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
295
packages/forms/signals/test/node/submit.spec.ts
Normal file
295
packages/forms/signals/test/node/submit.spec.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* @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 {Injector, resource, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
customError,
|
||||
form,
|
||||
required,
|
||||
requiredError,
|
||||
submit,
|
||||
validateAsync,
|
||||
ValidationError,
|
||||
} from '../../public_api';
|
||||
|
||||
describe('submit', () => {
|
||||
it('fails fast on invalid form', async () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
required(name.first);
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
await submit(f, async (form) => {
|
||||
fail('Submit action should run not on invalid form');
|
||||
});
|
||||
|
||||
expect(f.first().errors()).toEqual([requiredError({field: f.first})]);
|
||||
});
|
||||
|
||||
it('should not block on pending async validators', async () => {
|
||||
const data = signal('');
|
||||
const resolvers = promiseWithResolvers();
|
||||
const f = form(
|
||||
data,
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value(),
|
||||
factory: (params) =>
|
||||
resource({
|
||||
params,
|
||||
loader: () => resolvers.promise,
|
||||
}),
|
||||
errors: () => {},
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
|
||||
const submitSpy = jasmine.createSpy();
|
||||
await submit(f, submitSpy);
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(submitSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps error to a field', async () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
await submit(f, (form) => {
|
||||
return Promise.resolve(
|
||||
customError({
|
||||
kind: 'lastName',
|
||||
field: form.last,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(f.last().errors()).toEqual([customError({kind: 'lastName', field: f.last})]);
|
||||
});
|
||||
|
||||
it('maps errors to multiple fields', async () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
|
||||
await submit(f, (form) => {
|
||||
return Promise.resolve([
|
||||
customError({
|
||||
kind: 'firstName',
|
||||
field: form.first,
|
||||
}),
|
||||
customError({
|
||||
kind: 'lastName',
|
||||
field: form.last,
|
||||
}),
|
||||
customError({
|
||||
kind: 'lastName2',
|
||||
field: form.last,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
expect(f.first().errors()).toEqual([customError({kind: 'firstName', field: f.first})]);
|
||||
expect(f.last().errors()).toEqual([
|
||||
customError({kind: 'lastName', field: f.last}),
|
||||
customError({kind: 'lastName2', field: f.last}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('can read value from field state', async () => {
|
||||
const initialValue = {first: 'meow', last: 'wuf'};
|
||||
const data = signal(initialValue);
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
const submitSpy = jasmine.createSpy('submit');
|
||||
|
||||
await submit(f, (form) => {
|
||||
submitSpy(form().value());
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith(initialValue);
|
||||
});
|
||||
|
||||
it('maps untargeted errors to form root', async () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
await submit(f, () => {
|
||||
return Promise.resolve(customError());
|
||||
});
|
||||
|
||||
expect(f().errors()).toEqual([customError({field: f})]);
|
||||
});
|
||||
|
||||
it('marks the form as submitting', async () => {
|
||||
const initialValue = {first: 'meow', last: 'wuf'};
|
||||
const data = signal(initialValue);
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {when: ({valueOf}) => valueOf(name.last) !== ''});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
expect(f().submitting()).toBe(false);
|
||||
|
||||
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
|
||||
const result = submit(f, () => promise);
|
||||
expect(f().submitting()).toBe(true);
|
||||
|
||||
resolve([]);
|
||||
await result;
|
||||
});
|
||||
|
||||
it('marks descendants as submitting', async () => {
|
||||
const initialValue = {a: {b: 12}};
|
||||
const data = signal(initialValue);
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
expect(f.a.b().submitting()).toBe(false);
|
||||
|
||||
const {promise, resolve} = promiseWithResolvers<ValidationError[]>();
|
||||
const result = submit(f, () => promise);
|
||||
expect(f.a.b().submitting()).toBe(true);
|
||||
|
||||
resolve([]);
|
||||
await result;
|
||||
});
|
||||
|
||||
it('marks the form as touched', async () => {
|
||||
const initialValue = {first: 'meow', last: 'wuf'};
|
||||
const data = signal(initialValue);
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
|
||||
expect(f().touched()).toBe(false);
|
||||
|
||||
await submit(f, async () => []);
|
||||
|
||||
expect(f().touched()).toBe(true);
|
||||
});
|
||||
|
||||
it('marks descendants as touched', async () => {
|
||||
const initialValue = {a: {b: 12}};
|
||||
const data = signal(initialValue);
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
|
||||
expect(f.a.b().touched()).toBe(false);
|
||||
|
||||
await submit(f, async () => []);
|
||||
|
||||
expect(f.a.b().touched()).toBe(true);
|
||||
});
|
||||
|
||||
it('works on child fields', async () => {
|
||||
const initialValue = {first: 'meow', last: 'wuf'};
|
||||
const data = signal(initialValue);
|
||||
const f = form(
|
||||
data,
|
||||
(name) => {
|
||||
// first name required if last name specified
|
||||
required(name.first, {
|
||||
when: ({valueOf}) => valueOf(name.last) !== '',
|
||||
});
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
const submitSpy = jasmine.createSpy('submit');
|
||||
|
||||
await submit(f.first, (form) => {
|
||||
submitSpy(form().value());
|
||||
return Promise.resolve(customError({kind: 'lastName'}));
|
||||
});
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith('meow');
|
||||
});
|
||||
|
||||
it('recovers from errors thrown by submit action', async () => {
|
||||
const f = form(signal(0), {injector: TestBed.inject(Injector)});
|
||||
expect(f().submitting()).toBe(false);
|
||||
|
||||
const {promise, reject} = promiseWithResolvers<ValidationError[]>();
|
||||
const submitPromise = submit(f, () => promise);
|
||||
expect(f().submitting()).toBe(true);
|
||||
|
||||
const error = new Error('submit failed');
|
||||
reject(error);
|
||||
await expectAsync(submitPromise).toBeRejectedWith(error);
|
||||
expect(f().submitting()).toBe(false);
|
||||
});
|
||||
|
||||
it('errors are cleared on edit', async () => {
|
||||
const data = signal({first: '', last: ''});
|
||||
const f = form(data, {injector: TestBed.inject(Injector)});
|
||||
|
||||
await submit(f, async (form) => {
|
||||
return [
|
||||
customError({kind: 'submit', field: f.first}),
|
||||
customError({kind: 'submit', field: f.last}),
|
||||
];
|
||||
});
|
||||
|
||||
expect(f.first().errors()).toEqual([customError({kind: 'submit', field: f.first})]);
|
||||
expect(f.last().errors()).toEqual([customError({kind: 'submit', field: f.last})]);
|
||||
|
||||
f.first().value.set('Hello');
|
||||
|
||||
expect(f.first().errors()).toEqual([]);
|
||||
expect(f.last().errors()).toEqual([customError({kind: 'submit', field: f.last})]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Replace with `Promise.withResolvers()` once it's available.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
|
||||
*/
|
||||
function promiseWithResolvers<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: any) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return {promise, resolve, reject};
|
||||
}
|
||||
86
packages/forms/signals/test/node/types.spec.ts
Normal file
86
packages/forms/signals/test/node/types.spec.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* @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 {WritableSignal} from '@angular/core';
|
||||
import {form, required, schema, SchemaFn} from '../../public_api';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
details: {
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PhoneOrder {
|
||||
id: string;
|
||||
details: {
|
||||
total: number;
|
||||
model: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PizzaOrder {
|
||||
id: string;
|
||||
details: {
|
||||
total: number;
|
||||
toppings: string;
|
||||
};
|
||||
}
|
||||
|
||||
function typeVerificationOnlyDoNotRunMe() {
|
||||
describe('types', () => {
|
||||
it('should apply schema function of exact type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const pizzaOrderSchema: SchemaFn<PizzaOrder> = null!;
|
||||
form(pizzaOrder, pizzaOrderSchema);
|
||||
});
|
||||
|
||||
it('should apply schema function of partial type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const orderSchema: SchemaFn<Order> = null!;
|
||||
form(pizzaOrder, orderSchema);
|
||||
});
|
||||
|
||||
it('should not apply schema function of different type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const phoneOrderSchema: SchemaFn<PhoneOrder> = null!;
|
||||
// @ts-expect-error
|
||||
form(pizzaOrder, phoneOrderSchema);
|
||||
});
|
||||
|
||||
it('should apply schema of exact type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const pizzaOrderSchema = schema<PizzaOrder>(null!);
|
||||
form(pizzaOrder, pizzaOrderSchema);
|
||||
});
|
||||
|
||||
it('should apply schema of partial type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const orderSchema = schema<Order>(null!);
|
||||
form(pizzaOrder, orderSchema);
|
||||
});
|
||||
|
||||
it('should not apply schema of different type', () => {
|
||||
const pizzaOrder: WritableSignal<PizzaOrder> = null!;
|
||||
const phoneOrderSchema = schema<PhoneOrder>(null!);
|
||||
// @ts-expect-error
|
||||
form(pizzaOrder, phoneOrderSchema);
|
||||
});
|
||||
|
||||
it('should not allow binding logic to a potentially undefined field', () => {
|
||||
schema<{a: number; b: number | undefined; c?: number}>((p) => {
|
||||
required(p.a);
|
||||
// @ts-expect-error
|
||||
required(p.b);
|
||||
// @ts-expect-error
|
||||
required(p.c);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
587
packages/forms/signals/test/node/validation_status.spec.ts
Normal file
587
packages/forms/signals/test/node/validation_status.spec.ts
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
/**
|
||||
* @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, Injector, Resource, resource, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
customError,
|
||||
Field,
|
||||
form,
|
||||
NgValidationError,
|
||||
patternError,
|
||||
requiredError,
|
||||
validate,
|
||||
validateAsync,
|
||||
validateTree,
|
||||
ValidationError,
|
||||
type WithoutField,
|
||||
} from '../../public_api';
|
||||
|
||||
function validateValue(value: string): WithoutField<ValidationError>[] {
|
||||
return value === 'INVALID' ? [customError()] : [];
|
||||
}
|
||||
|
||||
function validateValueForChild(
|
||||
value: string,
|
||||
field: Field<unknown> | undefined,
|
||||
): ValidationError[] {
|
||||
return value === 'INVALID' ? [customError({field})] : [];
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, count = 100): Promise<void> {
|
||||
while (!fn()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
if (--count === 0) {
|
||||
throw Error('waitFor timeout');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('validation status', () => {
|
||||
let injector: Injector;
|
||||
let appRef: ApplicationRef;
|
||||
|
||||
beforeEach(() => {
|
||||
injector = TestBed.inject(Injector);
|
||||
appRef = TestBed.inject(ApplicationRef);
|
||||
});
|
||||
|
||||
describe('single-field validator', () => {
|
||||
it('should affect field validity', () => {
|
||||
const f = form(
|
||||
signal('VALID'),
|
||||
(p) => {
|
||||
validate(p, ({value}) => validateValue(value()));
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f().value.set('INVALID');
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('validity should flow from child to parent', () => {
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validate(p.child, ({value}) => validateValue(value()));
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('validity should not flow from parent to child', () => {
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validate(p, ({value}) => validateValue(value().child));
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f.child().valid()).toBe(true);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
expect(f.child().valid()).toBe(true);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tree validator', () => {
|
||||
it('should affect validity of host field if no target specified', () => {
|
||||
const f = form(
|
||||
signal('VALID'),
|
||||
(p) => {
|
||||
validateTree(p, ({value}) => validateValueForChild(value(), undefined));
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f().value.set('INVALID');
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should affect validity of targeted field', () => {
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validateTree(p, ({value, fieldOf}) =>
|
||||
validateValueForChild(value().child, fieldOf(p.child)),
|
||||
);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f.child().valid()).toBe(true);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
expect(f.child().valid()).toBe(false);
|
||||
expect(f.child().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('validity should flow from child to parent', () => {
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validateTree(p, ({value, fieldOf}) =>
|
||||
validateValueForChild(value().child, fieldOf(p.child)),
|
||||
);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not affect sibling validity', () => {
|
||||
const f = form(
|
||||
signal({child: 'VALID', sibling: ''}),
|
||||
(p) => {
|
||||
validateTree(p, ({value, fieldOf}) =>
|
||||
validateValueForChild(value().child, fieldOf(p.child)),
|
||||
);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f.sibling().valid()).toBe(true);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
expect(f.sibling().valid()).toBe(true);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('async validator', () => {
|
||||
it('should affect validity of host field if no target specified', async () => {
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal('VALID'),
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value(),
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: ({params}) =>
|
||||
new Promise<ValidationError[]>((r) =>
|
||||
setTimeout(() => r(validateValueForChild(params, undefined))),
|
||||
),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f().value.set('INVALID');
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should affect validity of targeted field', async () => {
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value().child,
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: ({params}) =>
|
||||
new Promise<ValidationError[]>((r) =>
|
||||
setTimeout(() => r(validateValueForChild(params, undefined))),
|
||||
),
|
||||
})),
|
||||
errors: (errs, {fieldOf}) =>
|
||||
errs.map((e) => ({
|
||||
...e,
|
||||
field: fieldOf(p.child),
|
||||
})),
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f.child().pending()).toBe(true);
|
||||
expect(f.child().valid()).toBe(false);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f.child().pending()).toBe(false);
|
||||
expect(f.child().valid()).toBe(true);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f.child().pending()).toBe(true);
|
||||
expect(f.child().valid()).toBe(false);
|
||||
expect(f.child().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f.child().pending()).toBe(false);
|
||||
expect(f.child().valid()).toBe(false);
|
||||
expect(f.child().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('validity should flow from child to parent', async () => {
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value().child,
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: ({params}) =>
|
||||
new Promise<ValidationError[]>((r) =>
|
||||
setTimeout(() => r(validateValueForChild(params, undefined))),
|
||||
),
|
||||
})),
|
||||
errors: (errs, {fieldOf}) =>
|
||||
errs.map((e) => ({
|
||||
...e,
|
||||
field: fieldOf(p.child),
|
||||
})),
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('pending should flow from parent to child', async () => {
|
||||
// We can't guarantee the parent won't assign a tree error to the sibling field, so the
|
||||
// sibling must inherit the pending state from the parent.
|
||||
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal({child: 'VALID', sibling: ''}),
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: ({value}) => value().child,
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: ({params}) =>
|
||||
new Promise<ValidationError[]>((r) =>
|
||||
setTimeout(() => r(validateValueForChild(params, undefined))),
|
||||
),
|
||||
})),
|
||||
errors: (errs, {fieldOf}) =>
|
||||
errs.map((e) => ({
|
||||
...e,
|
||||
field: fieldOf(p.child),
|
||||
})),
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f.sibling().pending()).toBe(true);
|
||||
expect(f.sibling().valid()).toBe(false);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f.sibling().pending()).toBe(false);
|
||||
expect(f.sibling().valid()).toBe(true);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f.sibling().pending()).toBe(true);
|
||||
expect(f.sibling().valid()).toBe(false);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f.sibling().pending()).toBe(false);
|
||||
expect(f.sibling().valid()).toBe(true);
|
||||
expect(f.sibling().invalid()).toBe(false);
|
||||
});
|
||||
|
||||
it('parent should be pending/invalid while child is pending/invalid', async () => {
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal({child: 'VALID'}),
|
||||
(p) => {
|
||||
validateAsync(p.child, {
|
||||
params: ({value}) => value(),
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: ({params}) =>
|
||||
new Promise<ValidationError[]>((r) =>
|
||||
setTimeout(() => r(validateValueForChild(params, undefined))),
|
||||
),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(true);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
f.child().value.set('INVALID');
|
||||
await waitFor(() => res?.isLoading());
|
||||
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
|
||||
await appRef.whenStable();
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple validators', () => {
|
||||
it('should be invalid status when validators are mix of valid and invalid', () => {
|
||||
const f = form(
|
||||
signal('MIXED'),
|
||||
(p) => {
|
||||
validate(p, () => []);
|
||||
validate(p, () => [customError()]);
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
expect(f().pending()).toBe(false);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be pending status when validators are mix of valid and pending', async () => {
|
||||
let res: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal('MIXED'),
|
||||
(p) => {
|
||||
validate(p, () => []);
|
||||
validateAsync(p, {
|
||||
params: () => [],
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]))),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => res?.isLoading());
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(false);
|
||||
});
|
||||
|
||||
it('should be invalid status when validators are mix of invalid and pending', async () => {
|
||||
let res: Resource<unknown>;
|
||||
let res2: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal('MIXED'),
|
||||
(p) => {
|
||||
validateAsync(p, {
|
||||
params: () => [],
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: () =>
|
||||
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
validateAsync(p, {
|
||||
params: () => [],
|
||||
factory: (params) =>
|
||||
(res2 = resource({
|
||||
params,
|
||||
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => !res?.isLoading() && res2.isLoading());
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be invalid status when validators are mix of valid, invalid, and pending', async () => {
|
||||
let res: Resource<unknown>;
|
||||
let res2: Resource<unknown>;
|
||||
|
||||
const f = form(
|
||||
signal('MIXED'),
|
||||
(p) => {
|
||||
validate(p, () => []);
|
||||
validateAsync(p, {
|
||||
params: () => [],
|
||||
factory: (params) =>
|
||||
(res = resource({
|
||||
params,
|
||||
loader: () =>
|
||||
new Promise<ValidationError[]>((r) => setTimeout(() => r([customError()]))),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
validateAsync(p, {
|
||||
params: () => [],
|
||||
factory: (params) =>
|
||||
(res2 = resource({
|
||||
params,
|
||||
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]), 10)),
|
||||
})),
|
||||
errors: (errs) => errs,
|
||||
});
|
||||
},
|
||||
{injector},
|
||||
);
|
||||
|
||||
await waitFor(() => !res?.isLoading() && res2.isLoading());
|
||||
expect(f().pending()).toBe(true);
|
||||
expect(f().valid()).toBe(false);
|
||||
expect(f().invalid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NgValidationError', () => {
|
||||
it('instanceof should check if structure matches a standard error type', () => {
|
||||
const e1 = requiredError();
|
||||
expect(e1 instanceof NgValidationError).toBe(true);
|
||||
const e2 = customError({kind: 'min', min: 'two'});
|
||||
expect(e2 instanceof NgValidationError).toBe(false);
|
||||
const e3 = patternError(/.*@.*\.com/);
|
||||
expect(e3 instanceof NgValidationError).toBe(true);
|
||||
});
|
||||
|
||||
it('instanceof should narrow the type to a discriminated union', () => {
|
||||
const e: unknown = undefined;
|
||||
if (e instanceof NgValidationError) {
|
||||
e.message;
|
||||
switch (e.kind) {
|
||||
case 'min':
|
||||
e.min;
|
||||
break;
|
||||
case 'standardSchema':
|
||||
e.issue;
|
||||
break;
|
||||
// @ts-expect-error
|
||||
case 'fakekind':
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Just so we have an expectation in the test,
|
||||
// the real goal is to test the type narrowing above.
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
24
packages/forms/signals/test/web/BUILD.bazel
Normal file
24
packages/forms/signals/test/web/BUILD.bazel
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
load("//tools:defaults.bzl", "ng_project", "zoneless_web_test_suite")
|
||||
|
||||
ng_project(
|
||||
name = "test_lib",
|
||||
testonly = True,
|
||||
srcs = glob(["**/*.spec.ts"]),
|
||||
deps = [
|
||||
"//:node_modules/zod",
|
||||
"//packages/core",
|
||||
"//packages/core/testing",
|
||||
"//packages/forms",
|
||||
"//packages/forms/signals",
|
||||
"//packages/platform-browser",
|
||||
"//packages/platform-browser/testing",
|
||||
"//packages/private/testing",
|
||||
],
|
||||
)
|
||||
|
||||
zoneless_web_test_suite(
|
||||
name = "test",
|
||||
deps = [
|
||||
":test_lib",
|
||||
],
|
||||
)
|
||||
561
packages/forms/signals/test/web/control_directive.spec.ts
Normal file
561
packages/forms/signals/test/web/control_directive.spec.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
/**
|
||||
* @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 {
|
||||
Component,
|
||||
ElementRef,
|
||||
Injector,
|
||||
input,
|
||||
inputBinding,
|
||||
model,
|
||||
provideZonelessChangeDetection,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {
|
||||
Control,
|
||||
disabled,
|
||||
form,
|
||||
hidden,
|
||||
max,
|
||||
MAX,
|
||||
maxLength,
|
||||
min,
|
||||
minLength,
|
||||
readonly,
|
||||
required,
|
||||
type DisabledReason,
|
||||
type Field,
|
||||
type FormCheckboxControl,
|
||||
type FormValueControl,
|
||||
} from '../../public_api';
|
||||
|
||||
@Component({
|
||||
selector: 'string-control',
|
||||
template: `<input [control]="control()"/>`,
|
||||
imports: [Control],
|
||||
})
|
||||
class TestStringControl {
|
||||
readonly control = input.required<Field<string>>();
|
||||
readonly controlDirective = viewChild.required(Control);
|
||||
}
|
||||
|
||||
describe('control directive', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideZonelessChangeDetection()],
|
||||
});
|
||||
});
|
||||
|
||||
it('synchronizes a basic form with a custom control', () => {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `
|
||||
<input [control]="f">
|
||||
`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal('test'));
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fix.nativeElement.firstChild as HTMLInputElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
// Initial state
|
||||
expect(input.value).toBe('test');
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set('testing'));
|
||||
expect(input.value).toBe('testing');
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
input.value = 'typing';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe('typing');
|
||||
});
|
||||
|
||||
it('synchronizes with a checkbox control', () => {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `<input type="checkbox" [control]="f">`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal(false));
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fix.nativeElement.firstChild as HTMLInputElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
// Initial state
|
||||
expect(input.checked).toBe(false);
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set(true));
|
||||
expect(input.checked).toBe(true);
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe(false);
|
||||
});
|
||||
|
||||
it('synchronizes with a radio group', () => {
|
||||
const {cmp, inputA, inputB, inputC} = setupRadioGroup();
|
||||
|
||||
// All the inputs should have the same name.
|
||||
expect(inputA.name).toBe('test');
|
||||
expect(inputB.name).toBe('test');
|
||||
expect(inputC.name).toBe('test');
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set('c'));
|
||||
expect(inputA.checked).toBe(false);
|
||||
expect(inputB.checked).toBe(false);
|
||||
expect(inputC.checked).toBe(true);
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
inputB.click();
|
||||
});
|
||||
expect(inputA.checked).toBe(false);
|
||||
expect(inputB.checked).toBe(true);
|
||||
expect(inputC.checked).toBe(false);
|
||||
expect(cmp.f().value()).toBe('b');
|
||||
});
|
||||
|
||||
it('synchronizes with a textarea', () => {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `<textarea #textarea [control]="f"></textarea>`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal(''));
|
||||
textarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('textarea');
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const textarea = fix.componentInstance.textarea().nativeElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
expect(textarea.value).toEqual('');
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set('hello'));
|
||||
expect(textarea.value).toEqual('hello');
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
textarea.value = 'hi';
|
||||
textarea.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe('hi');
|
||||
});
|
||||
|
||||
it('synchronizes with a select', () => {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `
|
||||
<select #select [control]="f">
|
||||
<option value="one">One</option>
|
||||
<option value="two">Two</option>
|
||||
<option value="three">Three</option>
|
||||
</select>
|
||||
`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal('invalid'));
|
||||
select = viewChild.required<ElementRef<HTMLSelectElement>>('select');
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const select = fix.componentInstance.select().nativeElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
expect(select.value).toEqual('');
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set('one'));
|
||||
expect(select.value).toEqual('one');
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
select.value = 'two';
|
||||
select.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe('two');
|
||||
});
|
||||
|
||||
it('should assign correct value when unhiding select', () => {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `
|
||||
@if (!f().hidden()) {
|
||||
<select #select [control]="f">
|
||||
@for(opt of options; track opt) {
|
||||
<option [value]="opt">{{opt}}</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal(''), (p) => hidden(p, ({value}) => value() === ''));
|
||||
select = viewChild<ElementRef<HTMLSelectElement>>('select');
|
||||
options = ['one', 'two', 'three'];
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
expect(fix.componentInstance.select()).toBeUndefined();
|
||||
|
||||
act(() => cmp.f().value.set('two'));
|
||||
expect(fix.componentInstance.select()).not.toBeUndefined();
|
||||
expect(fix.componentInstance.select()!.nativeElement.value).toEqual('two');
|
||||
});
|
||||
|
||||
it('synchronizes with a custom value control', () => {
|
||||
@Component({
|
||||
selector: 'my-input',
|
||||
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
|
||||
})
|
||||
class CustomInput implements FormValueControl<string> {
|
||||
value = model('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Control, CustomInput],
|
||||
template: `<my-input [control]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form<string>(signal('test'));
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fix.nativeElement.firstChild.firstChild as HTMLInputElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
// Initial state
|
||||
expect(input.value).toBe('test');
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set('testing'));
|
||||
expect(input.value).toBe('testing');
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
input.value = 'typing';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe('typing');
|
||||
});
|
||||
|
||||
it('initializes a required value input before the component lifecycle runs', () => {
|
||||
let initialValue: string | undefined = undefined;
|
||||
@Component({
|
||||
selector: 'my-input',
|
||||
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
|
||||
})
|
||||
class CustomInput implements FormValueControl<string> {
|
||||
value = model.required<string>();
|
||||
|
||||
ngOnInit(): void {
|
||||
initialValue = this.value();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Control, CustomInput],
|
||||
template: `<my-input [control]="f" value />`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form<string>(signal('test'));
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
expect(initialValue as string | undefined).toBe('test');
|
||||
});
|
||||
|
||||
it('synchronizes with a custom checkbox control', () => {
|
||||
@Component({
|
||||
selector: 'my-input',
|
||||
template:
|
||||
'<input #i type="checkbox" [checked]="checked()" (input)="checked.set(i.checked)" />',
|
||||
})
|
||||
class CustomInput implements FormCheckboxControl {
|
||||
checked = model(false);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Control, CustomInput],
|
||||
template: `<my-input [control]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal(false));
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fix.nativeElement.firstChild.firstChild as HTMLInputElement;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
// Initial state
|
||||
expect(input.checked).toBe(false);
|
||||
|
||||
// Model -> View
|
||||
act(() => cmp.f().value.set(true));
|
||||
expect(input.checked).toBe(true);
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(cmp.f().value()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not interfere with a component which accepts a control input directly', () => {
|
||||
@Component({
|
||||
selector: 'my-wrapper',
|
||||
template: `{{ control()().value() }}`,
|
||||
})
|
||||
class WrapperCmp {
|
||||
readonly control = input.required<Field<string>>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<my-wrapper [control]="f" />`,
|
||||
imports: [WrapperCmp, Control],
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal('test'));
|
||||
}
|
||||
|
||||
const el = act(() => TestBed.createComponent(TestCmp)).nativeElement;
|
||||
expect(el.textContent).toBe('test');
|
||||
});
|
||||
|
||||
it('should update bound controls on the field when it is bound and unbound', async () => {
|
||||
const f = form(signal(''), {injector: TestBed.inject(Injector)});
|
||||
expect(f().controls()).toEqual([]);
|
||||
|
||||
const fixture = act(() =>
|
||||
TestBed.createComponent(TestStringControl, {
|
||||
bindings: [inputBinding('control', () => f)],
|
||||
}),
|
||||
);
|
||||
expect(f().controls()).toEqual([fixture.componentInstance.controlDirective()]);
|
||||
|
||||
act(() => fixture.destroy());
|
||||
expect(f().controls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should track multiple bound controls per field', async () => {
|
||||
const f = form(signal(''), {injector: TestBed.inject(Injector)});
|
||||
const fixture1 = act(() =>
|
||||
TestBed.createComponent(TestStringControl, {
|
||||
bindings: [inputBinding('control', () => f)],
|
||||
}),
|
||||
);
|
||||
const fixture2 = act(() =>
|
||||
TestBed.createComponent(TestStringControl, {
|
||||
bindings: [inputBinding('control', () => f)],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(f().controls()).toEqual([
|
||||
fixture1.componentInstance.controlDirective(),
|
||||
fixture2.componentInstance.controlDirective(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should update bound controls on both fields when field binding changes', async () => {
|
||||
const f1 = form(signal(''), {injector: TestBed.inject(Injector)});
|
||||
const f2 = form(signal(''), {injector: TestBed.inject(Injector)});
|
||||
const control = signal(f1);
|
||||
const fixture = act(() =>
|
||||
TestBed.createComponent(TestStringControl, {
|
||||
bindings: [inputBinding('control', control)],
|
||||
}),
|
||||
);
|
||||
expect(f1().controls()).toEqual([fixture.componentInstance.controlDirective()]);
|
||||
expect(f2().controls()).toEqual([]);
|
||||
|
||||
act(() => control.set(f2));
|
||||
expect(f1().controls()).toEqual([]);
|
||||
expect(f2().controls()).toEqual([fixture.componentInstance.controlDirective()]);
|
||||
});
|
||||
|
||||
it('should synchronize custom properties', () => {
|
||||
@Component({
|
||||
template: `
|
||||
<input #text type="text" [control]="f.text">
|
||||
<input #number type="number" [control]="f.number">
|
||||
`,
|
||||
imports: [Control],
|
||||
})
|
||||
class CustomPropsTestCmp {
|
||||
textInput = viewChild.required<ElementRef<HTMLInputElement>>('text');
|
||||
numberInput = viewChild.required<ElementRef<HTMLInputElement>>('number');
|
||||
data = signal({
|
||||
number: 0,
|
||||
text: '',
|
||||
});
|
||||
f = form(this.data, (p) => {
|
||||
required(p.text);
|
||||
minLength(p.text, 0);
|
||||
maxLength(p.text, 100);
|
||||
min(p.number, 0);
|
||||
max(p.number, 100);
|
||||
});
|
||||
}
|
||||
|
||||
const comp = act(() => TestBed.createComponent(CustomPropsTestCmp)).componentInstance;
|
||||
|
||||
expect(comp.f.number().property(MAX)()).toBe(100);
|
||||
expect(comp.textInput().nativeElement.required).toBe(true);
|
||||
expect(comp.textInput().nativeElement.minLength).toBe(0);
|
||||
expect(comp.textInput().nativeElement.maxLength).toBe(100);
|
||||
expect(comp.numberInput().nativeElement.required).toBe(false);
|
||||
expect(comp.numberInput().nativeElement.min).toBe('0');
|
||||
expect(comp.numberInput().nativeElement.max).toBe('100');
|
||||
});
|
||||
|
||||
it('should synchronize readonly', () => {
|
||||
@Component({
|
||||
template: `
|
||||
<input #text type="text" [control]="f">
|
||||
`,
|
||||
imports: [Control],
|
||||
})
|
||||
class ReadonlyTestCmp {
|
||||
textInput = viewChild.required<ElementRef<HTMLInputElement>>('text');
|
||||
data = signal('');
|
||||
f = form(this.data, (p) => {
|
||||
readonly(p);
|
||||
});
|
||||
}
|
||||
|
||||
const comp = act(() => TestBed.createComponent(ReadonlyTestCmp)).componentInstance;
|
||||
|
||||
expect(comp.textInput().nativeElement.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('should synchronize disabled reasons', () => {
|
||||
@Component({
|
||||
selector: 'my-input',
|
||||
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
|
||||
})
|
||||
class CustomInput implements FormValueControl<string> {
|
||||
value = model('');
|
||||
disabledReasons = input<readonly DisabledReason[]>([]);
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<my-input [control]="f" />
|
||||
`,
|
||||
imports: [CustomInput, Control],
|
||||
})
|
||||
class ReadonlyTestCmp {
|
||||
myInput = viewChild.required<CustomInput>(CustomInput);
|
||||
data = signal('');
|
||||
f = form(this.data, (p) => {
|
||||
disabled(p, () => 'Currently unavailable');
|
||||
});
|
||||
}
|
||||
|
||||
const comp = act(() => TestBed.createComponent(ReadonlyTestCmp)).componentInstance;
|
||||
|
||||
expect(comp.myInput().disabledReasons()).toEqual([
|
||||
{message: 'Currently unavailable', field: comp.f},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should synchronize with custom control touched status', () => {
|
||||
@Component({
|
||||
selector: 'my-input',
|
||||
template: '<input #i [value]="value()" (input)="value.set(i.value)" />',
|
||||
})
|
||||
class CustomInput implements FormValueControl<string> {
|
||||
value = model('');
|
||||
touched = model(false);
|
||||
|
||||
touchIt() {
|
||||
this.touched.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Control, CustomInput],
|
||||
template: `<my-input [control]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form<string>(signal('test'));
|
||||
myInput = viewChild.required(CustomInput);
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const field = fix.componentInstance.f;
|
||||
const myInput = fix.componentInstance.myInput();
|
||||
|
||||
// Initial state
|
||||
expect(field().touched()).toBe(false);
|
||||
|
||||
// View -> Model
|
||||
act(() => {
|
||||
myInput.touchIt();
|
||||
});
|
||||
expect(field().touched()).toBe(true);
|
||||
|
||||
// Model -> View
|
||||
act(() => field().reset());
|
||||
expect(myInput.touched()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function setupRadioGroup() {
|
||||
@Component({
|
||||
imports: [Control],
|
||||
template: `
|
||||
<form>
|
||||
<input type="radio" value="a" [control]="f">
|
||||
<input type="radio" value="b" [control]="f">
|
||||
<input type="radio" value="c" [control]="f">
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal('a'), {
|
||||
name: 'test',
|
||||
});
|
||||
}
|
||||
|
||||
const fix = act(() => TestBed.createComponent(TestCmp));
|
||||
const formEl = (fix.nativeElement as HTMLElement).firstChild as HTMLFormElement;
|
||||
const inputs = Array.from(formEl.children) as HTMLInputElement[];
|
||||
|
||||
const [inputA, inputB, inputC] = inputs;
|
||||
const cmp = fix.componentInstance as TestCmp;
|
||||
|
||||
return {cmp, inputA, inputB, inputC};
|
||||
}
|
||||
|
||||
function act<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
TestBed.tick();
|
||||
}
|
||||
}
|
||||
|
|
@ -123,6 +123,9 @@ importers:
|
|||
'@schematics/angular':
|
||||
specifier: 21.0.0-next.1
|
||||
version: 21.0.0-next.1(chokidar@4.0.3)
|
||||
'@standard-schema/spec':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
'@types/angular':
|
||||
specifier: ^1.6.47
|
||||
version: 1.8.9
|
||||
|
|
@ -502,6 +505,9 @@ importers:
|
|||
vrsource-tslint-rules:
|
||||
specifier: 6.0.0
|
||||
version: 6.0.0(tslint@6.1.3(typescript@5.9.2))(typescript@5.9.2)
|
||||
zod:
|
||||
specifier: ^4.0.10
|
||||
version: 4.1.4
|
||||
|
||||
adev:
|
||||
dependencies:
|
||||
|
|
@ -3896,6 +3902,9 @@ packages:
|
|||
'@stackblitz/sdk@1.11.0':
|
||||
resolution: {integrity: sha512-DFQGANNkEZRzFk1/rDP6TcFdM82ycHE+zfl9C/M/jXlH68jiqHWHFMQURLELoD8koxvu/eW5uhg94NSAZlYrUQ==}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -11081,6 +11090,9 @@ packages:
|
|||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.1.4:
|
||||
resolution: {integrity: sha512-2YqJuWkU6IIK9qcE4k1lLLhyZ6zFw7XVRdQGpV97jEIZwTrscUw+DY31Xczd8nwaoksyJUIxCojZXwckJovWxA==}
|
||||
|
||||
zone.js@0.15.1:
|
||||
resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==}
|
||||
|
||||
|
|
@ -14765,6 +14777,8 @@ snapshots:
|
|||
|
||||
'@stackblitz/sdk@1.11.0': {}
|
||||
|
||||
'@standard-schema/spec@1.0.0': {}
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
|
|
@ -23327,6 +23341,8 @@ snapshots:
|
|||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.1.4: {}
|
||||
|
||||
zone.js@0.15.1: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue