From 3bbcae6ce85fb3778f92995cf99c037cc2dcdeb5 Mon Sep 17 00:00:00 2001 From: kirjs Date: Fri, 31 Oct 2025 08:45:10 -0400 Subject: [PATCH] refactor(forms): add compatForm This allows using reactive form controls in signal forms (cherry picked from commit 60447945bc5cb748ae7f1c42cbcd2ba9342cc837) --- goldens/public-api/forms/signals/index.api.md | 129 ++- packages/forms/signals/compat/BUILD.bazel | 20 + .../compat/src/compat_field_adapter.ts | 132 +++ .../signals/compat/src/compat_field_node.ts | 133 +++ .../forms/signals/compat/src/compat_form.ts | 127 +++ .../signals/compat/src/compat_node_state.ts | 54 ++ .../signals/compat/src/compat_structure.ts | 128 +++ .../compat/src/compat_validation_error.ts | 64 ++ .../compat/src/compat_validation_state.ts | 63 ++ packages/forms/signals/src/api/async.ts | 6 +- .../forms/signals/src/api/field_directive.ts | 6 +- packages/forms/signals/src/api/logic.ts | 21 +- packages/forms/signals/src/api/structure.ts | 76 +- packages/forms/signals/src/api/types.ts | 180 +++- .../forms/signals/src/api/validators/email.ts | 4 +- .../forms/signals/src/api/validators/max.ts | 4 +- .../signals/src/api/validators/max_length.ts | 4 +- .../forms/signals/src/api/validators/min.ts | 4 +- .../signals/src/api/validators/min_length.ts | 4 +- .../signals/src/api/validators/pattern.ts | 4 +- .../signals/src/api/validators/required.ts | 4 +- .../src/api/validators/standard_schema.ts | 31 +- packages/forms/signals/src/field/context.ts | 32 +- packages/forms/signals/src/schema/logic.ts | 4 +- .../forms/signals/src/schema/path_node.ts | 8 +- packages/forms/signals/src/schema/schema.ts | 4 +- .../signals/src/util/normalize_form_args.ts | 37 + packages/forms/signals/test/node/BUILD.bazel | 1 + .../signals/test/node/compat/compat.spec.ts | 829 ++++++++++++++++++ .../compat/compat_validation_error.spec.ts | 65 ++ .../signals/test/node/field_context.spec.ts | 22 +- .../signals/test/node/field_node.spec.ts | 29 +- .../signals/test/node/logic_node.spec.ts | 8 +- .../signals/test/node/recursive_logic.spec.ts | 6 +- .../forms/signals/test/node/resource.spec.ts | 12 +- .../test/node/validation_status.spec.ts | 24 +- 36 files changed, 2039 insertions(+), 240 deletions(-) create mode 100644 packages/forms/signals/compat/BUILD.bazel create mode 100644 packages/forms/signals/compat/src/compat_field_adapter.ts create mode 100644 packages/forms/signals/compat/src/compat_field_node.ts create mode 100644 packages/forms/signals/compat/src/compat_form.ts create mode 100644 packages/forms/signals/compat/src/compat_node_state.ts create mode 100644 packages/forms/signals/compat/src/compat_structure.ts create mode 100644 packages/forms/signals/compat/src/compat_validation_error.ts create mode 100644 packages/forms/signals/compat/src/compat_validation_state.ts create mode 100644 packages/forms/signals/src/util/normalize_form_args.ts create mode 100644 packages/forms/signals/test/node/compat/compat.spec.ts create mode 100644 packages/forms/signals/test/node/compat/compat_validation_error.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index ddcc5253877..81538d290b3 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -5,6 +5,7 @@ ```ts import { AbstractControl } from '@angular/forms'; +import * as _angular_forms from '@angular/forms'; import { ControlValueAccessor } from '@angular/forms'; import { DestroyableInjector } from '@angular/core'; import { FormControlStatus } from '@angular/forms'; @@ -28,7 +29,7 @@ import { ɵControl } from '@angular/core'; import { ɵFieldState } from '@angular/core'; // @public -export function aggregateMetadata(path: FieldPath, key: AggregateMetadataKey, logic: NoInfer>): void; +export function aggregateMetadata(path: SchemaPath, key: AggregateMetadataKey, logic: NoInfer>): void; // @public export class AggregateMetadataKey { @@ -42,19 +43,19 @@ export class AggregateMetadataKey { export function andMetadataKey(): AggregateMetadataKey; // @public -export function apply(path: FieldPath, schema: NoInfer>): void; +export function apply(path: SchemaPath, schema: NoInfer>): void; // @public -export function applyEach(path: FieldPath, schema: NoInfer>): void; +export function applyEach(path: SchemaPath, schema: NoInfer>): void; // @public -export function applyWhen(path: FieldPath, logic: LogicFn, schema: NoInfer>): void; +export function applyWhen(path: SchemaPath, logic: LogicFn, schema: NoInfer>): void; // @public -export function applyWhenValue(path: FieldPath, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn): void; +export function applyWhenValue(path: SchemaPath, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn): void; // @public -export function applyWhenValue(path: FieldPath, predicate: (value: TValue) => boolean, schema: NoInfer>): void; +export function applyWhenValue(path: SchemaPath, predicate: (value: TValue) => boolean, schema: NoInfer>): void; // @public export type AsyncValidationResult = ValidationResult | 'pending'; @@ -72,6 +73,18 @@ export interface ChildFieldContext extends RootFieldContext { readonly key: Signal; } +// @public +export type CompatFieldState = FieldState ? TValue : never, TKey> & { + control: Signal; +}; + +// @public +export type CompatSchemaPath = SchemaPath ? TValue : never, SchemaPathRules.Unsupported, TPathKind> & { + [ɵɵTYPE]: { + control: TControl; + }; +}; + // @public export function createMetadataKey(): MetadataKey; @@ -91,7 +104,7 @@ export class CustomValidationError implements ValidationError { } // @public -export function disabled(path: FieldPath, logic?: string | NoInfer>): void; +export function disabled(path: SchemaPath, logic?: string | NoInfer>): void; // @public export interface DisabledReason { @@ -100,7 +113,7 @@ export interface DisabledReason { } // @public -export function email(path: FieldPath, config?: BaseValidatorConfig): void; +export function email(path: SchemaPath, config?: BaseValidatorConfig): void; // @public export function emailError(options: WithField): EmailValidationError; @@ -124,7 +137,7 @@ export class Field implements ɵControl { // (undocumented) readonly field: i0.InputSignal>; // (undocumented) - readonly state: i0.Signal>; + readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl] ? CompatFieldState : FieldState>; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration, "[field]", never, { "field": { "alias": "field"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>; // (undocumented) @@ -143,13 +156,6 @@ export class Field implements ɵControl { // @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 extends ɵFieldState { readonly dirty: Signal; @@ -172,19 +178,19 @@ export interface FieldState = (() => FieldState) & (TValue extends Array ? ReadonlyArrayLike> : TValue extends Record ? Subfields : unknown); +export type FieldTree = (() => [TModel] extends [AbstractControl] ? CompatFieldState : FieldState) & (TModel extends AbstractControl ? unknown : TModel extends Array ? ReadonlyArrayLike> : TModel extends Record ? Subfields : unknown); // @public export type FieldValidator = LogicFn, TPathKind>; // @public -export function form(model: WritableSignal): FieldTree; +export function form(model: WritableSignal): FieldTree; // @public -export function form(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | FormOptions): FieldTree; +export function form(model: WritableSignal, schemaOrOptions: SchemaOrSchemaFn | FormOptions): FieldTree; // @public -export function form(model: WritableSignal, schema: SchemaOrSchemaFn, options: FormOptions): FieldTree; +export function form(model: WritableSignal, schema: SchemaOrSchemaFn, options: FormOptions): FieldTree; // @public export interface FormCheckboxControl extends FormUiControl { @@ -227,7 +233,7 @@ export interface FormValueControl extends FormUiControl { } // @public -export function hidden(path: FieldPath, logic: NoInfer>): void; +export function hidden(path: SchemaPath, logic: NoInfer>): void; // @public export interface HttpValidatorOptions { @@ -260,7 +266,7 @@ export type MapToErrorsFn; // @public -export function max(path: FieldPath, maxValue: number | LogicFn, config?: BaseValidatorConfig): void; +export function max(path: SchemaPath, maxValue: number | LogicFn, config?: BaseValidatorConfig): void; // @public export const MAX_LENGTH: AggregateMetadataKey; @@ -272,7 +278,7 @@ export function maxError(max: number, options: WithField export function maxError(max: number, options?: ValidationErrorOptions): WithoutField; // @public -export function maxLength(path: FieldPath, maxLength: number | LogicFn, config?: BaseValidatorConfig): void; +export function maxLength(path: SchemaPath, maxLength: number | LogicFn, config?: BaseValidatorConfig): void; // @public export function maxLengthError(maxLength: number, options: WithField): MaxLengthValidationError; @@ -302,16 +308,16 @@ export class MaxValidationError extends _NgValidationError { } // @public -export type MaybeFieldPath = (TValue & undefined) | FieldPath, TPathKind>; +export type MaybeFieldTree = (TModel & undefined) | FieldTree, TKey>; // @public -export type MaybeFieldTree = (TValue & undefined) | FieldTree, TKey>; +export type MaybeSchemaPathTree = (TModel & undefined) | SchemaPathTree, TPathKind>; // @public -export function metadata(path: FieldPath, factory: (ctx: FieldContext) => TData): MetadataKey; +export function metadata(path: SchemaPath, factory: (ctx: FieldContext) => TData): MetadataKey; // @public -export function metadata(path: FieldPath, key: MetadataKey, factory: (ctx: FieldContext) => TData): MetadataKey; +export function metadata(path: SchemaPath, key: MetadataKey, factory: (ctx: FieldContext) => TData): MetadataKey; // @public export class MetadataKey { @@ -321,7 +327,7 @@ export class MetadataKey { export const MIN: AggregateMetadataKey; // @public -export function min(path: FieldPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; +export function min(path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig): void; // @public export const MIN_LENGTH: AggregateMetadataKey; @@ -333,7 +339,7 @@ export function minError(min: number, options: WithField export function minError(min: number, options?: ValidationErrorOptions): WithoutField; // @public -export function minLength(path: FieldPath, minLength: number | LogicFn, config?: BaseValidatorConfig): void; +export function minLength(path: SchemaPath, minLength: number | LogicFn, config?: BaseValidatorConfig): void; // @public export function minLengthError(minLength: number, options: WithField): MinLengthValidationError; @@ -396,7 +402,7 @@ export namespace PathKind { export const PATTERN: AggregateMetadataKey; // @public -export function pattern(path: FieldPath, pattern: RegExp | LogicFn, config?: BaseValidatorConfig): void; +export function pattern(path: SchemaPath, pattern: RegExp | LogicFn, config?: BaseValidatorConfig): void; // @public export function patternError(pattern: RegExp, options: WithField): PatternValidationError; @@ -414,7 +420,7 @@ export class PatternValidationError extends _NgValidationError { } // @public -export function readonly(path: FieldPath, logic?: NoInfer>): void; +export function readonly(path: SchemaPath, logic?: NoInfer>): void; // @public export type ReadonlyArrayLike = Pick, number | 'length' | typeof Symbol.iterator>; @@ -429,7 +435,7 @@ export type RemoveStringIndexUnknownKey = string extends K ? unknown exten export const REQUIRED: AggregateMetadataKey; // @public -export function required(path: FieldPath, config?: BaseValidatorConfig & { +export function required(path: SchemaPath, config?: BaseValidatorConfig & { when?: NoInfer>; }): void; @@ -448,26 +454,53 @@ export class RequiredValidationError extends _NgValidationError { // @public export interface RootFieldContext { readonly field: FieldTree; - readonly fieldOf:

(p: FieldPath

) => FieldTree

; + // (undocumented) + fieldTreeOf(p: SchemaPathTree): FieldTree; readonly state: FieldState; - readonly stateOf:

(p: FieldPath

) => FieldState

; + // (undocumented) + stateOf(p: CompatSchemaPath): CompatFieldState; + // (undocumented) + stateOf(p: SchemaPath): FieldState; readonly value: Signal; - readonly valueOf:

(p: FieldPath

) => P; + valueOf(p: SchemaPath): PValue; } // @public -export type Schema = { - [ɵɵTYPE]: SchemaFn; +export type Schema = { + [ɵɵTYPE]: SchemaFn; }; // @public export function schema(fn: SchemaFn): Schema; // @public -export type SchemaFn = (p: FieldPath) => void; +export type SchemaFn = (p: SchemaPathTree) => void; // @public -export type SchemaOrSchemaFn = Schema | SchemaFn; +export type SchemaOrSchemaFn = Schema | SchemaFn; + +// @public +export type SchemaPath = { + [ɵɵTYPE]: { + value: () => TValue; + supportsRules: TSupportsRules; + pathKind: TPathKind; + }; +}; + +// @public +export type SchemaPathRules = SchemaPathRules.Supported | SchemaPathRules.Unsupported; + +// @public (undocumented) +export namespace SchemaPathRules { + export type Supported = 1; + export type Unsupported = 2; +} + +// @public +export type SchemaPathTree = (TModel extends AbstractControl ? CompatSchemaPath : SchemaPath) & (TModel extends AbstractControl ? unknown : TModel extends Array ? unknown : TModel extends Record ? { + [K in keyof TModel]: MaybeSchemaPathTree; +} : unknown); // @public export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField): StandardSchemaValidationError; @@ -485,14 +518,14 @@ export class StandardSchemaValidationError extends _NgValidationError { } // @public -export type Subfields = { - readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree; +export type Subfields = { + readonly [K in keyof TModel as TModel[K] extends Function ? never : K]: MaybeFieldTree; } & { - [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; + [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; }; // @public -export function submit(form: FieldTree, action: (form: FieldTree) => Promise): Promise; +export function submit(form: FieldTree, action: (form: FieldTree) => Promise): Promise; // @public export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting'; @@ -504,19 +537,19 @@ export type TreeValidationResult = LogicFn; // @public -export function validate(path: FieldPath, logic: NoInfer>): void; +export function validate(path: SchemaPath, logic: NoInfer>): void; // @public -export function validateAsync(path: FieldPath, opts: AsyncValidatorOptions): void; +export function validateAsync(path: SchemaPath, opts: AsyncValidatorOptions): void; // @public -export function validateHttp(path: FieldPath, opts: HttpValidatorOptions): void; +export function validateHttp(path: SchemaPath, opts: HttpValidatorOptions): void; // @public -export function validateStandardSchema>(path: FieldPath, schema: StandardSchemaV1): void; +export function validateStandardSchema>(path: SchemaPath & SchemaPathTree, schema: StandardSchemaV1): void; // @public -export function validateTree(path: FieldPath, logic: NoInfer>): void; +export function validateTree(path: SchemaPath, logic: NoInfer>): void; // @public export interface ValidationError { diff --git a/packages/forms/signals/compat/BUILD.bazel b/packages/forms/signals/compat/BUILD.bazel new file mode 100644 index 00000000000..05f4d34c580 --- /dev/null +++ b/packages/forms/signals/compat/BUILD.bazel @@ -0,0 +1,20 @@ +load("//tools:defaults.bzl", "ng_project") + +package(default_visibility = ["//visibility:public"]) + +ng_project( + name = "compat", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + deps = [ + "//:node_modules/rxjs", + "//packages/core", + "//packages/core/rxjs-interop", + "//packages/forms", + "//packages/forms/signals", + ], +) diff --git a/packages/forms/signals/compat/src/compat_field_adapter.ts b/packages/forms/signals/compat/src/compat_field_adapter.ts new file mode 100644 index 00000000000..a7a4e6ac659 --- /dev/null +++ b/packages/forms/signals/compat/src/compat_field_adapter.ts @@ -0,0 +1,132 @@ +/** + * @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 {AbstractControl} from '@angular/forms'; +import { + ChildFieldNodeOptions, + FieldNodeOptions, + FieldNodeStructure, +} from '../../src/field/structure'; +import {computed, Signal, WritableSignal} from '@angular/core'; +import {CompatFieldNode} from './compat_field_node'; +import {FieldNode} from '../../src/field/node'; +import {CompatValidationState} from './compat_validation_state'; +import {ValidationState} from '../../src/field/validation'; +import {CompatChildFieldNodeOptions, CompatStructure} from './compat_structure'; +import {CompatNodeState} from './compat_node_state'; +import {FieldNodeState} from '../../src/field/state'; +import {FieldAdapter, BasicFieldAdapter} from '../../src/field/field_adapter'; +import {FieldPathNode} from '../../src/schema/path_node'; +import {FormFieldManager} from '../../src/field/manager'; + +/** + * This is a tree-shakable Field adapter that can create a compat node + * that proxies FormControl state and value to a field. + */ +export class CompatFieldAdapter implements FieldAdapter { + readonly basicAdapter = new BasicFieldAdapter(); + + /** + * Creates a regular or compat root node state based on whether the control is present. + * @param fieldManager + * @param value + * @param pathNode + * @param adapter + */ + newRoot( + fieldManager: FormFieldManager, + value: WritableSignal, + pathNode: FieldPathNode, + adapter: FieldAdapter, + ): FieldNode { + if (value() instanceof AbstractControl) { + return createCompatNode({ + kind: 'root', + fieldManager, + value, + pathNode, + logic: pathNode.builder.build(), + fieldAdapter: adapter, + }); + } + + return this.basicAdapter.newRoot(fieldManager, value, pathNode, adapter); + } + + /** + * Creates a regular or compat node state based on whether the control is present. + * @param node + * @param options + */ + createNodeState(node: CompatFieldNode, options: CompatChildFieldNodeOptions): FieldNodeState { + if (!options.control) { + return this.basicAdapter.createNodeState(node); + } + return new CompatNodeState(node, options); + } + + /** + * Creates a regular or compat structure based on whether the control is present. + * @param node + * @param options + */ + createStructure(node: CompatFieldNode, options: CompatChildFieldNodeOptions): FieldNodeStructure { + if (!options.control) { + return this.basicAdapter.createStructure(node, options); + } + return new CompatStructure(node, options); + } + + /** + * Creates a regular or compat validation state based on whether the control is present. + * @param node + * @param options + */ + createValidationState( + node: CompatFieldNode, + options: CompatChildFieldNodeOptions, + ): ValidationState { + if (!options.control) { + return this.basicAdapter.createValidationState(node); + } + return new CompatValidationState(options); + } + + /** + * Creates a regular or compat node based on whether the control is present. + * @param options + */ + newChild(options: ChildFieldNodeOptions): FieldNode { + const value = options.parent.value()[options.initialKeyInParent]; + + if (value instanceof AbstractControl) { + return createCompatNode(options); + } + + return new FieldNode(options); + } +} + +/** + * Creates a CompatFieldNode from options. + * @param options + */ +export function createCompatNode(options: FieldNodeOptions) { + const control = ( + options.kind === 'root' + ? options.value + : computed(() => { + return options.parent.value()[options.initialKeyInParent]; + }) + ) as Signal; + + return new CompatFieldNode({ + ...options, + control, + }); +} diff --git a/packages/forms/signals/compat/src/compat_field_node.ts b/packages/forms/signals/compat/src/compat_field_node.ts new file mode 100644 index 00000000000..f9ad525608d --- /dev/null +++ b/packages/forms/signals/compat/src/compat_field_node.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, linkedSignal, runInInjectionContext, Signal, untracked} from '@angular/core'; +import {AbstractControl} from '@angular/forms'; +import {FieldNode} from '../../src/field/node'; +import {getInjectorFromOptions} from '../../src/field/util'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {map, takeUntil} from 'rxjs/operators'; +import type {CompatFieldNodeOptions} from './compat_structure'; +import {Observable, ReplaySubject} from 'rxjs'; + +/** + * Field node with additional control property. + * + * Compat node has no children. + */ +export class CompatFieldNode extends FieldNode { + readonly control: Signal; + + constructor(public readonly options: CompatFieldNodeOptions) { + super(options); + this.control = this.options.control; + } +} + +/** + * Makes a function which creates a new subject (and unsubscribes/destroys the previous one). + * + * This allows us to automatically unsubscribe from status changes of the previous FormControl when we go to subscribe to a new one + */ +function makeCreateDestroySubject() { + let destroy$ = new ReplaySubject(1); + return () => { + if (destroy$) { + destroy$.next(); + destroy$.complete(); + } + return (destroy$ = new ReplaySubject(1)); + }; +} + +/** + * Helper function taking options, and a callback which takes options, and a function + * converting reactive control to appropriate property using toSignal from rxjs compat. + * + * This helper keeps all complexity in one place by doing the following things: + * - Running the callback in injection context + * - Not tracking the callback, as it creates a new signal. + * - Reacting to control changes, allowing to swap control dynamically. + * + * @param options + * @param makeSignal + */ +export function extractControlPropToSignal( + options: CompatFieldNodeOptions, + makeSignal: (c: AbstractControl, destroy$: Observable) => Signal, +): Signal { + const injector = getInjectorFromOptions(options); + + // Creates a subject that could be used in takeUntil. + const createDestroySubject = makeCreateDestroySubject(); + + const signalOfControlSignal = linkedSignal({ + source: options.control, + computation: (control) => { + return untracked(() => { + return runInInjectionContext(injector, () => makeSignal(control, createDestroySubject())); + }); + }, + }); + + // We have to have computed, because we need to react to both: + // linked signal changes as well as the inner signal changes. + return computed(() => signalOfControlSignal()()); +} + +/** + * A helper function, simplifying getting reactive control properties after status changes. + * + * Used to extract errors and statuses such as valid, pending. + * + * @param options + * @param getValue + */ +export const getControlStatusSignal = ( + options: CompatFieldNodeOptions, + getValue: (c: AbstractControl) => T, +) => { + return extractControlPropToSignal(options, (c, destroy$) => + toSignal( + c.statusChanges.pipe( + map(() => getValue(c)), + takeUntil(destroy$), + ), + { + initialValue: getValue(c), + }, + ), + ); +}; + +/** + * A helper function, simplifying converting convert events to signals. + * + * Used to get dirty and touched signals from control. + * + * @param options + * @param getValue A function which takes control and returns required value. + */ +export const getControlEventsSignal = ( + options: CompatFieldNodeOptions, + getValue: (c: AbstractControl) => T, +) => { + return extractControlPropToSignal(options, (c, destroy$) => + toSignal( + c.events.pipe( + map(() => { + return getValue(c); + }), + takeUntil(destroy$), + ), + { + initialValue: getValue(c), + }, + ), + ); +}; diff --git a/packages/forms/signals/compat/src/compat_form.ts b/packages/forms/signals/compat/src/compat_form.ts new file mode 100644 index 00000000000..c3478901242 --- /dev/null +++ b/packages/forms/signals/compat/src/compat_form.ts @@ -0,0 +1,127 @@ +/** + * @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 {FieldTree, PathKind, SchemaOrSchemaFn} from '../../src/api/types'; +import {form, FormOptions} from '../../public_api'; +import {CompatFieldAdapter} from './compat_field_adapter'; +import {normalizeFormArgs} from '../../src/util/normalize_form_args'; +import {WritableSignal} from '@angular/core'; + +export type CompatFormOptions = Omit; + +/** + * Creates a compatibility form wrapped around the given model data. + * + * `compatForm` is a version of the `form` function that is designed for backwards + * compatibility with Reactive forms by accepting Reactive controls as a part of the data. + * + * @example + * ``` + * const lastName = new FormControl('lastName'); + * + * const nameModel = signal({ + * first: '', + * last: lastName + * }); + * + * const nameForm = compatForm(nameModel, (name) => { + * required(name.first); + * }); + * + * nameForm.last().value(); // lastName, not FormControl + * + * @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. + + * @category interop + * @experimental 21.0.0 + */ +export function compatForm(model: WritableSignal): FieldTree; + +/** + * Creates a compatibility form wrapped around the given model data. + * + * `compatForm` is a version of the `form` function that is designed for backwards + * compatibility with Reactive forms by accepting Reactive controls as a part of the data. + * + * @example + * ``` + * const lastName = new FormControl('lastName'); + * + * const nameModel = signal({ + * first: '', + * last: lastName + * }); + * + * const nameForm = compatForm(nameModel, (name) => { + * required(name.first); + * }); + * + * nameForm.last().value(); // lastName, not FormControl + * + * @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 (excluding adapter, since it's provided). + * + * @category interop + * @experimental 21.0.0 + */ +export function compatForm( + model: WritableSignal, + schemaOrOptions: SchemaOrSchemaFn | CompatFormOptions, +): FieldTree; + +/** + * Creates a compatibility form wrapped around the given model data. + * + * `compatForm` is a version of the `form` function that is designed for backwards + * compatibility with Reactive forms by accepting Reactive controls as a part of the data. + * + * @example + * ``` + * const lastName = new FormControl('lastName'); + * + * const nameModel = signal({ + * first: '', + * last: lastName + * }); + * + * const nameForm = compatForm(nameModel, (name) => { + * required(name.first); + * }); + * + * nameForm.last().value(); // lastName, not FormControl + * + * @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 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. + * @param options The form options (excluding adapter, since it's provided). + * + * @category interop + * @experimental 21.0.0 + */ +export function compatForm( + model: WritableSignal, + schema: SchemaOrSchemaFn, + options: CompatFormOptions, +): FieldTree; + +export function compatForm(...args: any[]): FieldTree { + const [model, maybeSchema, maybeOptions] = normalizeFormArgs(args); + + const options = {...maybeOptions, adapter: new CompatFieldAdapter()}; + const schema = maybeSchema || ((() => {}) as SchemaOrSchemaFn); + return form(model, schema, options) as FieldTree; +} diff --git a/packages/forms/signals/compat/src/compat_node_state.ts b/packages/forms/signals/compat/src/compat_node_state.ts new file mode 100644 index 00000000000..5a6b307e8fb --- /dev/null +++ b/packages/forms/signals/compat/src/compat_node_state.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {FieldNodeState} from '../../src/field/state'; +import {computed, Signal} from '@angular/core'; +import {AbstractControl} from '@angular/forms'; +import {CompatFieldNode, getControlEventsSignal, getControlStatusSignal} from './compat_field_node'; +import {CompatFieldNodeOptions} from './compat_structure'; + +/** + * A FieldNodeState class wrapping a FormControl and proxying it's state. + */ +export class CompatNodeState extends FieldNodeState { + override readonly touched: Signal; + override readonly dirty: Signal; + override readonly disabled: Signal; + private readonly control: Signal; + + constructor( + readonly compatNode: CompatFieldNode, + options: CompatFieldNodeOptions, + ) { + super(compatNode); + this.control = options.control; + this.touched = getControlEventsSignal(options, (c) => c.touched); + this.dirty = getControlEventsSignal(options, (c) => c.dirty); + const controlDisabled = getControlStatusSignal(options, (c) => c.disabled); + + this.disabled = computed(() => { + return controlDisabled() || this.disabledReasons().length > 0; + }); + } + + override markAsDirty() { + this.control().markAsDirty(); + } + + override markAsTouched() { + this.control().markAsTouched(); + } + + override markAsPristine() { + this.control().markAsPristine(); + } + + override markAsUntouched() { + this.control().markAsUntouched(); + } +} diff --git a/packages/forms/signals/compat/src/compat_structure.ts b/packages/forms/signals/compat/src/compat_structure.ts new file mode 100644 index 00000000000..cc934499bfb --- /dev/null +++ b/packages/forms/signals/compat/src/compat_structure.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, Signal, signal, WritableSignal} from '@angular/core'; +import {FormFieldManager} from '../../src/field/manager'; +import {FieldNode, ParentFieldNode} from '../../src/field/node'; +import { + ChildFieldNodeOptions, + FieldNodeOptions, + FieldNodeStructure, + RootFieldNodeOptions, +} from '../../src/field/structure'; + +import {toSignal} from '@angular/core/rxjs-interop'; +import {AbstractControl} from '@angular/forms'; +import {extractControlPropToSignal} from './compat_field_node'; +import {map, takeUntil} from 'rxjs/operators'; + +/** + * Child Field Node options also exposing control property. + */ +export interface CompatChildFieldNodeOptions extends ChildFieldNodeOptions { + control: Signal; +} + +/** + * Root Field Node options also exposing control property. + */ +export interface CompatRootFieldNodeOptions extends RootFieldNodeOptions { + control: Signal; +} + +/** + * Field Node options also exposing control property. + */ +export type CompatFieldNodeOptions = CompatRootFieldNodeOptions | CompatChildFieldNodeOptions; + +/** + * A helper function allowing to get parent if it exists. + */ +function getParentFromOptions(options: FieldNodeOptions) { + if (options.kind === 'root') { + return undefined; + } + + return options.parent; +} + +/** + * A helper function allowing to get fieldManager regardless of the option type. + */ +function getFieldManagerFromOptions(options: FieldNodeOptions) { + if (options.kind === 'root') { + return options.fieldManager; + } + + return options.parent.structure.root.structure.fieldManager; +} + +/** + * A helper function that takes CompatFieldNodeOptions, and produce a writable signal synced to the + * value of contained AbstractControl. + * + * This uses toSignal, which requires an injector. + * + * @param options + */ +function getControlValueSignal(options: CompatFieldNodeOptions) { + const value = extractControlPropToSignal(options, (control, destroy$) => { + return toSignal( + control.valueChanges.pipe( + map(() => control.getRawValue()), + takeUntil(destroy$), + ), + { + initialValue: control.getRawValue(), + }, + ); + }) as WritableSignal; + + value.set = (value: T) => { + options.control().setValue(value); + }; + + value.update = (fn: (current: T) => T) => { + value.set(fn(value())); + }; + + return value; +} + +/** + * Compat version of FieldNodeStructure, + * - It has no children + * - It wraps FormControl and proxies its value. + */ +export class CompatStructure extends FieldNodeStructure { + override value: WritableSignal; + override keyInParent: Signal = (() => { + throw new Error('Compat nodes do not use keyInParent.'); + }) as unknown as Signal; + override root: FieldNode; + override pathKeys: Signal; + override readonly children = signal([]); + override readonly childrenMap = signal(undefined); + override readonly parent: ParentFieldNode | undefined; + override readonly fieldManager: FormFieldManager; + + constructor(node: FieldNode, options: CompatFieldNodeOptions) { + super(options.logic); + this.value = getControlValueSignal(options); + this.parent = getParentFromOptions(options); + this.root = this.parent?.structure.root ?? node; + this.fieldManager = getFieldManagerFromOptions(options); + this.pathKeys = computed(() => + this.parent ? [...this.parent.structure.pathKeys(), this.keyInParent()] : [], + ); + } + + override getChild(): FieldNode | undefined { + return undefined; + } +} diff --git a/packages/forms/signals/compat/src/compat_validation_error.ts b/packages/forms/signals/compat/src/compat_validation_error.ts new file mode 100644 index 00000000000..ea58501fa02 --- /dev/null +++ b/packages/forms/signals/compat/src/compat_validation_error.ts @@ -0,0 +1,64 @@ +/** + * @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 {AbstractControl, FormArray, FormGroup, ValidationErrors} from '@angular/forms'; +import {ValidationError} from '../../src/api/validation_errors'; +import {FieldTree} from '../../src/api/types'; + +/** + * Converts reactive form validation error to signal forms CompatValidationError. + * @param errors + * @param control + * @return list of errors. + */ +export function reactiveErrorsToSignalErrors( + errors: ValidationErrors | null, + control: AbstractControl, +): CompatValidationError[] { + if (errors === null) { + return []; + } + + return Object.entries(errors).map(([kind, context]) => { + return new CompatValidationError({context, kind, control}); + }); +} + +export function extractNestedReactiveErrors(control: AbstractControl): CompatValidationError[] { + const errors: CompatValidationError[] = []; + + if (control.errors) { + errors.push(...reactiveErrorsToSignalErrors(control.errors, control)); + } + + if (control instanceof FormGroup || control instanceof FormArray) { + for (const c of Object.values(control.controls)) { + errors.push(...extractNestedReactiveErrors(c)); + } + } + + return errors; +} + +/** + * An error used for compat errors. + */ +export class CompatValidationError implements ValidationError { + readonly kind: string = 'compat'; + readonly control: AbstractControl; + readonly field!: FieldTree; + context: T; + + constructor({context, kind, control}: {context: T; kind: string; control: AbstractControl}) { + this.context = context; + this.kind = kind; + this.control = control; + } + + message?: string | undefined; +} diff --git a/packages/forms/signals/compat/src/compat_validation_state.ts b/packages/forms/signals/compat/src/compat_validation_state.ts new file mode 100644 index 00000000000..0dc634d22b3 --- /dev/null +++ b/packages/forms/signals/compat/src/compat_validation_state.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {computed, signal, Signal} from '@angular/core'; +import {AbstractControl} from '@angular/forms'; +import {getControlStatusSignal} from './compat_field_node'; +import {CompatFieldNodeOptions} from './compat_structure'; +import {calculateValidationSelfStatus, ValidationState} from '../../src/field/validation'; +import {CompatValidationError, extractNestedReactiveErrors} from './compat_validation_error'; +import {ValidationError} from '../../src/api/validation_errors'; + +// Readonly signal containing an empty array, used for optimization. +const EMPTY_ARRAY_SIGNAL = computed(() => []); +const TRUE_SIGNAL = computed(() => true); + +/** + * Compat version of a validation state that wraps a FormControl, and proxies it's validation state. + */ +export class CompatValidationState implements ValidationState { + readonly syncValid: Signal; + /** + * All validation errors for this field. + */ + readonly errors: Signal; + readonly pending: Signal; + readonly invalid: Signal; + readonly valid: Signal; + + constructor(options: CompatFieldNodeOptions) { + this.syncValid = getControlStatusSignal(options, (c: AbstractControl) => c.status === 'VALID'); + this.errors = getControlStatusSignal(options, extractNestedReactiveErrors); + this.pending = getControlStatusSignal(options, (c) => c.pending); + + this.valid = getControlStatusSignal(options, (c) => { + return c.valid; + }); + + this.invalid = getControlStatusSignal(options, (c) => { + return c.invalid; + }); + } + + asyncErrors: Signal<(ValidationError.WithField | 'pending')[]> = EMPTY_ARRAY_SIGNAL; + errorSummary: Signal = EMPTY_ARRAY_SIGNAL; + + // Those are irrelevant for compat mode, as it has no children + rawSyncTreeErrors = EMPTY_ARRAY_SIGNAL; + syncErrors = EMPTY_ARRAY_SIGNAL; + rawAsyncErrors = EMPTY_ARRAY_SIGNAL; + shouldSkipValidation = TRUE_SIGNAL; + + /** + * Computes status based on whether the field is valid/invalid/pending. + */ + readonly status: Signal<'valid' | 'invalid' | 'unknown'> = computed(() => { + return calculateValidationSelfStatus(this); + }); +} diff --git a/packages/forms/signals/src/api/async.ts b/packages/forms/signals/src/api/async.ts index 0e02865637f..27d81bd89cd 100644 --- a/packages/forms/signals/src/api/async.ts +++ b/packages/forms/signals/src/api/async.ts @@ -13,7 +13,7 @@ import {addDefaultField} from '../field/validation'; import {FieldPathNode} from '../schema/path_node'; import {assertPathIsCurrent} from '../schema/schema'; import {metadata} from './logic'; -import {FieldContext, FieldPath, PathKind, TreeValidationResult} from './types'; +import {FieldContext, SchemaPath, PathKind, TreeValidationResult, SchemaPathRules} from './types'; /** * A function that takes the result of an async operation and the current field context, and maps it @@ -152,7 +152,7 @@ export interface HttpValidatorOptions( - path: FieldPath, + path: SchemaPath, opts: AsyncValidatorOptions, ): void { assertPathIsCurrent(path); @@ -207,7 +207,7 @@ export function validateAsync( - path: FieldPath, + path: SchemaPath, opts: HttpValidatorOptions, ) { validateAsync(path, { diff --git a/packages/forms/signals/src/api/field_directive.ts b/packages/forms/signals/src/api/field_directive.ts index 4eafec57760..319d682a7e2 100644 --- a/packages/forms/signals/src/api/field_directive.ts +++ b/packages/forms/signals/src/api/field_directive.ts @@ -21,7 +21,7 @@ import { import {ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms'; import {InteropNgControl} from '../controls/interop_ng_control'; import type {FieldNode} from '../field/node'; -import type {FieldTree} from './types'; +import type {FieldState, FieldTree} from './types'; /** * Lightweight DI token provided by the {@link Field} directive. @@ -88,7 +88,7 @@ export class Field implements ɵControl { ɵinteropControlCreate() { const controlValueAccessor = this.controlValueAccessor!; controlValueAccessor.registerOnChange((value: T) => { - const state = this.state(); + const state = this.state() as FieldState; state.value.set(value); state.markAsDirty(); }); @@ -120,7 +120,7 @@ export class Field implements ɵControl { // (as opposed to just passing the field through to its `field` input). effect( (onCleanup) => { - const fieldNode = this.state() as FieldNode; + const fieldNode = this.state() as unknown as FieldNode; fieldNode.nodeState.fieldBindings.update((controls) => [ ...controls, this as Field, diff --git a/packages/forms/signals/src/api/logic.ts b/packages/forms/signals/src/api/logic.ts index 3c542026d7c..ece46cff099 100644 --- a/packages/forms/signals/src/api/logic.ts +++ b/packages/forms/signals/src/api/logic.ts @@ -12,11 +12,12 @@ import {assertPathIsCurrent} from '../schema/schema'; import {AggregateMetadataKey, createMetadataKey, MetadataKey} from './metadata'; import type { FieldContext, - FieldPath, + SchemaPath, FieldValidator, LogicFn, PathKind, TreeValidator, + SchemaPathRules, } from './types'; import {ensureCustomValidationResult} from './validators/util'; @@ -34,7 +35,7 @@ import {ensureCustomValidationResult} from './validators/util'; * @experimental 21.0.0 */ export function disabled( - path: FieldPath, + path: SchemaPath, logic?: string | NoInfer>, ): void { assertPathIsCurrent(path); @@ -67,7 +68,7 @@ export function disabled( * @experimental 21.0.0 */ export function readonly( - path: FieldPath, + path: SchemaPath, logic: NoInfer> = () => true, ) { assertPathIsCurrent(path); @@ -97,7 +98,7 @@ export function readonly( * @experimental 21.0.0 */ export function hidden( - path: FieldPath, + path: SchemaPath, logic: NoInfer>, ): void { assertPathIsCurrent(path); @@ -118,7 +119,7 @@ export function hidden( * @experimental 21.0.0 */ export function validate( - path: FieldPath, + path: SchemaPath, logic: NoInfer>, ): void { assertPathIsCurrent(path); @@ -144,7 +145,7 @@ export function validate( * @experimental 21.0.0 */ export function validateTree( - path: FieldPath, + path: SchemaPath, logic: NoInfer>, ): void { assertPathIsCurrent(path); @@ -173,7 +174,7 @@ export function aggregateMetadata< TMetadataItem, TPathKind extends PathKind = PathKind.Root, >( - path: FieldPath, + path: SchemaPath, key: AggregateMetadataKey, logic: NoInfer>, ): void { @@ -195,7 +196,7 @@ export function aggregateMetadata< * @experimental 21.0.0 */ export function metadata( - path: FieldPath, + path: SchemaPath, factory: (ctx: FieldContext) => TData, ): MetadataKey; @@ -212,13 +213,13 @@ export function metadata( - path: FieldPath, + path: SchemaPath, key: MetadataKey, factory: (ctx: FieldContext) => TData, ): MetadataKey; export function metadata( - path: FieldPath, + path: SchemaPath, ...rest: | [(ctx: FieldContext) => TData] | [MetadataKey, (ctx: FieldContext) => TData] diff --git a/packages/forms/signals/src/api/structure.ts b/packages/forms/signals/src/api/structure.ts index 47ff1e47dbe..ff55a3ff6ad 100644 --- a/packages/forms/signals/src/api/structure.ts +++ b/packages/forms/signals/src/api/structure.ts @@ -16,7 +16,7 @@ import {FieldPathNode} from '../schema/path_node'; import {assertPathIsCurrent, isSchemaOrSchemaFn, SchemaImpl} from '../schema/schema'; import {isArray} from '../util/type_guards'; import type { - FieldPath, + SchemaPath, FieldTree, LogicFn, OneOrMany, @@ -27,6 +27,7 @@ import type { TreeValidationResult, } from './types'; import type {ValidationError} from './validation_errors'; +import {normalizeFormArgs} from '../util/normalize_form_args'; /** * Options that may be specified when creating a form. @@ -49,29 +50,6 @@ export interface FormOptions { 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 `FieldTree` * of the model data. @@ -93,12 +71,12 @@ function normalizeFormArgs( * structure will match the shape of the model and any changes to the form data will be written to * the model. * @return A `FieldTree` representing a form around the data model. - * @template TValue The type of the data model. + * @template TModel The type of the data model. * * @category structure * @experimental 21.0.0 */ -export function form(model: WritableSignal): FieldTree; +export function form(model: WritableSignal): FieldTree; /** * Creates a form wrapped around the given model data. A form is represented as simply a `FieldTree` @@ -145,10 +123,10 @@ export function form(model: WritableSignal): FieldTree; * @category structure * @experimental 21.0.0 */ -export function form( - model: WritableSignal, - schemaOrOptions: SchemaOrSchemaFn | FormOptions, -): FieldTree; +export function form( + model: WritableSignal, + schemaOrOptions: SchemaOrSchemaFn | FormOptions, +): FieldTree; /** * Creates a form wrapped around the given model data. A form is represented as simply a `FieldTree` @@ -188,19 +166,19 @@ export function form( * @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 `FieldTree` representing a form around the data model. - * @template TValue The type of the data model. + * @template TModel The type of the data model. * * @category structure * @experimental 21.0.0 */ -export function form( - model: WritableSignal, - schema: SchemaOrSchemaFn, +export function form( + model: WritableSignal, + schema: SchemaOrSchemaFn, options: FormOptions, -): FieldTree; +): FieldTree; -export function form(...args: any[]): FieldTree { - const [model, schema, options] = normalizeFormArgs(args); +export function form(...args: any[]): FieldTree { + 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); @@ -208,7 +186,7 @@ export function form(...args: any[]): FieldTree { const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter); fieldManager.createFieldManagementEffect(fieldRoot.structure); - return fieldRoot.fieldProxy as FieldTree; + return fieldRoot.fieldProxy as FieldTree; } /** @@ -250,7 +228,7 @@ export function form(...args: any[]): FieldTree { * @experimental 21.0.0 */ export function applyEach( - path: FieldPath, + path: SchemaPath, schema: NoInfer>, ): void { assertPathIsCurrent(path); @@ -281,7 +259,7 @@ export function applyEach( * @experimental 21.0.0 */ export function apply( - path: FieldPath, + path: SchemaPath, schema: NoInfer>, ): void { assertPathIsCurrent(path); @@ -302,7 +280,7 @@ export function apply( * @experimental 21.0.0 */ export function applyWhen( - path: FieldPath, + path: SchemaPath, logic: LogicFn, schema: NoInfer>, ): void { @@ -326,7 +304,7 @@ export function applyWhen( * @experimental 21.0.0 */ export function applyWhenValue( - path: FieldPath, + path: SchemaPath, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn, ): void; @@ -344,13 +322,13 @@ export function applyWhenValue( * @experimental 21.0.0 */ export function applyWhenValue( - path: FieldPath, + path: SchemaPath, predicate: (value: TValue) => boolean, schema: NoInfer>, ): void; export function applyWhenValue( - path: FieldPath, + path: SchemaPath, predicate: (value: unknown) => boolean, schema: SchemaOrSchemaFn, ) { @@ -386,16 +364,16 @@ export function applyWhenValue( * @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. + * @template TModel The data type of the field being submitted. * * @category submission * @experimental 21.0.0 */ -export async function submit( - form: FieldTree, - action: (form: FieldTree) => Promise, +export async function submit( + form: FieldTree, + action: (form: FieldTree) => Promise, ) { - const node = form() as FieldNode; + const node = form() as unknown as FieldNode; markAllAsTouched(node); // Fail fast if the form is already invalid. diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index 875d32b0317..c86c4b6ca66 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -10,6 +10,7 @@ import {Signal, ɵFieldState} from '@angular/core'; import type {Field} from './field_directive'; import {AggregateMetadataKey, MetadataKey} from './metadata'; import type {ValidationError} from './validation_errors'; +import {AbstractControl} from '@angular/forms'; /** * Symbol used to retain generic type information when it would otherwise be lost. @@ -151,30 +152,36 @@ export type AsyncValidationResult = * @category types * @experimental 21.0.0 */ -export type FieldTree = (() => FieldState< - TValue, - TKey ->) & - (TValue extends Array - ? ReadonlyArrayLike> - : TValue extends Record - ? Subfields - : unknown); +export type FieldTree = + // Unwrapping: + // Intentionally using distributive Conditional Types here: + // https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types + (() => [TModel] extends [AbstractControl] + ? CompatFieldState + : FieldState) & + // Children: + (TModel extends AbstractControl + ? unknown + : TModel extends Array + ? ReadonlyArrayLike> + : TModel extends Record + ? Subfields + : unknown); /** - * The sub-fields that a user can navigate to from a `FieldTree`. + * The sub-fields that a user can navigate to from a `FieldTree`. * - * @template TValue The type of the data which the parent field is wrapped around. + * @template TModel The type of the data which the parent field is wrapped around. * * @experimental 21.0.0 */ -export type Subfields = { - readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree< - TValue[K], +export type Subfields = { + readonly [K in keyof TModel as TModel[K] extends Function ? never : K]: MaybeFieldTree< + TModel[K], string >; } & { - [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; + [Symbol.iterator](): Iterator<[string, MaybeFieldTree]>; }; /** @@ -196,14 +203,14 @@ export type ReadonlyArrayLike = Pick< * For example `MaybeField<{a: number} | undefined, TKey>` would be equivalent to * `undefined | FieldTree<{a: number}, TKey>`. * - * @template TValue The type of the data which the field is wrapped around. + * @template TModel 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. * * @experimental 21.0.0 */ -export type MaybeFieldTree = - | (TValue & undefined) - | FieldTree, TKey>; +export type MaybeFieldTree = + | (TModel & undefined) + | FieldTree, TKey>; /** * Contains all of the state (e.g. value, statuses, etc.) associated with a `FieldTree`, exposed as @@ -308,6 +315,38 @@ export interface FieldState = FieldState ? TValue : never, TKey> & { + control: Signal; +}; + +/** + * Allows declaring whether the Rules are supported for a given path. + * + * @experimental 21.0.0 + **/ +export type SchemaPathRules = SchemaPathRules.Supported | SchemaPathRules.Unsupported; + +export declare namespace SchemaPathRules { + /** + * Used for paths that support settings rules. + */ + type Supported = 1; + + /** + * Used for paths that do not support settings rules, e.g., compatPath. + */ + type Unsupported = 2; +} + /** * An object that represents a location in the `FieldTree` 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` @@ -319,13 +358,63 @@ export interface FieldState = { - [ɵɵTYPE]: [TValue, TPathKind]; -} & (TValue extends Array - ? unknown - : TValue extends Record - ? {[K in keyof TValue]: MaybeFieldPath} - : unknown); +export type SchemaPath< + TValue, + TSupportsRules extends SchemaPathRules = SchemaPathRules.Supported, + TPathKind extends PathKind = PathKind.Root, +> = { + [ɵɵTYPE]: { + value: () => TValue; + supportsRules: TSupportsRules; + pathKind: TPathKind; + }; +}; + +/** + * Schema path used if the value is an AbstractControl. + * + * @category interop + * @experimental 21.0.0 + */ +export type CompatSchemaPath< + TControl extends AbstractControl, + TPathKind extends PathKind = PathKind.Root, +> = SchemaPath< + TControl extends AbstractControl ? TValue : never, + SchemaPathRules.Unsupported, + TPathKind +> & + // & also we capture the control type, so that `stateOf(p)` can unwrap + // to a correctly typed `CompatFieldState`. + { + [ɵɵTYPE]: {control: TControl}; + }; + +/** + * Nested schema path. + * + * It mirrors the structure of a given data structure, and allows applying rules to the appropriate + * fields. + * + * @experimental 21.0.0 + */ +export type SchemaPathTree< + TModel, + TPathKind extends PathKind = PathKind.Root, +> = (TModel extends AbstractControl + ? CompatSchemaPath + : SchemaPath) & + // Subpaths + (TModel extends AbstractControl + ? unknown + : // Array paths have no subpaths + TModel extends Array + ? unknown + : // Object subfields + TModel extends Record + ? {[K in keyof TModel]: MaybeSchemaPathTree} + : // Primitive or other type - no subpaths + unknown); /** * Helper type for defining `FieldPath`. Given a type `TValue` that may include `undefined`, it @@ -339,9 +428,9 @@ export type FieldPath = { * * @experimental 21.0.0 */ -export type MaybeFieldPath = - | (TValue & undefined) - | FieldPath, TPathKind>; +export type MaybeSchemaPathTree = + | (TModel & undefined) + | SchemaPathTree, TPathKind>; /** * Defines logic for a form. @@ -351,35 +440,35 @@ export type MaybeFieldPath = * @category types * @experimental 21.0.0 */ -export type Schema = { - [ɵɵTYPE]: SchemaFn; +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 TModel 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. * * @category types * @experimental 21.0.0 */ -export type SchemaFn = ( - p: FieldPath, +export type SchemaFn = ( + p: SchemaPathTree, ) => 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 TModel 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. * * @category types * @experimental 21.0.0 */ -export type SchemaOrSchemaFn = - | Schema - | SchemaFn; +export type SchemaOrSchemaFn = + | Schema + | SchemaFn; /** * A function that receives the `FieldContext` for the field the logic is bound to and returns @@ -473,12 +562,17 @@ export interface RootFieldContext { readonly state: FieldState; /** The current field. */ readonly field: FieldTree; + /** 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

) => FieldTree

