diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md new file mode 100644 index 00000000000..daae535de6e --- /dev/null +++ b/goldens/public-api/forms/signals/index.api.md @@ -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 { + // (undocumented) + readonly getInitial: () => TAcc; + // (undocumented) + readonly reduce: (acc: TAcc, item: TItem) => TAcc; +} + +// @public +export function aggregateProperty(path: FieldPath, prop: AggregateProperty, logic: NoInfer>): void; + +// @public +export function andProperty(): AggregateProperty; + +// @public +export function apply(path: FieldPath, schema: NoInfer>): void; + +// @public +export function applyEach(path: FieldPath, schema: NoInfer>): void; + +// @public +export function applyWhen(path: FieldPath, logic: LogicFn, schema: NoInfer>): void; + +// @public +export function applyWhenValue(path: FieldPath, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn): void; + +// @public +export function applyWhenValue(path: FieldPath, predicate: (value: TValue) => boolean, schema: NoInfer>): void; + +// @public +export type AsyncValidationResult = ValidationResult | 'pending'; + +// @public +export interface AsyncValidatorOptions { + readonly errors: MapToErrorsFn; + readonly factory: (params: Signal) => ResourceRef; + readonly params: (ctx: FieldContext) => TParams; +} + +// @public +export interface ChildFieldContext extends RootFieldContext { + readonly key: Signal; +} + +// @public +export class Control { + get cva(): ControlValueAccessor | undefined; + readonly cvaArray: ControlValueAccessor[] | null; + readonly el: ElementRef; + readonly field: WritableSignal>; + // (undocumented) + set _field(value: Field); + get ngControl(): NgControl; + readonly state: Signal>; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration, "[control]", never, { "_field": { "alias": "control"; "required": true; }; }, {}, never, never, true, never>; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration, never>; +} + +// @public +export function createProperty(): Property; + +// @public +export function customError, typeof BRAND>>(obj: WithField): CustomValidationError; + +// @public +export function customError, typeof BRAND>>(obj?: E): WithoutField; + +// @public +export class CustomValidationError extends ValidationError { + [key: PropertyKey]: unknown; +} + +// @public +export function disabled(path: FieldPath, logic?: string | NoInfer>): void; + +// @public +export interface DisabledReason { + readonly field: Field; + readonly message?: string; +} + +// @public +export function email(path: FieldPath, config?: BaseValidatorConfig): void; + +// @public +export function emailError(options: WithField): EmailValidationError; + +// @public +export function emailError(options?: ValidationErrorOptions): WithoutField; + +// @public +export class EmailValidationError extends _NgValidationError { + // (undocumented) + readonly kind = "email"; +} + +// @public +export type Field = (() => FieldState) & (TValue extends Array ? ReadonlyArrayLike> : TValue extends Record ? Subfields : unknown); + +// @public +export type FieldContext = TPathKind extends PathKind.Item ? ItemFieldContext : TPathKind extends PathKind.Child ? ChildFieldContext : RootFieldContext; + +// @public +export type FieldPath = { + [ɵɵTYPE]: [TValue, TPathKind]; +} & (TValue extends Array ? unknown : TValue extends Record ? { + [K in keyof TValue]: MaybeFieldPath; +} : unknown); + +// @public +export interface FieldState { + readonly controls: Signal[]>; + readonly dirty: Signal; + readonly disabled: Signal; + readonly disabledReasons: Signal; + readonly errors: Signal; + readonly errorSummary: Signal; + hasProperty(key: Property | AggregateProperty): boolean; + readonly hidden: Signal; + readonly invalid: Signal; + readonly keyInParent: Signal; + markAsDirty(): void; + markAsTouched(): void; + readonly name: Signal; + readonly pending: Signal; + property(prop: AggregateProperty): Signal; + property(prop: Property): M | undefined; + readonly readonly: Signal; + reset(): void; + readonly submitting: Signal; + readonly touched: Signal; + readonly valid: Signal; + readonly value: WritableSignal; +} + +// @public +export type FieldValidationResult = ValidationSuccess | OneOrMany>; + +// @public +export type FieldValidator = LogicFn; + +// @public +export function form(model: WritableSignal): Field; + +// @public +export function form(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | FormOptions): Field; + +// @public +export function form(model: WritableSignal, schema: SchemaOrSchemaFn, options: FormOptions): Field; + +// @public +export interface FormCheckboxControl extends FormUiControl { + readonly checked: ModelSignal; + readonly value?: undefined; +} + +// @public +export interface FormOptions { + adapter?: FieldAdapter; + injector?: Injector; + // (undocumented) + name?: string; +} + +// @public +export interface FormUiControl { + readonly dirty?: InputSignal; + readonly disabled?: InputSignal; + readonly disabledReasons?: InputSignal; + readonly errors?: InputSignal; + readonly hidden?: InputSignal; + readonly invalid?: InputSignal; + readonly max?: InputSignal; + readonly maxLength?: InputSignal; + readonly min?: InputSignal; + readonly minLength?: InputSignal; + readonly name?: InputSignal; + readonly pattern?: InputSignal; + readonly pending?: InputSignal; + readonly readonly?: InputSignal; + readonly required?: InputSignal; + readonly touched?: ModelSignal | InputSignal | OutputRef; +} + +// @public +export interface FormValueControl extends FormUiControl { + readonly checked?: undefined; + readonly value: ModelSignal; +} + +// @public +export function hidden(path: FieldPath, logic: NoInfer>): void; + +// @public +export interface HttpValidatorOptions { + readonly errors: MapToErrorsFn; + readonly options?: HttpResourceOptions; + readonly request: ((ctx: FieldContext) => string | undefined) | ((ctx: FieldContext) => HttpResourceRequest | undefined); +} + +// @public +export class InteropNgControl implements Pick, Pick, InteropSharedKeys | 'hasValidator'> { + constructor(field: () => FieldState); + // (undocumented) + readonly control: AbstractControl; + // (undocumented) + get dirty(): boolean; + // (undocumented) + get disabled(): boolean; + // (undocumented) + get enabled(): boolean; + // (undocumented) + get errors(): ValidationErrors | null; + // (undocumented) + protected field: () => FieldState; + // (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 extends ChildFieldContext { + readonly index: Signal; +} + +// @public +export function listProperty(): AggregateProperty; + +// @public +export type LogicFn = (ctx: FieldContext) => TReturn; + +// @public +export type MapToErrorsFn = (result: TResult, ctx: FieldContext) => TreeValidationResult; + +// @public +export const MAX: AggregateProperty; + +// @public +export function max(path: FieldPath, maxValue: number | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export const MAX_LENGTH: AggregateProperty; + +// @public +export function maxError(max: number, options: WithField): MaxValidationError; + +// @public +export function maxError(max: number, options?: ValidationErrorOptions): WithoutField; + +// @public +export function maxLength(path: FieldPath, maxLength: number | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export function maxLengthError(maxLength: number, options: WithField): MaxLengthValidationError; + +// @public +export function maxLengthError(maxLength: number, options?: ValidationErrorOptions): WithoutField; + +// @public +export class MaxLengthValidationError extends _NgValidationError { + constructor(maxLength: number, options?: ValidationErrorOptions); + // (undocumented) + readonly kind = "maxLength"; + // (undocumented) + readonly maxLength: number; +} + +// @public +export function maxProperty(): AggregateProperty; + +// @public +export class MaxValidationError extends _NgValidationError { + constructor(max: number, options?: ValidationErrorOptions); + // (undocumented) + readonly kind = "max"; + // (undocumented) + readonly max: number; +} + +// @public +export type MaybeField = (TValue & undefined) | Field, TKey>; + +// @public +export type MaybeFieldPath = (TValue & undefined) | FieldPath, TPathKind>; + +// @public +export const MIN: AggregateProperty; + +// @public +export function min(path: FieldPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export const MIN_LENGTH: AggregateProperty; + +// @public +export function minError(min: number, options: WithField): MinValidationError; + +// @public +export function minError(min: number, options?: ValidationErrorOptions): WithoutField; + +// @public +export function minLength(path: FieldPath, minLength: number | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export function minLengthError(minLength: number, options: WithField): MinLengthValidationError; + +// @public +export function minLengthError(minLength: number, options?: ValidationErrorOptions): WithoutField; + +// @public +export class MinLengthValidationError extends _NgValidationError { + constructor(minLength: number, options?: ValidationErrorOptions); + // (undocumented) + readonly kind = "minLength"; + // (undocumented) + readonly minLength: number; +} + +// @public +export function minProperty(): AggregateProperty; + +// @public +export class MinValidationError extends _NgValidationError { + constructor(min: number, options?: ValidationErrorOptions); + // (undocumented) + readonly kind = "min"; + // (undocumented) + readonly min: number; +} + +// @public +export type Mutable = { + -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 | readonly T[]; + +// @public +export function orProperty(): AggregateProperty; + +// @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; + +// @public +export function pattern(path: FieldPath, pattern: RegExp | LogicFn, config?: BaseValidatorConfig): void; + +// @public +export function patternError(pattern: RegExp, options: WithField): PatternValidationError; + +// @public +export function patternError(pattern: RegExp, options?: ValidationErrorOptions): WithoutField; + +// @public +export class PatternValidationError extends _NgValidationError { + constructor(pattern: RegExp, options?: ValidationErrorOptions); + // (undocumented) + readonly kind = "pattern"; + // (undocumented) + readonly pattern: RegExp; +} + +// @public +export class Property { +} + +// @public +export function property(path: FieldPath, factory: (ctx: FieldContext) => TData): Property; + +// @public +export function property(path: FieldPath, prop: Property, factory: (ctx: FieldContext) => TData): Property; + +// @public +export function readonly(path: FieldPath, logic?: NoInfer>): void; + +// @public +export type ReadonlyArrayLike = Pick, number | 'length' | typeof Symbol.iterator>; + +// @public +export function reducedProperty(reduce: (acc: TAcc, item: TItem) => TAcc, getInitial: () => TAcc): AggregateProperty; + +// @public +export const REQUIRED: AggregateProperty; + +// @public +export function required(path: FieldPath, config?: BaseValidatorConfig & { + emptyPredicate?: (value: TValue) => boolean; + when?: NoInfer>; +}): void; + +// @public +export function requiredError(options: WithField): RequiredValidationError; + +// @public +export function requiredError(options?: ValidationErrorOptions): WithoutField; + +// @public +export class RequiredValidationError extends _NgValidationError { + // (undocumented) + readonly kind = "required"; +} + +// @public +export interface RootFieldContext { + readonly field: Field; + readonly fieldOf:

(p: FieldPath

) => Field

; + readonly state: FieldState; + readonly stateOf:

(p: FieldPath

) => FieldState

; + readonly value: Signal; + readonly valueOf:

(p: FieldPath

) => P; +} + +// @public +export type Schema = { + [ɵɵTYPE]: SchemaFn; +}; + +// @public +export function schema(fn: SchemaFn): Schema; + +// @public +export type SchemaFn = (p: FieldPath) => void; + +// @public +export type SchemaOrSchemaFn = Schema | SchemaFn; + +// @public +export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField): StandardSchemaValidationError; + +// @public +export function standardSchemaError(issue: StandardSchemaV1.Issue, options?: ValidationErrorOptions): WithoutField; + +// @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 = { + readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeField; +}; + +// @public +export function submit(form: Field, action: (form: Field) => Promise): Promise; + +// @public +export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting'; + +// @public +export type TreeValidationResult = ValidationSuccess | OneOrMany>; + +// @public +export type TreeValidator = LogicFn; + +// @public +export function validate(path: FieldPath, logic: NoInfer>): void; + +// @public +export function validateAsync(path: FieldPath, opts: AsyncValidatorOptions): void; + +// @public +export function validateHttp(path: FieldPath, opts: HttpValidatorOptions): void; + +// @public +export function validateTree(path: FieldPath, logic: NoInfer>): void; + +// @public +export abstract class ValidationError { + [BRAND]: undefined; + constructor(options?: ValidationErrorOptions); + readonly field: Field; + readonly kind: string; + readonly message?: string; +} + +// @public +export type ValidationResult = ValidationSuccess | OneOrMany; + +// @public +export type ValidationSuccess = null | undefined | void; + +// @public +export type Validator = LogicFn; + +// @public +export type WithField = T & { + field: Field; +}; + +// @public +export type WithOptionalField = T & { + field?: Field; +}; + +// @public +export type WithoutField = T & { + field: never; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/package.json b/package.json index fa103b5fcaf..6ed6ffec6cb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/forms/signals/BUILD.bazel b/packages/forms/signals/BUILD.bazel new file mode 100644 index 00000000000..3b7084a877e --- /dev/null +++ b/packages/forms/signals/BUILD.bazel @@ -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", +) diff --git a/packages/forms/signals/README.md b/packages/forms/signals/README.md new file mode 100644 index 00000000000..e2e67b7c394 --- /dev/null +++ b/packages/forms/signals/README.md @@ -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. diff --git a/packages/forms/signals/index.ts b/packages/forms/signals/index.ts new file mode 100644 index 00000000000..ffb1b7aab41 --- /dev/null +++ b/packages/forms/signals/index.ts @@ -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'; diff --git a/packages/forms/signals/public_api.ts b/packages/forms/signals/public_api.ts new file mode 100644 index 00000000000..07c006e5a7a --- /dev/null +++ b/packages/forms/signals/public_api.ts @@ -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'; diff --git a/packages/forms/signals/src/api/async.ts b/packages/forms/signals/src/api/async.ts new file mode 100644 index 00000000000..e61a9170e9b --- /dev/null +++ b/packages/forms/signals/src/api/async.ts @@ -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 = ( + result: TResult, + ctx: FieldContext, +) => 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) => 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) => ResourceRef; + + /** + * 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; +} + +/** + * 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 { + /** + * 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) => string | undefined) + | ((ctx: FieldContext) => 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; + + /** + * The options to use when creating the httpResource. + */ + readonly options?: HttpResourceOptions; +} + +/** + * 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( + path: FieldPath, + opts: AsyncValidatorOptions, +): 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); + 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( + path: FieldPath, + opts: HttpValidatorOptions, +) { + validateAsync(path, { + params: opts.request, + factory: (request: Signal) => httpResource(request, opts.options), + errors: opts.errors, + }); +} diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts new file mode 100644 index 00000000000..1f549902e78 --- /dev/null +++ b/packages/forms/signals/src/api/control.ts @@ -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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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 | InputSignal | OutputRef; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +/** + * 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 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; + // 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; + // 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; +} diff --git a/packages/forms/signals/src/api/logic.ts b/packages/forms/signals/src/api/logic.ts new file mode 100644 index 00000000000..7e0ca9c302c --- /dev/null +++ b/packages/forms/signals/src/api/logic.ts @@ -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( + path: FieldPath, + logic?: string | NoInfer>, +): 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); + } + 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( + path: FieldPath, + logic: NoInfer> = () => 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()) { + * + * + * } + * ``` + * + * @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( + path: FieldPath, + logic: NoInfer>, +): 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( + path: FieldPath, + logic: NoInfer>, +): void { + assertPathIsCurrent(path); + + const pathNode = FieldPathNode.unwrapFieldPath(path); + pathNode.logic.addSyncErrorRule((ctx) => + addDefaultField(logic(ctx as FieldContext), 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( + path: FieldPath, + logic: NoInfer>, +): void { + assertPathIsCurrent(path); + + const pathNode = FieldPathNode.unwrapFieldPath(path); + pathNode.logic.addSyncTreeErrorRule((ctx) => + addDefaultField(logic(ctx as FieldContext), 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( + path: FieldPath, + prop: AggregateProperty, + logic: NoInfer>, +): 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( + path: FieldPath, + factory: (ctx: FieldContext) => TData, +): Property; + +/** + * 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( + path: FieldPath, + prop: Property, + factory: (ctx: FieldContext) => TData, +): Property; + +export function property( + path: FieldPath, + ...rest: + | [(ctx: FieldContext) => TData] + | [Property, (ctx: FieldContext) => TData] +): Property { + assertPathIsCurrent(path); + + let key: Property; + let factory: (ctx: FieldContext) => 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); + return key; +} diff --git a/packages/forms/signals/src/api/property.ts b/packages/forms/signals/src/api/property.ts new file mode 100644 index 00000000000..922098376cc --- /dev/null +++ b/packages/forms/signals/src/api/property.ts @@ -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 { + private brand!: TValue; + + /** Use {@link createProperty}. */ + private constructor() {} +} + +/** Creates a {@link Property}. */ +export function createProperty(): Property { + return new (Property as new () => Property)(); +} + +/** + * 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 { + 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( + reduce: (acc: TAcc, item: TItem) => TAcc, + getInitial: () => TAcc, +): AggregateProperty { + return new (AggregateProperty as new ( + reduce: (acc: TAcc, item: TItem) => TAcc, + getInitial: () => TAcc, + ) => AggregateProperty)(reduce, getInitial); +} + +/** + * Creates an aggregate property that reduces its individual values into a list. + */ +export function listProperty(): AggregateProperty { + return reducedProperty( + (acc, item) => (item === undefined ? acc : [...acc, item]), + () => [], + ); +} + +/** + * Creates an aggregate property that reduces its individual values by taking their min. + */ +export function minProperty(): AggregateProperty { + return reducedProperty( + (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 { + return reducedProperty( + (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 { + 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 { + return reducedProperty( + (prev, next) => prev && next, + () => true, + ); +} + +/** + * An aggregate property representing whether the field is required. + */ +export const REQUIRED: AggregateProperty = orProperty(); + +/** + * An aggregate property representing the min value of the field. + */ +export const MIN: AggregateProperty = maxProperty(); + +/** + * An aggregate property representing the max value of the field. + */ +export const MAX: AggregateProperty = minProperty(); + +/** + * An aggregate property representing the min length of the field. + */ +export const MIN_LENGTH: AggregateProperty = maxProperty(); + +/** + * An aggregate property representing the max length of the field. + */ +export const MAX_LENGTH: AggregateProperty = minProperty(); + +/** + * An aggregate property representing the patterns the field must match. + */ +export const PATTERN: AggregateProperty = listProperty(); diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts new file mode 100644 index 00000000000..d8ece8e972e --- /dev/null +++ b/packages/forms/signals/src/api/structure.ts @@ -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( + args: any[], +): [WritableSignal, SchemaOrSchemaFn | undefined, FormOptions | undefined] { + let model: WritableSignal; + let schema: SchemaOrSchemaFn | 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(model: WritableSignal): Field; + +/** + * 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( + model: WritableSignal, + schemaOrOptions: SchemaOrSchemaFn | FormOptions, +): Field; + +/** + * 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( + model: WritableSignal, + schema: SchemaOrSchemaFn, + options: FormOptions, +): Field; + +export function form(...args: any[]): Field { + const [model, schema, options] = normalizeFormArgs(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; +} + +/** + * 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( + path: FieldPath, + schema: NoInfer>, +): void { + assertPathIsCurrent(path); + + const elementPath = FieldPathNode.unwrapFieldPath(path).element.fieldPathProxy; + apply(elementPath, schema as Schema); +} + +/** + * 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( + path: FieldPath, + schema: NoInfer>, +): 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` 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( + path: FieldPath, + logic: LogicFn, + schema: NoInfer>, +): 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( + path: FieldPath, + predicate: (value: TValue) => value is TNarrowed, + schema: SchemaOrSchemaFn, +): 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( + path: FieldPath, + predicate: (value: TValue) => boolean, + schema: NoInfer>, +): void; + +export function applyWhenValue( + path: FieldPath, + predicate: (value: unknown) => boolean, + schema: SchemaOrSchemaFn, +) { + 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( + form: Field, + action: (form: Field) => Promise, +) { + 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>, +) { + if (!isArray(errors)) { + errors = [errors]; + } + const errorsByField = new Map(); + 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(fn: SchemaFn): Schema { + return SchemaImpl.create(fn) as unknown as Schema; +} + +/** Marks a {@link node} and its descendants as touched. */ +function markAllAsTouched(node: FieldNode) { + node.markAsTouched(); + for (const child of node.structure.children()) { + markAllAsTouched(child); + } +} diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts new file mode 100644 index 00000000000..619bfb9586a --- /dev/null +++ b/packages/forms/signals/src/api/types.ts @@ -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 = { + -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 | 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; + /** 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 = + | ValidationSuccess + | OneOrMany>; + +/** + * 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 = + | ValidationSuccess + | OneOrMany>; + +/** + * 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 = + | ValidationSuccess + | OneOrMany; + +/** + * 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 = + | ValidationResult + | '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`. 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 = (() => FieldState< + TValue, + TKey +>) & + (TValue extends Array + ? ReadonlyArrayLike> + : TValue extends Record + ? Subfields + : unknown); + +/** + * The sub-fields that a user can navigate to from a `Field`. + * + * @template TValue The type of the data which the parent field is wrapped around. + */ +export type Subfields = { + 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 = Pick< + ReadonlyArray, + 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 & undefined) + | Field, TKey>; + +/** + * Contains all of the state (e.g. value, statuses, etc.) associated with a `Field`, exposed as + * signals. + */ +export interface FieldState { + /** + * 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; + /** + * A signal indicating whether the field has been touched by the user. + */ + readonly touched: Signal; + /** + * A signal indicating whether field value has been changed by user. + */ + readonly dirty: Signal; + + /** + * 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; + + /** + * A signal indicating whether the field is currently disabled. + */ + readonly disabled: Signal; + /** + * A signal containing the reasons why the field is currently disabled. + */ + readonly disabledReasons: Signal; + /** + * A signal indicating whether the field is currently readonly. + */ + readonly readonly: Signal; + /** + * A signal containing the current errors for the field. + */ + readonly errors: Signal; + /** + * A signal containing the {@link errors} of the field and its descendants. + */ + readonly errorSummary: Signal; + /** + * 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; + /** + * 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; + /** + * Whether there are any validators still pending for this field. + */ + readonly pending: Signal; + /** + * A signal indicating whether the field is currently in the process of being submitted. + */ + readonly submitting: Signal; + /** + * A signal of a unique name for the field, by default based on the name of its parent field. + */ + readonly name: Signal; + + /** + * 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; + /** + * A signal containing the `Control` directives this field is currently bound to. + */ + readonly controls: Signal[]>; + + /** + * Reads an aggregate property value from the field. + * @param prop The property to read. + */ + property(prop: AggregateProperty): Signal; + + /** + * Reads a property value from the field. + * @param prop The property key to read. + */ + property(prop: Property): M | undefined; + + /** + * Checks whether the given metadata key has been defined for this field. + */ + hasProperty(key: Property | AggregateProperty): 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 = { + [ɵɵTYPE]: [TValue, TPathKind]; +} & (TValue extends Array + ? unknown + : TValue extends Record + ? {[K in keyof TValue]: MaybeFieldPath} + : 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 & undefined) + | FieldPath, 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 = { + [ɵɵTYPE]: SchemaFn; +}; + +/** + * 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 = ( + p: FieldPath, +) => 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 = + | Schema + | SchemaFn; + +/** + * 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 = ( + ctx: FieldContext, +) => 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 = 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 = 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 = 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 + : TPathKind extends PathKind.Child + ? ChildFieldContext + : RootFieldContext; + +/** + * The base field context that is available for all fields. + */ +export interface RootFieldContext { + /** A signal containing the value of the current field. */ + readonly value: Signal; + /** The state of the current field. */ + readonly state: FieldState; + /** The current field. */ + readonly field: Field; + /** Gets the value of the field represented by the given path. */ + readonly valueOf:

(p: FieldPath

) => P; + /** Gets the state of the field represented by the given path. */ + readonly stateOf:

(p: FieldPath

) => FieldState

; + /** Gets the field represented by the given path. */ + readonly fieldOf:

(p: FieldPath

) => Field

; +} + +/** + * Field context that is available for all fields that are a child of another field. + */ +export interface ChildFieldContext extends RootFieldContext { + /** The key of the current field in its parent field. */ + readonly key: Signal; +} + +/** + * Field context that is available for all fields that are an item in an array field. + */ +export interface ItemFieldContext extends ChildFieldContext { + /** The index of the current field in its parent field. */ + readonly index: Signal; +} diff --git a/packages/forms/signals/src/api/validation_errors.ts b/packages/forms/signals/src/api/validation_errors.ts new file mode 100644 index 00000000000..fb70d772e37 --- /dev/null +++ b/packages/forms/signals/src/api/validation_errors.ts @@ -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 & {field: Field}; + +/** + * 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 & {field?: Field}; + +/** + * 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 & {field: never}; + +/** + * Create a required error associated with the target field + * @param options The validation error options + */ +export function requiredError(options: WithField): RequiredValidationError; +/** + * Create a required error + * @param options The optional validation error options + */ +export function requiredError( + options?: ValidationErrorOptions, +): WithoutField; +export function requiredError( + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function minError( + min: number, + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function maxError( + max: number, + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function minLengthError( + minLength: number, + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function maxLengthError( + maxLength: number, + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function patternError( + pattern: RegExp, + options?: ValidationErrorOptions, +): WithOptionalField { + 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): EmailValidationError; +/** + * Create an email format error + * @param options The optional validation error options + */ +export function emailError(options?: ValidationErrorOptions): WithoutField; +export function emailError( + options?: ValidationErrorOptions, +): WithOptionalField { + 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, +): 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; +export function standardSchemaError( + issue: StandardSchemaV1.Issue, + options?: ValidationErrorOptions, +): WithOptionalField { + 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, typeof BRAND>>( + obj: WithField, +): CustomValidationError; +/** + * Create a custom error + * @param obj The object to create an error from + */ +export function customError, typeof BRAND>>( + obj?: E, +): WithoutField; +export function customError, typeof BRAND>>( + obj?: E, +): WithOptionalField { + 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; + + /** 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; diff --git a/packages/forms/signals/src/api/validators/email.ts b/packages/forms/signals/src/api/validators/email.ts new file mode 100644 index 00000000000..95cecbaa993 --- /dev/null +++ b/packages/forms/signals/src/api/validators/email.ts @@ -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( + path: FieldPath, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/index.ts b/packages/forms/signals/src/api/validators/index.ts new file mode 100644 index 00000000000..234073a7655 --- /dev/null +++ b/packages/forms/signals/src/api/validators/index.ts @@ -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'; diff --git a/packages/forms/signals/src/api/validators/max.ts b/packages/forms/signals/src/api/validators/max.ts new file mode 100644 index 00000000000..3d27d802fe7 --- /dev/null +++ b/packages/forms/signals/src/api/validators/max.ts @@ -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( + path: FieldPath, + maxValue: number | LogicFn, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/max_length.ts b/packages/forms/signals/src/api/validators/max_length.ts new file mode 100644 index 00000000000..519acaffcd1 --- /dev/null +++ b/packages/forms/signals/src/api/validators/max_length.ts @@ -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, + maxLength: number | LogicFn, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/min.ts b/packages/forms/signals/src/api/validators/min.ts new file mode 100644 index 00000000000..2bc622cf710 --- /dev/null +++ b/packages/forms/signals/src/api/validators/min.ts @@ -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( + path: FieldPath, + minValue: number | LogicFn, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/min_length.ts b/packages/forms/signals/src/api/validators/min_length.ts new file mode 100644 index 00000000000..3037e224a60 --- /dev/null +++ b/packages/forms/signals/src/api/validators/min_length.ts @@ -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, + minLength: number | LogicFn, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/pattern.ts b/packages/forms/signals/src/api/validators/pattern.ts new file mode 100644 index 00000000000..cbc76e190c6 --- /dev/null +++ b/packages/forms/signals/src/api/validators/pattern.ts @@ -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( + path: FieldPath, + pattern: RegExp | LogicFn, + config?: BaseValidatorConfig, +) { + 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/required.ts b/packages/forms/signals/src/api/validators/required.ts new file mode 100644 index 00000000000..41a7a5943c6 --- /dev/null +++ b/packages/forms/signals/src/api/validators/required.ts @@ -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( + path: FieldPath, + config?: BaseValidatorConfig & { + emptyPredicate?: (value: TValue) => boolean; + when?: NoInfer>; + }, +): 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; + }); +} diff --git a/packages/forms/signals/src/api/validators/standard_schema.ts b/packages/forms/signals/src/api/validators/standard_schema.ts new file mode 100644 index 00000000000..045957bdfd8 --- /dev/null +++ b/packages/forms/signals/src/api/validators/standard_schema.ts @@ -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 = 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 extends Record + ? { + [K in keyof T as RemoveStringIndexUnknownKey]: IgnoreUnknownProperties; + } + : 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>( + path: FieldPath, + schema: StandardSchemaV1, +) { + // 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, + issue: StandardSchemaV1.Issue, +): StandardSchemaValidationError { + let target = field as Field>; + for (const pathPart of issue.path ?? []) { + const pathKey = typeof pathPart === 'object' ? pathPart.key : pathPart; + target = target[pathKey] as Field>; + } + return addDefaultField(standardSchemaError(issue), target); +} diff --git a/packages/forms/signals/src/api/validators/util.ts b/packages/forms/signals/src/api/validators/util.ts new file mode 100644 index 00000000000..99c804ecfbf --- /dev/null +++ b/packages/forms/signals/src/api/validators/util.ts @@ -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 = + | { + /** A user-facing error message to include with the error. */ + message?: string | LogicFn; + 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> + | LogicFn>, 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( + opt: Exclude | LogicFn | undefined, + ctx: FieldContext, +): TOption | undefined { + return opt instanceof Function ? opt(ctx) : opt; +} diff --git a/packages/forms/signals/src/controls/control.ts b/packages/forms/signals/src/controls/control.ts new file mode 100644 index 00000000000..f1af4e1efe8 --- /dev/null +++ b/packages/forms/signals/src/controls/control.ts @@ -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 { + /** 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>(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) { + 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 = inject(ElementRef); + + /** The NG_VALUE_ACCESSOR array for the host component. */ + readonly cvaArray = inject(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]); + onCleanup(() => { + fieldNode.nodeState.controls.update((controls) => controls.filter((c) => c !== this)); + }); + }, + {injector: this.injector}, + ); + } + + /** + * Set up state synchronization between the field and a native , `, + }) + class TestCmp { + f = form(signal('')); + textarea = viewChild.required>('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: ` + + `, + }) + class TestCmp { + f = form(signal('invalid')); + select = viewChild.required>('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()) { + + } + `, + }) + class TestCmp { + f = form(signal(''), (p) => hidden(p, ({value}) => value() === '')); + select = viewChild>('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: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + } + + @Component({ + imports: [Control, CustomInput], + template: ``, + }) + class TestCmp { + f = form(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: '', + }) + class CustomInput implements FormValueControl { + value = model.required(); + + ngOnInit(): void { + initialValue = this.value(); + } + } + + @Component({ + imports: [Control, CustomInput], + template: ``, + }) + class TestCmp { + f = form(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: + '', + }) + class CustomInput implements FormCheckboxControl { + checked = model(false); + } + + @Component({ + imports: [Control, CustomInput], + template: ``, + }) + 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>(); + } + + @Component({ + template: ``, + 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: ` + + + `, + imports: [Control], + }) + class CustomPropsTestCmp { + textInput = viewChild.required>('text'); + numberInput = viewChild.required>('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: ` + + `, + imports: [Control], + }) + class ReadonlyTestCmp { + textInput = viewChild.required>('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: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + disabledReasons = input([]); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Control], + }) + class ReadonlyTestCmp { + myInput = viewChild.required(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: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + touched = model(false); + + touchIt() { + this.touched.set(true); + } + } + + @Component({ + imports: [Control, CustomInput], + template: ``, + }) + class TestCmp { + f = form(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: ` +

+ + + +
+ `, + }) + 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(fn: () => T): T { + try { + return fn(); + } finally { + TestBed.tick(); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03c99f708f..1991c787596 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}