; + valueOf(p: SchemaPath): PValue; + + stateOf( + p: CompatSchemaPath, + ): CompatFieldState; + + stateOf(p: SchemaPath): FieldState; + + fieldTreeOf(p: SchemaPathTree): FieldTree; } /** diff --git a/packages/forms/signals/src/api/validators/email.ts b/packages/forms/signals/src/api/validators/email.ts index 0f8e93f64a1..979f3a5d933 100644 --- a/packages/forms/signals/src/api/validators/email.ts +++ b/packages/forms/signals/src/api/validators/email.ts @@ -7,7 +7,7 @@ */ import {validate} from '../logic'; -import {FieldPath, PathKind} from '../types'; +import {SchemaPath, SchemaPathRules, PathKind} from '../types'; import {emailError} from '../validation_errors'; import {BaseValidatorConfig, getOption, isEmpty} from './util'; @@ -58,7 +58,7 @@ const EMAIL_REGEXP = * @experimental 21.0.0 */ export function email( - path: FieldPath, + path: SchemaPath, config?: BaseValidatorConfig, ) { validate(path, (ctx) => { diff --git a/packages/forms/signals/src/api/validators/max.ts b/packages/forms/signals/src/api/validators/max.ts index 55c48134d0e..e157b996db2 100644 --- a/packages/forms/signals/src/api/validators/max.ts +++ b/packages/forms/signals/src/api/validators/max.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {MAX} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, SchemaPathRules, LogicFn, PathKind} from '../types'; import {maxError} from '../validation_errors'; import {BaseValidatorConfig, getOption, isEmpty} from './util'; @@ -30,7 +30,7 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util'; * @experimental 21.0.0 */ export function max( - path: FieldPath, + path: SchemaPath, maxValue: number | LogicFn, config?: BaseValidatorConfig, ) { diff --git a/packages/forms/signals/src/api/validators/max_length.ts b/packages/forms/signals/src/api/validators/max_length.ts index 13b896ac6ba..c0f8f5e1157 100644 --- a/packages/forms/signals/src/api/validators/max_length.ts +++ b/packages/forms/signals/src/api/validators/max_length.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {MAX_LENGTH} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, SchemaPathRules, LogicFn, PathKind} from '../types'; import {maxLengthError} from '../validation_errors'; import { BaseValidatorConfig, @@ -40,7 +40,7 @@ export function maxLength< TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root, >( - path: FieldPath, + path: SchemaPath, maxLength: number | LogicFn, config?: BaseValidatorConfig, ) { diff --git a/packages/forms/signals/src/api/validators/min.ts b/packages/forms/signals/src/api/validators/min.ts index db7b008ba05..74104c9b8fb 100644 --- a/packages/forms/signals/src/api/validators/min.ts +++ b/packages/forms/signals/src/api/validators/min.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {MIN} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, LogicFn, PathKind, SchemaPathRules} from '../types'; import {minError} from '../validation_errors'; import {BaseValidatorConfig, getOption, isEmpty} from './util'; @@ -30,7 +30,7 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util'; * @experimental 21.0.0 */ export function min( - path: FieldPath, + path: SchemaPath, minValue: number | LogicFn, config?: BaseValidatorConfig, ) { diff --git a/packages/forms/signals/src/api/validators/min_length.ts b/packages/forms/signals/src/api/validators/min_length.ts index c56e5b1ec1e..4a22f2e8b85 100644 --- a/packages/forms/signals/src/api/validators/min_length.ts +++ b/packages/forms/signals/src/api/validators/min_length.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {MIN_LENGTH} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, LogicFn, PathKind, SchemaPathRules} from '../types'; import {minLengthError} from '../validation_errors'; import { BaseValidatorConfig, @@ -40,7 +40,7 @@ export function minLength< TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root, >( - path: FieldPath, + path: SchemaPath, minLength: number | LogicFn, config?: BaseValidatorConfig, ) { diff --git a/packages/forms/signals/src/api/validators/pattern.ts b/packages/forms/signals/src/api/validators/pattern.ts index a74c5bb4bbe..e56b3bc1c3c 100644 --- a/packages/forms/signals/src/api/validators/pattern.ts +++ b/packages/forms/signals/src/api/validators/pattern.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {PATTERN} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, LogicFn, PathKind, SchemaPathRules} from '../types'; import {patternError} from '../validation_errors'; import {BaseValidatorConfig, getOption, isEmpty} from './util'; @@ -29,7 +29,7 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util'; * @experimental 21.0.0 */ export function pattern( - path: FieldPath, + path: SchemaPath, pattern: RegExp | LogicFn, config?: BaseValidatorConfig, ) { diff --git a/packages/forms/signals/src/api/validators/required.ts b/packages/forms/signals/src/api/validators/required.ts index ae8cbe243c3..84700ab16b8 100644 --- a/packages/forms/signals/src/api/validators/required.ts +++ b/packages/forms/signals/src/api/validators/required.ts @@ -9,7 +9,7 @@ import {computed} from '@angular/core'; import {aggregateMetadata, metadata, validate} from '../logic'; import {REQUIRED} from '../metadata'; -import {FieldPath, LogicFn, PathKind} from '../types'; +import {SchemaPath, LogicFn, PathKind, SchemaPathRules} from '../types'; import {requiredError} from '../validation_errors'; import {BaseValidatorConfig, getOption, isEmpty} from './util'; @@ -31,7 +31,7 @@ import {BaseValidatorConfig, getOption, isEmpty} from './util'; * @experimental 21.0.0 */ export function required( - path: FieldPath, + path: SchemaPath, config?: BaseValidatorConfig & { when?: NoInfer>; }, diff --git a/packages/forms/signals/src/api/validators/standard_schema.ts b/packages/forms/signals/src/api/validators/standard_schema.ts index 1d16779322d..687981a9c1a 100644 --- a/packages/forms/signals/src/api/validators/standard_schema.ts +++ b/packages/forms/signals/src/api/validators/standard_schema.ts @@ -6,12 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, resource, ɵisPromise} from '@angular/core'; +import {computed, resource, ɵisPromise, Signal} from '@angular/core'; import type {StandardSchemaV1} from '@standard-schema/spec'; import {addDefaultField} from '../../field/validation'; import {validateAsync} from '../async'; import {metadata, validateTree} from '../logic'; -import {FieldPath, FieldTree} from '../types'; +import type {SchemaPath, SchemaPathTree, FieldTree} from '../types'; import {standardSchemaError, StandardSchemaValidationError} from '../validation_errors'; /** @@ -55,8 +55,8 @@ export type IgnoreUnknownProperties = * @category validation * @experimental 21.0.0 */ -export function validateStandardSchema>( - path: FieldPath, +export function validateStandardSchema>( + path: SchemaPath & SchemaPathTree, schema: StandardSchemaV1, ) { // We create both a sync and async validator because the standard schema validator can return @@ -64,18 +64,29 @@ export function validateStandardSchema { + type Result = StandardSchemaV1.Result | Promise>; + const VALIDATOR_MEMO = metadata>(path, ({value}) => { return computed(() => schema['~standard'].validate(value())); }); - validateTree(path, ({state, fieldOf}) => { + + validateTree(path, ({state, fieldTreeOf}) => { // Skip sync validation if the result is a Promise. const result = state.metadata(VALIDATOR_MEMO)!(); if (ɵisPromise(result)) { return []; } - return result.issues?.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue)) ?? []; + return ( + result.issues?.map((issue) => + standardIssueToFormTreeError(fieldTreeOf(path), issue), + ) ?? [] + ); }); - validateAsync(path, { + + validateAsync< + TModel, + Promise> | undefined, + readonly StandardSchemaV1.Issue[] + >(path, { params: ({state}) => { // Skip async validation if the result is *not* a Promise. const result = state.metadata(VALIDATOR_MEMO)!(); @@ -87,8 +98,8 @@ export function validateStandardSchema (await params)?.issues ?? [], }); }, - onSuccess: (issues, {fieldOf}) => { - return issues.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue)); + onSuccess: (issues, {fieldTreeOf}) => { + return issues.map((issue) => standardIssueToFormTreeError(fieldTreeOf(path), issue)); }, onError: () => {}, }); diff --git a/packages/forms/signals/src/field/context.ts b/packages/forms/signals/src/field/context.ts index 98a820f3a1f..f5f584b7bc1 100644 --- a/packages/forms/signals/src/field/context.ts +++ b/packages/forms/signals/src/field/context.ts @@ -7,7 +7,15 @@ */ import {computed, Signal, untracked, WritableSignal} from '@angular/core'; -import {FieldContext, FieldPath, FieldState, FieldTree} from '../api/types'; +import {AbstractControl} from '@angular/forms'; +import { + FieldContext, + FieldState, + FieldTree, + SchemaPath, + SchemaPathRules, + SchemaPathTree, +} from '../api/types'; import {FieldPathNode} from '../schema/path_node'; import {isArray} from '../util/type_guards'; import type {FieldNode} from './node'; @@ -25,7 +33,10 @@ export class FieldNodeContext implements FieldContext { * actually change, as they only place we currently track fields moving within the parent * structure is for arrays, and paths do not currently support array indexing. */ - private readonly cache = new WeakMap, Signal>>(); + private readonly cache = new WeakMap< + SchemaPath, + Signal> + >(); constructor( /** The field node this context corresponds to. */ @@ -37,7 +48,7 @@ export class FieldNodeContext implements FieldContext { * @param target The path to resolve * @returns The field corresponding to the target path. */ - private resolve(target: FieldPath): FieldTree { + private resolve(target: SchemaPath): FieldTree { if (!this.cache.has(target)) { const resolver = computed>(() => { const targetPathNode = FieldPathNode.unwrapFieldPath(target); @@ -107,7 +118,16 @@ export class FieldNodeContext implements FieldContext { return Number(key); }); - readonly fieldOf =

(p: FieldPath

) => this.resolve(p); - readonly stateOf =

(p: FieldPath

) => this.resolve(p)(); - readonly valueOf =

(p: FieldPath

) => this.resolve(p)().value(); + readonly fieldTreeOf = (p: SchemaPathTree) => this.resolve(p); + readonly stateOf = (p: SchemaPath) => this.resolve(p)(); + readonly valueOf = (p: SchemaPath) => { + const result = this.resolve(p)().value(); + + if (result instanceof AbstractControl) { + throw new Error( + `Tried to read an 'AbstractControl' value form a 'form()'. Did you mean to use 'compatForm()' instead?`, + ); + } + return result; + }; } diff --git a/packages/forms/signals/src/schema/logic.ts b/packages/forms/signals/src/schema/logic.ts index 7b67cb06f36..12324031da9 100644 --- a/packages/forms/signals/src/schema/logic.ts +++ b/packages/forms/signals/src/schema/logic.ts @@ -8,7 +8,7 @@ import {untracked} from '@angular/core'; import {AggregateMetadataKey, MetadataKey} from '../api/metadata'; -import {DisabledReason, type FieldContext, type FieldPath, type LogicFn} from '../api/types'; +import {DisabledReason, type FieldContext, type SchemaPath, type LogicFn} from '../api/types'; import type {ValidationError} from '../api/validation_errors'; import type {FieldNode} from '../field/node'; import {isArray} from '../util/type_guards'; @@ -37,7 +37,7 @@ export interface Predicate { * The path which this predicate was created for. This is used to determine the correct * `FieldContext` to pass to the predicate function. */ - readonly path: FieldPath; + readonly path: SchemaPath; } /** diff --git a/packages/forms/signals/src/schema/path_node.ts b/packages/forms/signals/src/schema/path_node.ts index 23a79201442..7db3717b4ce 100644 --- a/packages/forms/signals/src/schema/path_node.ts +++ b/packages/forms/signals/src/schema/path_node.ts @@ -5,7 +5,7 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ -import {FieldPath} from '../api/types'; +import {SchemaPath, SchemaPathRules} from '../api/types'; import {DYNAMIC, Predicate} from './logic'; import {LogicNodeBuilder} from './logic_node'; import type {SchemaImpl} from './schema'; @@ -32,10 +32,10 @@ export class FieldPathNode { /** * A proxy that wraps the path node, allowing navigation to its child paths via property access. */ - readonly fieldPathProxy: FieldPath = new Proxy( + readonly fieldPathProxy: SchemaPath = new Proxy( this, FIELD_PATH_PROXY_HANDLER, - ) as unknown as FieldPath; + ) as unknown as SchemaPath; /** * For a root path node this will contain the root logic builder. For non-root nodes, @@ -95,7 +95,7 @@ export class FieldPathNode { } /** Extracts the underlying path node from the given path proxy. */ - static unwrapFieldPath(formPath: FieldPath): FieldPathNode { + static unwrapFieldPath(formPath: SchemaPath): FieldPathNode { return (formPath as any)[PATH] as FieldPathNode; } diff --git a/packages/forms/signals/src/schema/schema.ts b/packages/forms/signals/src/schema/schema.ts index 6ebb95f771d..479ac13a275 100644 --- a/packages/forms/signals/src/schema/schema.ts +++ b/packages/forms/signals/src/schema/schema.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {FieldPath, SchemaFn, SchemaOrSchemaFn} from '../api/types'; +import {SchemaPath, SchemaFn, SchemaOrSchemaFn} from '../api/types'; import {FieldPathNode} from './path_node'; /** @@ -103,7 +103,7 @@ export function isSchemaOrSchemaFn(value: unknown): value is SchemaOrSchemaFn): void { +export function assertPathIsCurrent(path: SchemaPath): void { if (currentCompilingNode !== FieldPathNode.unwrapFieldPath(path).root) { throw new Error( `A FieldPath can only be used directly within the Schema that owns it,` + diff --git a/packages/forms/signals/src/util/normalize_form_args.ts b/packages/forms/signals/src/util/normalize_form_args.ts new file mode 100644 index 00000000000..cce22c9f26c --- /dev/null +++ b/packages/forms/signals/src/util/normalize_form_args.ts @@ -0,0 +1,37 @@ +/** + * @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 {WritableSignal} from '@angular/core'; +import type {SchemaOrSchemaFn} from '../api/types'; +import type {FormOptions} from '../api/structure'; +import {isSchemaOrSchemaFn} from '../schema/schema'; + +/** + * Extracts the model, schema, and options from the arguments passed to `form()`. + */ +export 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]; +} diff --git a/packages/forms/signals/test/node/BUILD.bazel b/packages/forms/signals/test/node/BUILD.bazel index a66e8f3ea34..57dad12c25b 100644 --- a/packages/forms/signals/test/node/BUILD.bazel +++ b/packages/forms/signals/test/node/BUILD.bazel @@ -12,6 +12,7 @@ ts_project( "//packages/core/testing", "//packages/forms", "//packages/forms/signals", + "//packages/forms/signals/compat", "//packages/platform-browser", "//packages/platform-browser/testing", "//packages/private/testing", diff --git a/packages/forms/signals/test/node/compat/compat.spec.ts b/packages/forms/signals/test/node/compat/compat.spec.ts new file mode 100644 index 00000000000..625823069d3 --- /dev/null +++ b/packages/forms/signals/test/node/compat/compat.spec.ts @@ -0,0 +1,829 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {ApplicationRef, Injector, signal} from '@angular/core'; +import { + customError, + disabled, + email, + FieldState, + FieldTree, + form, + hidden, + metadata, + readonly, + required, + submit, + TreeValidationResult, + validate, + validateTree, +} from '../../../public_api'; +import {TestBed} from '@angular/core/testing'; +import {FormControl, FormGroup, Validators} from '@angular/forms'; +import {CompatValidationError} from '../../../compat/src/compat_validation_error'; +import {compatForm} from '../../../compat/src/compat_form'; + +function promiseWithResolvers(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: any) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return {promise, resolve, reject}; +} + +describe('Forms compat', () => { + it('should not error on a valid value', () => { + const cat = signal({ + name: 'pirojok-the-cat', + age: new FormControl(5, {nonNullable: true}), + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + const age = f.age(); + expect(age.value()).toBe(5); + expect(f.age().valid()).toBe(true); + }); + + it('should handle and propagate errors', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.age().value()).toBe(5); + expect(f.age().valid()).toBe(true); + expect(f().valid()).toBe(true); + control.setValue(2); + expect(f.age().value()).toBe(2); + expect(f.age().valid()).toBe(false); + expect(f().valid()).toBe(false); + f.age().value.set(100); + expect(f.age().value()).toBe(100); + expect(f.age().valid()).toBe(true); + expect(f().valid()).toBe(true); + }); + + it('can be in root', () => { + const catControl = new FormControl('meow', Validators.minLength(3)); + const cat = signal(catControl); + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f().value()).toBe('meow'); + expect(f().valid()).toBe(true); + }); + + it('picks up the value from a new form control', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.age().value()).toBe(5); + f().value.set({age: new FormControl(10), name: 'lol'}); + expect(f.age().value()).toBe(10); + + const fc = new FormControl(25); + cat.set({ + name: 'meow-the-cat', + age: fc, + }); + expect(f.age().value()).toBe(25); + expect(f().value()).toEqual({ + name: 'meow-the-cat', + age: fc, + }); + }); + + it('handles multiple value and control changes', () => { + const control = new FormControl(100, Validators.min(3)); + const cat = signal(control); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f().value()).toBe(100); + control.setValue(101); + expect(f().value()).toBe(101); + cat.set(new FormControl(6)); + expect(f().value()).toBe(6); + cat.set(new FormControl(7)); + expect(f().value()).toBe(7); + cat.set(new FormControl(8)); + expect(f().value()).toBe(8); + }); + + describe('validation', () => { + it('picks up the validation from a new form control', () => { + const control = new FormControl(5, Validators.min(10)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.age().valid()).toBeFalse(); + expect(f.age().errors()).toEqual([ + new CompatValidationError({control, kind: 'min', context: {min: 10, actual: 5}}), + ]); + f().value.set({age: new FormControl(4), name: 'lol'}); + expect(f.age().valid()).toBeTrue(); + }); + + it('allows to manually set errors', () => { + const catControl = new FormControl('meow', Validators.minLength(3)); + const cat = signal(catControl); + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + catControl.setErrors({meow: true}); + expect(f().errors()).toEqual([ + new CompatValidationError({kind: 'meow', context: true, control: catControl}), + ]); + }); + + it('picks up the multiple errors from a new form control', () => { + const control = new FormControl('pirojok-the-error', [ + (c) => { + return { + [c.value]: {'meow': true}, + 'error-1': 'error-1-content', + 'error-2': 'error-2-content', + }; + }, + ]); + + const cat = signal({ + name: 'pirojok-the-cat', + mistake: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.mistake().valid()).toBeFalse(); + expect(f.mistake().errors()).toEqual([ + new CompatValidationError({kind: 'pirojok-the-error', context: {meow: true}, control}), + new CompatValidationError({kind: 'error-1', context: 'error-1-content', control}), + new CompatValidationError({kind: 'error-2', context: 'error-2-content', control}), + ]); + }); + + it('supports async validation', async () => { + let resolve: Function = () => {}; + const formControl = new FormControl(5, { + nonNullable: true, + asyncValidators: () => { + // can't use promiseWithResolver here, because this runs multiple times across tests. + return new Promise((r) => { + resolve = r; + }); + }, + }); + const cat = signal({ + name: 'pirojok-the-cat', + age: formControl, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.age().pending()).toBeTrue(); + + resolve(null); + + await TestBed.inject(ApplicationRef).whenStable(); + + expect(f.age().pending()).toBeFalse(); + expect(f.age().valid()).toBeTrue(); + + f.age().control().setValue(18); + + expect(f.age().pending()).toBeTrue(); + expect(f.age().valid()).toBeFalse(); + + resolve({'incorrect-cat': 123}); + + await TestBed.inject(ApplicationRef).whenStable(); + + expect(f.age().pending()).toBeFalse(); + expect(f.age().errors()).toEqual([ + new CompatValidationError({kind: 'incorrect-cat', context: 123, control: formControl}), + ]); + }); + }); + + describe('state propagation', () => { + it('propagates disabled state from parent', () => { + const ageControl = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: ageControl, + }); + + const f = compatForm( + cat, + (p) => { + disabled(p, ({value}) => { + return value().name === 'disabled-cat'; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(f.name().disabled()).withContext('name is initially enabled').toBeFalse(); + expect(f.age().disabled()).withContext('age is initially enabled').toBeFalse(); + f.name().value.set('disabled-cat'); + expect(f.name().disabled()).withContext('name is disabled').toBeTrue(); + expect(f.age().disabled()).withContext('age is disabled').toBeTrue(); + f.name().value.set('enabled-cat'); + expect(f.name().disabled()).withContext('name is enabled').toBeFalse(); + expect(f.age().disabled()).withContext('age is enabled').toBeFalse(); + ageControl.disable(); + expect(f.name().disabled()).withContext('name is enabled').toBeFalse(); + expect(f.age().disabled()).withContext('age is disabled').toBeTrue(); + }); + + it('propagates hidden state from parent', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat1', + age: control, + }); + + const f = compatForm( + cat, + (p) => { + hidden(p, ({value}) => { + return value().name === 'hidden-cat'; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(f.name().hidden()).withContext('name is initially displayed').toBeFalse(); + expect(f.age().hidden()).withContext('age is initially displayed').toBeFalse(); + f.name().value.set('hidden-cat'); + expect(f.name().hidden()).withContext('name is hidden').toBeTrue(); + expect(f().hidden()).withContext('name is hidden').toBeTrue(); + expect(f.age().hidden()).withContext('age is hidden').toBeTrue(); + }); + + it('propagates touched state to parent', async () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.name().touched()).withContext('name is initially not touched').toBeFalse(); + expect(f().touched()).withContext('form is initially not touched').toBeFalse(); + + control.markAsTouched(); + + expect(f.age().touched()).withContext('age is touched, when control is touched').toBeTrue(); + expect(f().touched()).withContext('form is touched when a child is touched').toBeTrue(); + + control.markAsUntouched(); + + expect(f.age().touched()).withContext('name is not touched when untouched').toBeFalse(); + expect(f().touched()).withContext('name is not touched when child is untouched').toBeFalse(); + + f.age().markAsTouched(); + + expect(f.age().touched()).withContext('age is touched, when control is touched').toBeTrue(); + expect(f().touched()).withContext('form is touched when a child is touched').toBeTrue(); + }); + + it('picks up submittedState from parent', async () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, {injector: TestBed.inject(Injector)}); + + expect(f().submitting()).toBe(false); + expect(f.age().submitting()).toBe(false); + + const {promise, resolve} = promiseWithResolvers(); + + const result = submit(f as unknown as FieldTree, () => { + return promise; + }); + + expect(f().submitting()).toBe(true); + expect(f.age().submitting()).toBe(true); + + resolve([]); + await result; + + expect(f().submitting()).toBe(false); + expect(f.age().submitting()).toBe(false); + }); + + it('propagates dirty state to parent', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.age().dirty()).withContext('age is initially not dirty').toBeFalse(); + expect(f().dirty()).withContext('form is initially not dirty').toBeFalse(); + + control.markAsDirty(); + + expect(f.age().dirty()).withContext('age is dirty, when control is dirty').toBeTrue(); + expect(f().dirty()).withContext('form is dirty when the child is dirty').toBeTrue(); + + control.markAsPristine(); + + expect(f.age().dirty()).withContext('age is not dirty when marked as pristine').toBeFalse(); + expect(f().dirty()) + .withContext('age is not dirty when age is marked as pristine') + .toBeFalse(); + + f.age().markAsDirty(); + expect(f.age().dirty()).withContext('age is dirty, when control is dirty').toBeTrue(); + expect(f().dirty()).withContext('form is dirty when the child is dirty').toBeTrue(); + }); + + it('allows resetting state', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + control.markAsDirty(); + control.markAsTouched(); + + expect(f.age().dirty()).toBeTrue(); + expect(f.age().touched()).toBeTrue(); + + f.age().reset(); + + expect(f.age().dirty()).toBeFalse(); + expect(f.age().touched()).toBeFalse(); + expect(f().dirty()).toBeFalse(); + expect(f().touched()).toBeFalse(); + }); + }); + + describe('exposing control', () => { + it('works for control', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + address: { + house: control, + }, + }); + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f.address.house().control()).toBe(control); + }); + + it('supports getting control from stateOf', () => { + const control = new FormControl(5, Validators.min(3)); + + const cat = signal({ + name: 'pirojok-the-cat', + address: { + house: control, + }, + }); + + const f = compatForm( + cat, + (p) => { + validate(p, ({stateOf}) => { + return stateOf(p.address.house).control().value === 6 ? undefined : {kind: 'too small'}; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(f().errors()).toEqual([customError({kind: 'too small', field: f})]); + }); + + it('supports getting control from fieldTreeOf', () => { + const control = new FormControl(5, Validators.min(3)); + + const cat = signal({ + name: 'pirojok-the-cat', + address: { + house: control, + }, + }); + + const f = compatForm( + cat, + (p) => { + validate(p, ({fieldTreeOf}) => { + return fieldTreeOf(p.address.house)().control().value === 6 + ? undefined + : {kind: 'too small'}; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(f().errors()).toEqual([customError({kind: 'too small', field: f})]); + }); + + it('fails for regular values', () => { + const control = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + address: { + house: control, + }, + }); + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + // @ts-expect-error + expect(() => f.name().control()).toThrowError(); + }); + }); + + it('disallows passing a path with a FormControl on the type level', () => { + const control = new FormControl(5, {nonNullable: true, validators: [Validators.min(3)]}); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + compatForm( + cat, + (path) => { + // @ts-expect-error + required(path.age); + // @ts-expect-error + validate(path.age, () => { + return undefined; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + }); + + it('allows to use form control values for validation', () => { + const control = new FormControl(5, {nonNullable: true, validators: [Validators.min(3)]}); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + const f = compatForm( + cat, + (path) => { + required(path.name); + + validate(path.name, ({valueOf}) => { + return valueOf(path.age) < 8 ? customError({kind: 'too small'}) : undefined; + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(f.name().valid()).toBe(false); + expect(f.name().errors()).toEqual([ + customError({ + kind: 'too small', + field: f.name, + }), + ]); + + control.setValue(10); + expect(f.name().valid()).toBe(true); + f.age().value.set(4); + expect(f.name().valid()).toBe(false); + f.age().value.update((value) => value + 6); + expect(f.name().valid()).toBe(true); + }); + + describe('arrays', () => { + it('works with removing an element and bringing it back', async () => { + const validCat = new FormControl('valid cat', {nonNullable: true}); + const invalidCat = new FormControl('invalid cat', { + nonNullable: true, + validators: [Validators.maxLength(5)], + }); + + const cats = signal({ + cats: [validCat, invalidCat], + }); + + const f = compatForm(cats, { + injector: TestBed.inject(Injector), + }); + + expect(f.cats[0]().value()).toBe('valid cat'); + expect(f.cats[1]().value()).toBe('invalid cat'); + + expect(f.cats[0]().valid()).withContext('first cat is valid').toBe(true); + expect(f.cats[1]().valid()).withContext('second cat is not valid').toBe(false); + + expect(f().valid()) + .withContext('form is not valid because child validator fails') + .toBe(false); + + cats.set({cats: []}); + expect(f().valid()) + .withContext('form is valid because invalid control has been removed') + .toBe(true); + + cats.set({cats: [validCat]}); + expect(f().valid()) + .withContext('form is valid because only valid control was brought back') + .toBe(true); + + expect(f.cats[0]().value()).toBe('valid cat'); + expect(f.cats[1]).toBe(undefined); + + cats.set({cats: [invalidCat]}); + expect(f().valid()) + .withContext('form is invalid again, because invalid child is back') + .toBe(false); + + expect(f.cats[0]().value()).toBe('invalid cat'); + expect(f.cats[1]).toBe(undefined); + }); + }); + + describe('FormGroup', () => { + it('is supported', () => { + const cat = signal( + new FormGroup({ + name: new FormControl('pirojok-the-cat'), + age: new FormControl(10), + }), + ); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f().value()).toEqual({ + name: 'pirojok-the-cat', + age: 10, + }); + + expect(f().valid()).toEqual(true); + }); + + it('uses the actual value, not rawValue', () => { + const ageControl = new FormControl(10); + const cat = signal( + new FormGroup({ + name: new FormControl('pirojok-the-cat'), + age: ageControl, + }), + ); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + ageControl.disable(); + expect(f().value()).toEqual({ + name: 'pirojok-the-cat', + age: 10, + }); + + expect(f().valid()).toEqual(true); + }); + + it('propagates validity and errors from child controls', () => { + const ageControl = new FormControl(1, Validators.min(5)); + const cat = signal( + new FormGroup({ + name: new FormControl('pirojok-the-cat'), + age: ageControl, + }), + ); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + expect(f().valid()).toEqual(false); + expect(f().errors()).toEqual([ + new CompatValidationError({kind: 'min', control: ageControl, context: {min: 5, actual: 1}}), + ]); + }); + }); + + it(`should not interpret 'FormControl' properties as subfields`, () => { + const cat = signal(new FormControl('pirojok-the-cat')); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + // Does not pick up form control props as form children. + // @ts-expect-error + expect(f.value).toBe(undefined); + // @ts-expect-error + expect(f.setValue).toBe(undefined); + // @ts-expect-error + expect(f.child).toBe(undefined); + }); + + describe('type test all rules', () => { + it('compat form', () => { + const control = new FormControl(5, {nonNullable: true, validators: [Validators.min(3)]}); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + compatForm( + cat, + (path) => { + required(path.name); + + validate(path.name, ({valueOf}) => { + return valueOf(path.age) < 8 ? customError({kind: 'too small'}) : undefined; + }); + required(path.name, { + when: ({valueOf}) => { + return valueOf(path.age) < 8; + }, + }); + validateTree(path.name, ({valueOf}) => { + return valueOf(path.age) < 8 ? [] : []; + }); + + readonly(path.name, ({valueOf}) => { + return valueOf(path.age) < 8; + }); + + metadata(path.name, ({valueOf}) => { + return valueOf(path.age) < 8 ? '' : ''; + }); + + email(path.name, { + error: ({valueOf}) => { + return valueOf(path.age) < 8 ? [] : []; + }, + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect().nothing(); + }); + + describe('regular forms', () => { + it('throws when using valueOf on FormControl', () => { + const control = new FormControl(5, {nonNullable: true, validators: [Validators.min(3)]}); + const cat = signal({ + name: 'pirojok-the-cat', + age: control, + }); + const f = form( + cat, + (p) => { + validate(p.name, ({valueOf}) => { + valueOf(p.age); + }); + }, + { + injector: TestBed.inject(Injector), + }, + ); + + expect(() => f().valid()).toThrowError(/Tried to read an 'AbstractControl' value/); + }); + }); + }); + + describe('type tests', () => { + it('uses raw value', () => { + const ageControl = new FormControl(10); + const cat = signal( + new FormGroup({ + name: new FormControl('pirojok-the-cat', {nonNullable: true}), + age: ageControl, + }), + ); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + const name: string = f().value().name; + }); + + it('unwraps the value', () => { + const ageControl = new FormControl(5, Validators.min(3)); + const cat = signal({ + name: 'pirojok-the-cat', + age: ageControl, + }); + + const f = compatForm(cat, { + injector: TestBed.inject(Injector), + }); + + const age: number | null = f.age().value(); + // @ts-expect-error + const notAge: string = f.age().value(); + }); + + it('allows generic CompatValidationError', () => { + const error = new CompatValidationError({ + kind: 'min', + context: {min: 3, max: 4}, + control: new FormControl(), + }); + + const min: number = error.context.min; + // @ts-expect-error + const notMin: string = error.context.min; + }); + + it('Allows setting T value for non compat fields', () => { + function setStateValue(state: FieldState, value: T) { + state.value.set(value); + } + + function setValueCompatState(field: FieldState, value: T) { + field.value.set(field.value()); + } + + function setValueRegularState(field: FieldState, value: T) { + field.value.set(field.value()); + } + + function setValue(f: FieldTree, value: T) { + // @ts-expect-error + f().value.set(value); + } + }); + }); +}); diff --git a/packages/forms/signals/test/node/compat/compat_validation_error.spec.ts b/packages/forms/signals/test/node/compat/compat_validation_error.spec.ts new file mode 100644 index 00000000000..7a8447840cc --- /dev/null +++ b/packages/forms/signals/test/node/compat/compat_validation_error.spec.ts @@ -0,0 +1,65 @@ +/** + * @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 {FormArray, FormControl, FormGroup, ValidationErrors, ValidatorFn} from '@angular/forms'; +import { + reactiveErrorsToSignalErrors, + CompatValidationError, + extractNestedReactiveErrors, +} from '../../../compat/src/compat_validation_error'; + +describe('destroy$', () => { + const control = new FormControl(); + it('converts an error to a custom error', () => { + expect(reactiveErrorsToSignalErrors({min: true}, control)).toEqual([ + new CompatValidationError({kind: 'min', context: true, control}), + ]); + }); + + it('converts multiple errors', () => { + expect(reactiveErrorsToSignalErrors({min: true, max: {max: 1, actual: 0}}, control)).toEqual([ + new CompatValidationError({kind: 'min', context: true, control}), + new CompatValidationError({kind: 'max', context: {max: 1, actual: 0}, control}), + ]); + }); +}); + +describe('extracts validation errors', () => { + const failingValidator: ValidatorFn = (): ValidationErrors => { + return {fail: true}; + }; + + it('should extract errors from nested controls', () => { + const cityControl = new FormControl('', failingValidator); + const catControl = new FormControl('', failingValidator); + const fg = new FormGroup({ + name: new FormControl(''), + address: new FormGroup({ + city: cityControl, + }), + cats: new FormArray([catControl]), + }); + + expect(extractNestedReactiveErrors(fg)).toEqual([ + new CompatValidationError({kind: 'fail', context: true, control: cityControl}), + new CompatValidationError({kind: 'fail', context: true, control: catControl}), + ]); + }); + + it('should extract errors from the actual control', () => { + const control = new FormControl(1, failingValidator); + const errors = extractNestedReactiveErrors(control); + expect(errors).toEqual([new CompatValidationError({kind: 'fail', context: true, control})]); + }); + + it('should extract errors from the a group', () => { + const control = new FormGroup([], failingValidator); + const errors = extractNestedReactiveErrors(control); + expect(errors).toEqual([new CompatValidationError({kind: 'fail', context: true, control})]); + }); +}); diff --git a/packages/forms/signals/test/node/field_context.spec.ts b/packages/forms/signals/test/node/field_context.spec.ts index e659eb0a098..260a658cc5c 100644 --- a/packages/forms/signals/test/node/field_context.spec.ts +++ b/packages/forms/signals/test/node/field_context.spec.ts @@ -8,17 +8,25 @@ import {Injector, signal, WritableSignal} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {applyEach, FieldContext, FieldPath, form, PathKind, validate} from '../../public_api'; +import { + applyEach, + FieldContext, + SchemaPath, + SchemaPathTree, + form, + PathKind, + validate, +} from '../../public_api'; function testContext( s: WritableSignal, - callback: (ctx: FieldContext, p: FieldPath) => void, + callback: (ctx: FieldContext, p: SchemaPathTree) => void, ) { const isCalled = jasmine.createSpy(); TestBed.runInInjectionContext(() => { - const f = form(s, (p) => { - validate(p, (ctx) => { + const f = form(s, (p) => { + validate(p as SchemaPath, (ctx) => { callback(ctx, p); isCalled(); return undefined; @@ -138,11 +146,11 @@ describe('Field Context', () => { }); }); - it('fieldOf', () => { + it('fieldTreeOf', () => { const cat = signal({name: 'pirojok-the-cat', age: 5}); testContext(cat, (ctx, p) => { - expect(ctx.fieldOf(p.name)().value()).toEqual('pirojok-the-cat'); - expect(ctx.fieldOf(p.age)().value()).toEqual(5); + expect(ctx.fieldTreeOf(p.name)().value()).toEqual('pirojok-the-cat'); + expect(ctx.fieldTreeOf(p.age)().value()).toEqual(5); }); }); }); diff --git a/packages/forms/signals/test/node/field_node.spec.ts b/packages/forms/signals/test/node/field_node.spec.ts index 6878d2c1922..80951f698af 100644 --- a/packages/forms/signals/test/node/field_node.spec.ts +++ b/packages/forms/signals/test/node/field_node.spec.ts @@ -13,7 +13,7 @@ import { applyEach, customError, disabled, - FieldPath, + SchemaPath, form, hidden, readonly, @@ -23,6 +23,7 @@ import { Schema, schema, SchemaOrSchemaFn, + SchemaPathTree, validate, validateTree, ValidationError, @@ -534,10 +535,10 @@ describe('FieldNode', () => { signal({names: [{name: 'Alex'}, {name: 'Miles'}]}), (p) => { applyEach(p.names, (a) => { - disabled(a.name, ({value, fieldOf}) => { - const el = fieldOf(a); + disabled(a.name, ({value, fieldTreeOf}) => { + const el = fieldTreeOf(a); expect(el().value().name).toBe(value()); - expect([...fieldOf(p).names].findIndex((e: any) => e === el)).not.toBe(-1); + expect([...fieldTreeOf(p).names].findIndex((e: any) => e === el)).not.toBe(-1); return true; }); }); @@ -1014,13 +1015,13 @@ describe('FieldNode', () => { const f = form( cat, (p) => { - validateTree(p, ({value, fieldOf}) => { + validateTree(p, ({value, fieldTreeOf}) => { const errors: ValidationError[] = []; if (value().name.length > 8) { - errors.push(customError({kind: 'long_name', field: fieldOf(p.name)})); + errors.push(customError({kind: 'long_name', field: fieldTreeOf(p.name)})); } if (value().age < 0) { - errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)})); + errors.push(customError({kind: 'temporal_anomaly', field: fieldTreeOf(p.age)})); } return errors; }); @@ -1046,13 +1047,13 @@ describe('FieldNode', () => { const f = form( cat, (p) => { - validateTree(p, ({value, fieldOf}) => { + validateTree(p, ({value, fieldTreeOf}) => { const errors: ValidationError[] = []; if (value().name.length > 8) { - errors.push(customError({kind: 'long_name', field: fieldOf(p.name)})); + errors.push(customError({kind: 'long_name', field: fieldTreeOf(p.name)})); } if (value().age < 0) { - errors.push(customError({kind: 'temporal_anomaly', field: fieldOf(p.age)})); + errors.push(customError({kind: 'temporal_anomaly', field: fieldTreeOf(p.age)})); } return errors; }); @@ -1177,7 +1178,7 @@ describe('FieldNode', () => { const opts = {injector: TestBed.inject(Injector)}; const subFn = jasmine.createSpy('schemaFn'); const sub: Schema = schema(subFn); - const s = schema((p: FieldPath<{a: string; b: string}>) => { + const s = schema((p: SchemaPathTree<{a: string; b: string}>) => { apply(p.a, sub); apply(p.b, sub); }); @@ -1227,15 +1228,15 @@ describe('FieldNode', () => { }); it('should error on resolving predefined schema path that is not part of the form', () => { - let otherP: FieldPath; + let otherP: SchemaPath; const s = schema((p) => (otherP = p)); SchemaImpl.rootCompile(s); const f = form( signal(''), (p) => { - disabled(p, ({fieldOf}) => { - fieldOf(otherP); + disabled(p, ({fieldTreeOf}) => { + fieldTreeOf(otherP); return true; }); }, diff --git a/packages/forms/signals/test/node/logic_node.spec.ts b/packages/forms/signals/test/node/logic_node.spec.ts index 45405ecdc13..bee360fcc1d 100644 --- a/packages/forms/signals/test/node/logic_node.spec.ts +++ b/packages/forms/signals/test/node/logic_node.spec.ts @@ -7,17 +7,17 @@ */ import {signal} from '@angular/core'; -import {FieldContext, FieldState, customError} from '../../public_api'; +import {customError, FieldContext} from '../../public_api'; import {DYNAMIC} from '../../src/schema/logic'; import {LogicNodeBuilder} from '../../src/schema/logic_node'; const fakeFieldContext: FieldContext = { - fieldOf: () => undefined!, - stateOf:

() => + fieldTreeOf: () => undefined!, + stateOf: () => ({ context: undefined, structure: {pathKeys: () => [], parent: undefined}, - }) as unknown as FieldState

, + }) as any, valueOf: () => undefined!, field: undefined!, state: undefined!, diff --git a/packages/forms/signals/test/node/recursive_logic.spec.ts b/packages/forms/signals/test/node/recursive_logic.spec.ts index 53d78504c11..c45450a6914 100644 --- a/packages/forms/signals/test/node/recursive_logic.spec.ts +++ b/packages/forms/signals/test/node/recursive_logic.spec.ts @@ -18,9 +18,9 @@ interface TreeData { next: TreeData | null; } -function narrowed( - field: FieldTree | undefined, - guard: (value: TValue) => value is TNarrowed, +function narrowed( + field: FieldTree | undefined, + guard: (value: TModel) => value is TNarrowed, ): Signal | undefined> { return computed( () => field && (guard(field().value()) ? (field as FieldTree) : undefined), diff --git a/packages/forms/signals/test/node/resource.spec.ts b/packages/forms/signals/test/node/resource.spec.ts index 0bfd6fc9238..0bb4c5f0d2b 100644 --- a/packages/forms/signals/test/node/resource.spec.ts +++ b/packages/forms/signals/test/node/resource.spec.ts @@ -161,12 +161,12 @@ describe('resources', () => { return params as Cat[]; }, }), - onSuccess: (cats, {fieldOf}) => { + onSuccess: (cats, {fieldTreeOf}) => { return cats.map((cat, index) => customError({ kind: 'meows_too_much', name: cat.name, - field: fieldOf(p)[index], + field: fieldTreeOf(p)[index], }), ); }, @@ -197,11 +197,11 @@ describe('resources', () => { return params as Cat[]; }, }), - onSuccess: (cats, {fieldOf}) => { + onSuccess: (cats, {fieldTreeOf}) => { return customError({ kind: 'meows_too_much', name: cats[0].name, - field: fieldOf(p)[0], + field: fieldTreeOf(p)[0], }); }, onError: () => null, @@ -269,8 +269,8 @@ describe('resources', () => { required(address.street); validateHttp(address, { request: ({value}) => ({url: '/checkaddress', params: {...value()}}), - onSuccess: (message: string, {fieldOf}) => - customError({message, field: fieldOf(address.street)}), + onSuccess: (message: string, {fieldTreeOf}) => + customError({message, field: fieldTreeOf(address.street)}), onError: () => null, }); }); diff --git a/packages/forms/signals/test/node/validation_status.spec.ts b/packages/forms/signals/test/node/validation_status.spec.ts index ffcef82673f..dc92903ea7f 100644 --- a/packages/forms/signals/test/node/validation_status.spec.ts +++ b/packages/forms/signals/test/node/validation_status.spec.ts @@ -116,8 +116,8 @@ describe('validation status', () => { const f = form( signal({child: 'VALID'}), (p) => { - validateTree(p, ({value, fieldOf}) => - validateValueForChild(value().child, fieldOf(p.child)), + validateTree(p, ({value, fieldTreeOf}) => + validateValueForChild(value().child, fieldTreeOf(p.child)), ); }, {injector}, @@ -135,8 +135,8 @@ describe('validation status', () => { const f = form( signal({child: 'VALID'}), (p) => { - validateTree(p, ({value, fieldOf}) => - validateValueForChild(value().child, fieldOf(p.child)), + validateTree(p, ({value, fieldTreeOf}) => + validateValueForChild(value().child, fieldTreeOf(p.child)), ); }, {injector}, @@ -154,8 +154,8 @@ describe('validation status', () => { const f = form( signal({child: 'VALID', sibling: ''}), (p) => { - validateTree(p, ({value, fieldOf}) => - validateValueForChild(value().child, fieldOf(p.child)), + validateTree(p, ({value, fieldTreeOf}) => + validateValueForChild(value().child, fieldTreeOf(p.child)), ); }, {injector}, @@ -233,10 +233,10 @@ describe('validation status', () => { setTimeout(() => r(validateValueForChild(params, undefined))), ), })), - onSuccess: (results, {fieldOf}) => + onSuccess: (results, {fieldTreeOf}) => results.map((e) => ({ ...e, - field: fieldOf(p.child), + field: fieldTreeOf(p.child), })), onError: () => null, }); @@ -283,10 +283,10 @@ describe('validation status', () => { setTimeout(() => r(validateValueForChild(params, undefined))), ), })), - onSuccess: (results, {fieldOf}) => + onSuccess: (results, {fieldTreeOf}) => results.map((e) => ({ ...e, - field: fieldOf(p.child), + field: fieldTreeOf(p.child), })), onError: () => null, }); @@ -336,10 +336,10 @@ describe('validation status', () => { setTimeout(() => r(validateValueForChild(params, undefined))), ), })), - onSuccess: (results, {fieldOf}) => + onSuccess: (results, {fieldTreeOf}) => results.map((e) => ({ ...e, - field: fieldOf(p.child), + field: fieldTreeOf(p.child), })), onError: () => null, });