refactor(forms): add compatForm

This allows using reactive form controls in signal forms

(cherry picked from commit 60447945bc)
This commit is contained in:
kirjs 2025-10-31 08:45:10 -04:00 committed by Jessica Janiuk
parent 3bed9f0f16
commit 3bbcae6ce8
36 changed files with 2039 additions and 240 deletions

View file

@ -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<TValue, TMetadataItem, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, key: AggregateMetadataKey<any, TMetadataItem>, logic: NoInfer<LogicFn<TValue, TMetadataItem, TPathKind>>): void;
export function aggregateMetadata<TValue, TMetadataItem, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, key: AggregateMetadataKey<any, TMetadataItem>, logic: NoInfer<LogicFn<TValue, TMetadataItem, TPathKind>>): void;
// @public
export class AggregateMetadataKey<TAcc, TItem> {
@ -42,19 +43,19 @@ export class AggregateMetadataKey<TAcc, TItem> {
export function andMetadataKey(): AggregateMetadataKey<boolean, boolean>;
// @public
export function apply<TValue>(path: FieldPath<TValue>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
export function apply<TValue>(path: SchemaPath<TValue>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export function applyEach<TValue>(path: FieldPath<TValue[]>, schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>): void;
export function applyEach<TValue>(path: SchemaPath<TValue[]>, schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>): void;
// @public
export function applyWhen<TValue>(path: FieldPath<TValue>, logic: LogicFn<TValue, boolean>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
export function applyWhen<TValue>(path: SchemaPath<TValue>, logic: LogicFn<TValue, boolean>, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export function applyWhenValue<TValue, TNarrowed extends TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn<TNarrowed>): void;
export function applyWhenValue<TValue, TNarrowed extends TValue>(path: SchemaPath<TValue>, predicate: (value: TValue) => value is TNarrowed, schema: SchemaOrSchemaFn<TNarrowed>): void;
// @public
export function applyWhenValue<TValue>(path: FieldPath<TValue>, predicate: (value: TValue) => boolean, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
export function applyWhenValue<TValue>(path: SchemaPath<TValue>, predicate: (value: TValue) => boolean, schema: NoInfer<SchemaOrSchemaFn<TValue>>): void;
// @public
export type AsyncValidationResult<E extends ValidationError = ValidationError> = ValidationResult<E> | 'pending';
@ -72,6 +73,18 @@ export interface ChildFieldContext<TValue> extends RootFieldContext<TValue> {
readonly key: Signal<string>;
}
// @public
export type CompatFieldState<TControl extends AbstractControl, TKey extends string | number = string | number> = FieldState<TControl extends AbstractControl<unknown, infer TValue> ? TValue : never, TKey> & {
control: Signal<TControl>;
};
// @public
export type CompatSchemaPath<TControl extends AbstractControl, TPathKind extends PathKind = PathKind.Root> = SchemaPath<TControl extends AbstractControl<unknown, infer TValue> ? TValue : never, SchemaPathRules.Unsupported, TPathKind> & {
[ɵɵTYPE]: {
control: TControl;
};
};
// @public
export function createMetadataKey<TValue>(): MetadataKey<TValue>;
@ -91,7 +104,7 @@ export class CustomValidationError implements ValidationError {
}
// @public
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
// @public
export interface DisabledReason {
@ -100,7 +113,7 @@ export interface DisabledReason {
}
// @public
export function email<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
export function email<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
// @public
export function emailError(options: WithField<ValidationErrorOptions>): EmailValidationError;
@ -124,7 +137,7 @@ export class Field<T> implements ɵControl<T> {
// (undocumented)
readonly field: i0.InputSignal<FieldTree<T>>;
// (undocumented)
readonly state: i0.Signal<FieldState<T, string | number>>;
readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<Field<any>, "[field]", never, { "field": { "alias": "field"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
// (undocumented)
@ -143,13 +156,6 @@ export class Field<T> implements ɵControl<T> {
// @public
export type FieldContext<TValue, TPathKind extends PathKind = PathKind.Root> = TPathKind extends PathKind.Item ? ItemFieldContext<TValue> : TPathKind extends PathKind.Child ? ChildFieldContext<TValue> : RootFieldContext<TValue>;
// @public
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
[ɵɵTYPE]: [TValue, TPathKind];
} & (TValue extends Array<unknown> ? unknown : TValue extends Record<string, any> ? {
[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>;
} : unknown);
// @public
export interface FieldState<TValue, TKey extends string | number = string | number> extends ɵFieldState<TValue> {
readonly dirty: Signal<boolean>;
@ -172,19 +178,19 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
}
// @public
export type FieldTree<TValue, TKey extends string | number = string | number> = (() => FieldState<TValue, TKey>) & (TValue extends Array<infer U> ? ReadonlyArrayLike<MaybeFieldTree<U, number>> : TValue extends Record<string, any> ? Subfields<TValue> : unknown);
export type FieldTree<TModel, TKey extends string | number = string | number> = (() => [TModel] extends [AbstractControl] ? CompatFieldState<TModel, TKey> : FieldState<TModel, TKey>) & (TModel extends AbstractControl ? unknown : TModel extends Array<infer U> ? ReadonlyArrayLike<MaybeFieldTree<U, number>> : TModel extends Record<string, any> ? Subfields<TModel> : unknown);
// @public
export type FieldValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, ValidationResult<ValidationError.WithoutField>, TPathKind>;
// @public
export function form<TValue>(model: WritableSignal<TValue>): FieldTree<TValue>;
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
// @public
export function form<TValue>(model: WritableSignal<TValue>, schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions): FieldTree<TValue>;
export function form<TModel>(model: WritableSignal<TModel>, schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions): FieldTree<TModel>;
// @public
export function form<TValue>(model: WritableSignal<TValue>, schema: SchemaOrSchemaFn<TValue>, options: FormOptions): FieldTree<TValue>;
export function form<TModel>(model: WritableSignal<TModel>, schema: SchemaOrSchemaFn<TModel>, options: FormOptions): FieldTree<TModel>;
// @public
export interface FormCheckboxControl extends FormUiControl {
@ -227,7 +233,7 @@ export interface FormValueControl<TValue> extends FormUiControl {
}
// @public
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
// @public
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
@ -260,7 +266,7 @@ export type MapToErrorsFn<TValue, TResult, TPathKind extends PathKind = PathKind
export const MAX: AggregateMetadataKey<number | undefined, number | undefined>;
// @public
export function max<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, maxValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
export function max<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>, maxValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
// @public
export const MAX_LENGTH: AggregateMetadataKey<number | undefined, number | undefined>;
@ -272,7 +278,7 @@ export function maxError(max: number, options: WithField<ValidationErrorOptions>
export function maxError(max: number, options?: ValidationErrorOptions): WithoutField<MaxValidationError>;
// @public
export function maxLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, maxLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
export function maxLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, maxLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
// @public
export function maxLengthError(maxLength: number, options: WithField<ValidationErrorOptions>): MaxLengthValidationError;
@ -302,16 +308,16 @@ export class MaxValidationError extends _NgValidationError {
}
// @public
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = (TValue & undefined) | FieldPath<Exclude<TValue, undefined>, TPathKind>;
export type MaybeFieldTree<TModel, TKey extends string | number = string | number> = (TModel & undefined) | FieldTree<Exclude<TModel, undefined>, TKey>;
// @public
export type MaybeFieldTree<TValue, TKey extends string | number = string | number> = (TValue & undefined) | FieldTree<Exclude<TValue, undefined>, TKey>;
export type MaybeSchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> = (TModel & undefined) | SchemaPathTree<Exclude<TModel, undefined>, TPathKind>;
// @public
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): MetadataKey<TData>;
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): MetadataKey<TData>;
// @public
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, key: MetadataKey<TData>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): MetadataKey<TData>;
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, key: MetadataKey<TData>, factory: (ctx: FieldContext<TValue, TPathKind>) => TData): MetadataKey<TData>;
// @public
export class MetadataKey<TValue> {
@ -321,7 +327,7 @@ export class MetadataKey<TValue> {
export const MIN: AggregateMetadataKey<number | undefined, number | undefined>;
// @public
export function min<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<number, TPathKind>, minValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
export function min<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>, minValue: number | LogicFn<number, number | undefined, TPathKind>, config?: BaseValidatorConfig<number, TPathKind>): void;
// @public
export const MIN_LENGTH: AggregateMetadataKey<number | undefined, number | undefined>;
@ -333,7 +339,7 @@ export function minError(min: number, options: WithField<ValidationErrorOptions>
export function minError(min: number, options?: ValidationErrorOptions): WithoutField<MinValidationError>;
// @public
export function minLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, minLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
export function minLength<TValue extends ValueWithLengthOrSize, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, minLength: number | LogicFn<TValue, number | undefined, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind>): void;
// @public
export function minLengthError(minLength: number, options: WithField<ValidationErrorOptions>): MinLengthValidationError;
@ -396,7 +402,7 @@ export namespace PathKind {
export const PATTERN: AggregateMetadataKey<RegExp[], RegExp | undefined>;
// @public
export function pattern<TPathKind extends PathKind = PathKind.Root>(path: FieldPath<string, TPathKind>, pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
export function pattern<TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>, pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>, config?: BaseValidatorConfig<string, TPathKind>): void;
// @public
export function patternError(pattern: RegExp, options: WithField<ValidationErrorOptions>): PatternValidationError;
@ -414,7 +420,7 @@ export class PatternValidationError extends _NgValidationError {
}
// @public
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: NoInfer<LogicFn<TValue, boolean, TPathKind>>): void;
// @public
export type ReadonlyArrayLike<T> = Pick<ReadonlyArray<T>, number | 'length' | typeof Symbol.iterator>;
@ -429,7 +435,7 @@ export type RemoveStringIndexUnknownKey<K, V> = string extends K ? unknown exten
export const REQUIRED: AggregateMetadataKey<boolean, boolean>;
// @public
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind> & {
export function required<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, config?: BaseValidatorConfig<TValue, TPathKind> & {
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
}): void;
@ -448,26 +454,53 @@ export class RequiredValidationError extends _NgValidationError {
// @public
export interface RootFieldContext<TValue> {
readonly field: FieldTree<TValue>;
readonly fieldOf: <P>(p: FieldPath<P>) => FieldTree<P>;
// (undocumented)
fieldTreeOf<PModel>(p: SchemaPathTree<PModel>): FieldTree<PModel>;
readonly state: FieldState<TValue>;
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
// (undocumented)
stateOf<PControl extends AbstractControl>(p: CompatSchemaPath<PControl>): CompatFieldState<PControl>;
// (undocumented)
stateOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): FieldState<PValue>;
readonly value: Signal<TValue>;
readonly valueOf: <P>(p: FieldPath<P>) => P;
valueOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): PValue;
}
// @public
export type Schema<in TValue> = {
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
export type Schema<in TModel> = {
[ɵɵTYPE]: SchemaFn<TModel, PathKind.Root>;
};
// @public
export function schema<TValue>(fn: SchemaFn<TValue>): Schema<TValue>;
// @public
export type SchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = (p: FieldPath<TValue, TPathKind>) => void;
export type SchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> = (p: SchemaPathTree<TModel, TPathKind>) => void;
// @public
export type SchemaOrSchemaFn<TValue, TPathKind extends PathKind = PathKind.Root> = Schema<TValue> | SchemaFn<TValue, TPathKind>;
export type SchemaOrSchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> = Schema<TModel> | SchemaFn<TModel, TPathKind>;
// @public
export type SchemaPath<TValue, TSupportsRules extends SchemaPathRules = SchemaPathRules.Supported, TPathKind extends PathKind = PathKind.Root> = {
[ɵɵ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, TPathKind extends PathKind = PathKind.Root> = (TModel extends AbstractControl ? CompatSchemaPath<TModel, TPathKind> : SchemaPath<TModel, SchemaPathRules.Supported, TPathKind>) & (TModel extends AbstractControl ? unknown : TModel extends Array<any> ? unknown : TModel extends Record<string, any> ? {
[K in keyof TModel]: MaybeSchemaPathTree<TModel[K], PathKind.Child>;
} : unknown);
// @public
export function standardSchemaError(issue: StandardSchemaV1.Issue, options: WithField<ValidationErrorOptions>): StandardSchemaValidationError;
@ -485,14 +518,14 @@ export class StandardSchemaValidationError extends _NgValidationError {
}
// @public
export type Subfields<TValue> = {
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree<TValue[K], string>;
export type Subfields<TModel> = {
readonly [K in keyof TModel as TModel[K] extends Function ? never : K]: MaybeFieldTree<TModel[K], string>;
} & {
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TValue[keyof TValue], string>]>;
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TModel[keyof TModel], string>]>;
};
// @public
export function submit<TValue>(form: FieldTree<TValue>, action: (form: FieldTree<TValue>) => Promise<TreeValidationResult>): Promise<void>;
export function submit<TModel>(form: FieldTree<TModel>, action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>): Promise<void>;
// @public
export type SubmittedStatus = 'unsubmitted' | 'submitted' | 'submitting';
@ -504,19 +537,19 @@ export type TreeValidationResult<E extends ValidationError.WithOptionalField = V
export type TreeValidator<TValue, TPathKind extends PathKind = PathKind.Root> = LogicFn<TValue, TreeValidationResult, TPathKind>;
// @public
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<FieldValidator<TValue, TPathKind>>): void;
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic: NoInfer<FieldValidator<TValue, TPathKind>>): void;
// @public
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>): void;
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>): void;
// @public
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, opts: HttpValidatorOptions<TValue, TResult, TPathKind>): void;
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, opts: HttpValidatorOptions<TValue, TResult, TPathKind>): void;
// @public
export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProperties<TSchema>>(path: FieldPath<TValue>, schema: StandardSchemaV1<TSchema>): void;
export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProperties<TSchema>>(path: SchemaPath<TModel> & SchemaPathTree<TModel>, schema: StandardSchemaV1<TSchema>): void;
// @public
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: FieldPath<TValue, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;
// @public
export interface ValidationError {

View file

@ -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",
],
)

View file

@ -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<TModel>(
fieldManager: FormFieldManager,
value: WritableSignal<TModel>,
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<TModel>(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<AbstractControl>;
return new CompatFieldNode({
...options,
control,
});
}

View file

@ -0,0 +1,133 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {computed, 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<AbstractControl>;
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<void>(1);
return () => {
if (destroy$) {
destroy$.next();
destroy$.complete();
}
return (destroy$ = new ReplaySubject<void>(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<T, R = T>(
options: CompatFieldNodeOptions,
makeSignal: (c: AbstractControl<unknown, T>, destroy$: Observable<void>) => Signal<R>,
): Signal<R> {
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 = <T>(
options: CompatFieldNodeOptions,
getValue: (c: AbstractControl<unknown>) => T,
) => {
return extractControlPropToSignal<unknown, T>(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 = <T>(
options: CompatFieldNodeOptions,
getValue: (c: AbstractControl) => T,
) => {
return extractControlPropToSignal<unknown, T>(options, (c, destroy$) =>
toSignal(
c.events.pipe(
map(() => {
return getValue(c);
}),
takeUntil(destroy$),
),
{
initialValue: getValue(c),
},
),
);
};

View file

@ -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<FormOptions, 'adapter'>;
/**
* 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<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
/**
* 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<TModel>(
model: WritableSignal<TModel>,
schemaOrOptions: SchemaOrSchemaFn<TModel> | CompatFormOptions,
): FieldTree<TModel>;
/**
* 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<TModel>(
model: WritableSignal<TModel>,
schema: SchemaOrSchemaFn<TModel>,
options: CompatFormOptions,
): FieldTree<TModel>;
export function compatForm<TModel>(...args: any[]): FieldTree<TModel> {
const [model, maybeSchema, maybeOptions] = normalizeFormArgs<TModel>(args);
const options = {...maybeOptions, adapter: new CompatFieldAdapter()};
const schema = maybeSchema || ((() => {}) as SchemaOrSchemaFn<TModel, PathKind>);
return form(model, schema, options) as FieldTree<TModel>;
}

View file

@ -0,0 +1,54 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {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<boolean>;
override readonly dirty: Signal<boolean>;
override readonly disabled: Signal<boolean>;
private readonly control: Signal<AbstractControl>;
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();
}
}

View file

@ -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<AbstractControl>;
}
/**
* Root Field Node options also exposing control property.
*/
export interface CompatRootFieldNodeOptions extends RootFieldNodeOptions {
control: Signal<AbstractControl>;
}
/**
* 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<T>(options: CompatFieldNodeOptions) {
const value = extractControlPropToSignal<T>(options, (control, destroy$) => {
return toSignal(
control.valueChanges.pipe(
map(() => control.getRawValue()),
takeUntil(destroy$),
),
{
initialValue: control.getRawValue(),
},
);
}) as WritableSignal<T>;
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<unknown>;
override keyInParent: Signal<string> = (() => {
throw new Error('Compat nodes do not use keyInParent.');
}) as unknown as Signal<string>;
override root: FieldNode;
override pathKeys: Signal<readonly PropertyKey[]>;
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;
}
}

View file

@ -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<T = unknown> implements ValidationError {
readonly kind: string = 'compat';
readonly control: AbstractControl;
readonly field!: FieldTree<unknown>;
context: T;
constructor({context, kind, control}: {context: T; kind: string; control: AbstractControl}) {
this.context = context;
this.kind = kind;
this.control = control;
}
message?: string | undefined;
}

View file

@ -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<boolean>;
/**
* All validation errors for this field.
*/
readonly errors: Signal<CompatValidationError[]>;
readonly pending: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly valid: Signal<boolean>;
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<ValidationError.WithField[]> = 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);
});
}

View file

@ -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<TValue, TResult, TPathKind extends PathKin
* @experimental 21.0.0
*/
export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
opts: AsyncValidatorOptions<TValue, TParams, TResult, TPathKind>,
): void {
assertPathIsCurrent(path);
@ -207,7 +207,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
* @experimental 21.0.0
*/
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
) {
validateAsync(path, {

View file

@ -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<T> implements ɵControl<T> {
ɵinteropControlCreate() {
const controlValueAccessor = this.controlValueAccessor!;
controlValueAccessor.registerOnChange((value: T) => {
const state = this.state();
const state = this.state() as FieldState<T>;
state.value.set(value);
state.markAsDirty();
});
@ -120,7 +120,7 @@ export class Field<T> implements ɵControl<T> {
// (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<unknown>,

View file

@ -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<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>,
): void {
assertPathIsCurrent(path);
@ -67,7 +68,7 @@ export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(
* @experimental 21.0.0
*/
export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>> = () => true,
) {
assertPathIsCurrent(path);
@ -97,7 +98,7 @@ export function readonly<TValue, TPathKind extends PathKind = PathKind.Root>(
* @experimental 21.0.0
*/
export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic: NoInfer<LogicFn<TValue, boolean, TPathKind>>,
): void {
assertPathIsCurrent(path);
@ -118,7 +119,7 @@ export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(
* @experimental 21.0.0
*/
export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic: NoInfer<FieldValidator<TValue, TPathKind>>,
): void {
assertPathIsCurrent(path);
@ -144,7 +145,7 @@ export function validate<TValue, TPathKind extends PathKind = PathKind.Root>(
* @experimental 21.0.0
*/
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
logic: NoInfer<TreeValidator<TValue, TPathKind>>,
): void {
assertPathIsCurrent(path);
@ -173,7 +174,7 @@ export function aggregateMetadata<
TMetadataItem,
TPathKind extends PathKind = PathKind.Root,
>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
key: AggregateMetadataKey<any, TMetadataItem>,
logic: NoInfer<LogicFn<TValue, TMetadataItem, TPathKind>>,
): void {
@ -195,7 +196,7 @@ export function aggregateMetadata<
* @experimental 21.0.0
*/
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
): MetadataKey<TData>;
@ -212,13 +213,13 @@ export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Ro
* @experimental 21.0.0
*/
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
key: MetadataKey<TData>,
factory: (ctx: FieldContext<TValue, TPathKind>) => TData,
): MetadataKey<TData>;
export function metadata<TValue, TData, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
...rest:
| [(ctx: FieldContext<TValue, TPathKind>) => TData]
| [MetadataKey<TData>, (ctx: FieldContext<TValue, TPathKind>) => TData]

View file

@ -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<TValue>(
args: any[],
): [WritableSignal<TValue>, SchemaOrSchemaFn<TValue> | undefined, FormOptions | undefined] {
let model: WritableSignal<TValue>;
let schema: SchemaOrSchemaFn<TValue> | undefined;
let options: FormOptions | undefined;
if (args.length === 3) {
[model, schema, options] = args;
} else if (args.length === 2) {
if (isSchemaOrSchemaFn(args[1])) {
[model, schema] = args;
} else {
[model, options] = args;
}
} else {
[model] = args;
}
return [model, schema, options];
}
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `FieldTree`
* of the model data.
@ -93,12 +71,12 @@ function normalizeFormArgs<TValue>(
* 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<TValue>(model: WritableSignal<TValue>): FieldTree<TValue>;
export function form<TModel>(model: WritableSignal<TModel>): FieldTree<TModel>;
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `FieldTree`
@ -145,10 +123,10 @@ export function form<TValue>(model: WritableSignal<TValue>): FieldTree<TValue>;
* @category structure
* @experimental 21.0.0
*/
export function form<TValue>(
model: WritableSignal<TValue>,
schemaOrOptions: SchemaOrSchemaFn<TValue> | FormOptions,
): FieldTree<TValue>;
export function form<TModel>(
model: WritableSignal<TModel>,
schemaOrOptions: SchemaOrSchemaFn<TModel> | FormOptions,
): FieldTree<TModel>;
/**
* Creates a form wrapped around the given model data. A form is represented as simply a `FieldTree`
@ -188,19 +166,19 @@ export function form<TValue>(
* @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<TValue>(
model: WritableSignal<TValue>,
schema: SchemaOrSchemaFn<TValue>,
export function form<TModel>(
model: WritableSignal<TModel>,
schema: SchemaOrSchemaFn<TModel>,
options: FormOptions,
): FieldTree<TValue>;
): FieldTree<TModel>;
export function form<TValue>(...args: any[]): FieldTree<TValue> {
const [model, schema, options] = normalizeFormArgs<TValue>(args);
export function form<TModel>(...args: any[]): FieldTree<TModel> {
const [model, schema, options] = normalizeFormArgs<TModel>(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<TValue>(...args: any[]): FieldTree<TValue> {
const fieldRoot = FieldNode.newRoot(fieldManager, model, pathNode, adapter);
fieldManager.createFieldManagementEffect(fieldRoot.structure);
return fieldRoot.fieldProxy as FieldTree<TValue>;
return fieldRoot.fieldProxy as FieldTree<TModel>;
}
/**
@ -250,7 +228,7 @@ export function form<TValue>(...args: any[]): FieldTree<TValue> {
* @experimental 21.0.0
*/
export function applyEach<TValue>(
path: FieldPath<TValue[]>,
path: SchemaPath<TValue[]>,
schema: NoInfer<SchemaOrSchemaFn<TValue, PathKind.Item>>,
): void {
assertPathIsCurrent(path);
@ -281,7 +259,7 @@ export function applyEach<TValue>(
* @experimental 21.0.0
*/
export function apply<TValue>(
path: FieldPath<TValue>,
path: SchemaPath<TValue>,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void {
assertPathIsCurrent(path);
@ -302,7 +280,7 @@ export function apply<TValue>(
* @experimental 21.0.0
*/
export function applyWhen<TValue>(
path: FieldPath<TValue>,
path: SchemaPath<TValue>,
logic: LogicFn<TValue, boolean>,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void {
@ -326,7 +304,7 @@ export function applyWhen<TValue>(
* @experimental 21.0.0
*/
export function applyWhenValue<TValue, TNarrowed extends TValue>(
path: FieldPath<TValue>,
path: SchemaPath<TValue>,
predicate: (value: TValue) => value is TNarrowed,
schema: SchemaOrSchemaFn<TNarrowed>,
): void;
@ -344,13 +322,13 @@ export function applyWhenValue<TValue, TNarrowed extends TValue>(
* @experimental 21.0.0
*/
export function applyWhenValue<TValue>(
path: FieldPath<TValue>,
path: SchemaPath<TValue>,
predicate: (value: TValue) => boolean,
schema: NoInfer<SchemaOrSchemaFn<TValue>>,
): void;
export function applyWhenValue(
path: FieldPath<unknown>,
path: SchemaPath<unknown>,
predicate: (value: unknown) => boolean,
schema: SchemaOrSchemaFn<unknown>,
) {
@ -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<TValue>(
form: FieldTree<TValue>,
action: (form: FieldTree<TValue>) => Promise<TreeValidationResult>,
export async function submit<TModel>(
form: FieldTree<TModel>,
action: (form: FieldTree<TModel>) => Promise<TreeValidationResult>,
) {
const node = form() as FieldNode;
const node = form() as unknown as FieldNode;
markAllAsTouched(node);
// Fail fast if the form is already invalid.

View file

@ -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<E extends ValidationError = ValidationError> =
* @category types
* @experimental 21.0.0
*/
export type FieldTree<TValue, TKey extends string | number = string | number> = (() => FieldState<
TValue,
TKey
>) &
(TValue extends Array<infer U>
? ReadonlyArrayLike<MaybeFieldTree<U, number>>
: TValue extends Record<string, any>
? Subfields<TValue>
: unknown);
export type FieldTree<TModel, TKey extends string | number = string | number> =
// Unwrapping:
// Intentionally using distributive Conditional Types here:
// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types
(() => [TModel] extends [AbstractControl]
? CompatFieldState<TModel, TKey>
: FieldState<TModel, TKey>) &
// Children:
(TModel extends AbstractControl
? unknown
: TModel extends Array<infer U>
? ReadonlyArrayLike<MaybeFieldTree<U, number>>
: TModel extends Record<string, any>
? Subfields<TModel>
: unknown);
/**
* The sub-fields that a user can navigate to from a `FieldTree<TValue>`.
* The sub-fields that a user can navigate to from a `FieldTree<TModel>`.
*
* @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<TValue> = {
readonly [K in keyof TValue as TValue[K] extends Function ? never : K]: MaybeFieldTree<
TValue[K],
export type Subfields<TModel> = {
readonly [K in keyof TModel as TModel[K] extends Function ? never : K]: MaybeFieldTree<
TModel[K],
string
>;
} & {
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TValue[keyof TValue], string>]>;
[Symbol.iterator](): Iterator<[string, MaybeFieldTree<TModel[keyof TModel], string>]>;
};
/**
@ -196,14 +203,14 @@ export type ReadonlyArrayLike<T> = 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, TKey extends string | number = string | number> =
| (TValue & undefined)
| FieldTree<Exclude<TValue, undefined>, TKey>;
export type MaybeFieldTree<TModel, TKey extends string | number = string | number> =
| (TModel & undefined)
| FieldTree<Exclude<TModel, undefined>, TKey>;
/**
* Contains all of the state (e.g. value, statuses, etc.) associated with a `FieldTree`, exposed as
@ -308,6 +315,38 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
reset(): void;
}
/**
* This is FieldState also providing access to the wrapped FormControl.
*
* @category interop
* @experimental 21.0.0
*/
export type CompatFieldState<
TControl extends AbstractControl,
TKey extends string | number = string | number,
> = FieldState<TControl extends AbstractControl<unknown, infer TValue> ? TValue : never, TKey> & {
control: Signal<TControl>;
};
/**
* 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<TValue, TKey extends string | number = string | numb
* @category types
* @experimental 21.0.0
*/
export type FieldPath<TValue, TPathKind extends PathKind = PathKind.Root> = {
[ɵɵTYPE]: [TValue, TPathKind];
} & (TValue extends Array<unknown>
? unknown
: TValue extends Record<string, any>
? {[K in keyof TValue]: MaybeFieldPath<TValue[K], PathKind.Child>}
: unknown);
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<unknown, infer TValue> ? 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<TModel, TPathKind>
: SchemaPath<TModel, SchemaPathRules.Supported, TPathKind>) &
// Subpaths
(TModel extends AbstractControl
? unknown
: // Array paths have no subpaths
TModel extends Array<any>
? unknown
: // Object subfields
TModel extends Record<string, any>
? {[K in keyof TModel]: MaybeSchemaPathTree<TModel[K], PathKind.Child>}
: // 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<TValue, TPathKind extends PathKind = PathKind.Root> = {
*
* @experimental 21.0.0
*/
export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> =
| (TValue & undefined)
| FieldPath<Exclude<TValue, undefined>, TPathKind>;
export type MaybeSchemaPathTree<TModel, TPathKind extends PathKind = PathKind.Root> =
| (TModel & undefined)
| SchemaPathTree<Exclude<TModel, undefined>, TPathKind>;
/**
* Defines logic for a form.
@ -351,35 +440,35 @@ export type MaybeFieldPath<TValue, TPathKind extends PathKind = PathKind.Root> =
* @category types
* @experimental 21.0.0
*/
export type Schema<in TValue> = {
[ɵɵTYPE]: SchemaFn<TValue, PathKind.Root>;
export type Schema<in TModel> = {
[ɵɵTYPE]: SchemaFn<TModel, PathKind.Root>;
};
/**
* Function that defines rules for a schema.
*
* @template TValue The type of data stored in the form that this schema function is attached to.
* @template 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<TValue, TPathKind extends PathKind = PathKind.Root> = (
p: FieldPath<TValue, TPathKind>,
export type SchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> = (
p: SchemaPathTree<TModel, TPathKind>,
) => void;
/**
* A schema or schema definition function.
*
* @template TValue The type of data stored in the form that this schema function is attached to.
* @template 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<TValue, TPathKind extends PathKind = PathKind.Root> =
| Schema<TValue>
| SchemaFn<TValue, TPathKind>;
export type SchemaOrSchemaFn<TModel, TPathKind extends PathKind = PathKind.Root> =
| Schema<TModel>
| SchemaFn<TModel, TPathKind>;
/**
* A function that receives the `FieldContext` for the field the logic is bound to and returns
@ -473,12 +562,17 @@ export interface RootFieldContext<TValue> {
readonly state: FieldState<TValue>;
/** The current field. */
readonly field: FieldTree<TValue>;
/** Gets the value of the field represented by the given path. */
readonly valueOf: <P>(p: FieldPath<P>) => P;
/** Gets the state of the field represented by the given path. */
readonly stateOf: <P>(p: FieldPath<P>) => FieldState<P>;
/** Gets the field represented by the given path. */
readonly fieldOf: <P>(p: FieldPath<P>) => FieldTree<P>;
valueOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): PValue;
stateOf<PControl extends AbstractControl>(
p: CompatSchemaPath<PControl>,
): CompatFieldState<PControl>;
stateOf<PValue>(p: SchemaPath<PValue, SchemaPathRules>): FieldState<PValue>;
fieldTreeOf<PModel>(p: SchemaPathTree<PModel>): FieldTree<PModel>;
}
/**

View file

@ -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<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<string, TPathKind>,
path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>,
config?: BaseValidatorConfig<string, TPathKind>,
) {
validate(path, (ctx) => {

View file

@ -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<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<number, TPathKind>,
path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>,
maxValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
) {

View file

@ -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<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
maxLength: number | LogicFn<TValue, number | undefined, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind>,
) {

View file

@ -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<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<number, TPathKind>,
path: SchemaPath<number, SchemaPathRules.Supported, TPathKind>,
minValue: number | LogicFn<number, number | undefined, TPathKind>,
config?: BaseValidatorConfig<number, TPathKind>,
) {

View file

@ -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<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
minLength: number | LogicFn<TValue, number | undefined, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind>,
) {

View file

@ -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<TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<string, TPathKind>,
path: SchemaPath<string, SchemaPathRules.Supported, TPathKind>,
pattern: RegExp | LogicFn<string | undefined, RegExp | undefined, TPathKind>,
config?: BaseValidatorConfig<string, TPathKind>,
) {

View file

@ -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<TValue, TPathKind extends PathKind = PathKind.Root>(
path: FieldPath<TValue, TPathKind>,
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
config?: BaseValidatorConfig<TValue, TPathKind> & {
when?: NoInfer<LogicFn<TValue, boolean, TPathKind>>;
},

View file

@ -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<T> =
* @category validation
* @experimental 21.0.0
*/
export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProperties<TSchema>>(
path: FieldPath<TValue>,
export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProperties<TSchema>>(
path: SchemaPath<TModel> & SchemaPathTree<TModel>,
schema: StandardSchemaV1<TSchema>,
) {
// We create both a sync and async validator because the standard schema validator can return
@ -64,18 +64,29 @@ export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProp
// handles the sync result, and the async validator handles the Promise.
// We memoize the result of the validation function here, so that it is only run once for both
// validators, it can then be passed through both sync & async validation.
const VALIDATOR_MEMO = metadata(path, ({value}) => {
type Result = StandardSchemaV1.Result<TSchema> | Promise<StandardSchemaV1.Result<TSchema>>;
const VALIDATOR_MEMO = metadata<TModel, Signal<Result>>(path, ({value}) => {
return computed(() => schema['~standard'].validate(value()));
});
validateTree(path, ({state, fieldOf}) => {
validateTree<TModel>(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<TModel>(path), issue),
) ?? []
);
});
validateAsync(path, {
validateAsync<
TModel,
Promise<StandardSchemaV1.Result<TSchema>> | 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<TSchema, TValue extends IgnoreUnknownProp
loader: async ({params}) => (await params)?.issues ?? [],
});
},
onSuccess: (issues, {fieldOf}) => {
return issues.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue));
onSuccess: (issues, {fieldTreeOf}) => {
return issues.map((issue) => standardIssueToFormTreeError(fieldTreeOf<TModel>(path), issue));
},
onError: () => {},
});

View file

@ -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<unknown> {
* actually change, as they only place we currently track fields moving within the parent
* structure is for arrays, and paths do not currently support array indexing.
*/
private readonly cache = new WeakMap<FieldPath<unknown>, Signal<FieldTree<unknown>>>();
private readonly cache = new WeakMap<
SchemaPath<unknown, SchemaPathRules>,
Signal<FieldTree<unknown>>
>();
constructor(
/** The field node this context corresponds to. */
@ -37,7 +48,7 @@ export class FieldNodeContext implements FieldContext<unknown> {
* @param target The path to resolve
* @returns The field corresponding to the target path.
*/
private resolve<U>(target: FieldPath<U>): FieldTree<U> {
private resolve<U>(target: SchemaPath<U, SchemaPathRules>): FieldTree<U> {
if (!this.cache.has(target)) {
const resolver = computed<FieldTree<unknown>>(() => {
const targetPathNode = FieldPathNode.unwrapFieldPath(target);
@ -107,7 +118,16 @@ export class FieldNodeContext implements FieldContext<unknown> {
return Number(key);
});
readonly fieldOf = <P>(p: FieldPath<P>) => this.resolve(p);
readonly stateOf = <P>(p: FieldPath<P>) => this.resolve(p)();
readonly valueOf = <P>(p: FieldPath<P>) => this.resolve(p)().value();
readonly fieldTreeOf = <TModel>(p: SchemaPathTree<TModel>) => this.resolve<TModel>(p);
readonly stateOf = <TModel>(p: SchemaPath<TModel, SchemaPathRules>) => this.resolve<TModel>(p)();
readonly valueOf = <TValue>(p: SchemaPath<TValue, SchemaPathRules>) => {
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;
};
}

View file

@ -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<any>;
readonly path: SchemaPath<any>;
}
/**

View file

@ -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<any> = new Proxy(
readonly fieldPathProxy: SchemaPath<any> = new Proxy(
this,
FIELD_PATH_PROXY_HANDLER,
) as unknown as FieldPath<any>;
) as unknown as SchemaPath<any>;
/**
* 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<unknown>): FieldPathNode {
static unwrapFieldPath(formPath: SchemaPath<unknown, SchemaPathRules>): FieldPathNode {
return (formPath as any)[PATH] as FieldPathNode;
}

View file

@ -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<un
}
/** Checks that a path node belongs to the schema function currently being compiled. */
export function assertPathIsCurrent(path: FieldPath<unknown>): void {
export function assertPathIsCurrent(path: SchemaPath<unknown>): void {
if (currentCompilingNode !== FieldPathNode.unwrapFieldPath(path).root) {
throw new Error(
`A FieldPath can only be used directly within the Schema that owns it,` +

View file

@ -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<TModel>(
args: any[],
): [WritableSignal<TModel>, SchemaOrSchemaFn<TModel> | undefined, FormOptions | undefined] {
let model: WritableSignal<TModel>;
let schema: SchemaOrSchemaFn<TModel> | 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];
}

View file

@ -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",

View file

@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: any) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {promise, resolve, reject};
}
describe('Forms compat', () => {
it('should not error on a valid value', () => {
const cat = signal({
name: 'pirojok-the-cat',
age: new FormControl<number>(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<number>(5, {
nonNullable: true,
asyncValidators: () => {
// can't use promiseWithResolver here, because this runs multiple times across tests.
return new Promise<null>((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<TreeValidationResult>();
const result = submit(f as unknown as FieldTree<void>, () => {
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<T>(state: FieldState<T>, value: T) {
state.value.set(value);
}
function setValueCompatState<T>(field: FieldState<T>, value: T) {
field.value.set(field.value());
}
function setValueRegularState<T>(field: FieldState<T>, value: T) {
field.value.set(field.value());
}
function setValue<T>(f: FieldTree<T>, value: T) {
// @ts-expect-error
f().value.set(value);
}
});
});
});

View file

@ -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})]);
});
});

View file

@ -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<T>(
s: WritableSignal<T>,
callback: (ctx: FieldContext<T>, p: FieldPath<T>) => void,
callback: (ctx: FieldContext<T>, p: SchemaPathTree<T>) => void,
) {
const isCalled = jasmine.createSpy();
TestBed.runInInjectionContext(() => {
const f = form(s, (p) => {
validate(p, (ctx) => {
const f = form<T>(s, (p) => {
validate(p as SchemaPath<T>, (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);
});
});
});

View file

@ -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<string> = 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<any>;
let otherP: SchemaPath<any>;
const s = schema<string>((p) => (otherP = p));
SchemaImpl.rootCompile(s);
const f = form(
signal(''),
(p) => {
disabled(p, ({fieldOf}) => {
fieldOf(otherP);
disabled(p, ({fieldTreeOf}) => {
fieldTreeOf(otherP);
return true;
});
},

View file

@ -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<unknown> = {
fieldOf: () => undefined!,
stateOf: <P>() =>
fieldTreeOf: () => undefined!,
stateOf: () =>
({
context: undefined,
structure: {pathKeys: () => [], parent: undefined},
}) as unknown as FieldState<P>,
}) as any,
valueOf: () => undefined!,
field: undefined!,
state: undefined!,

View file

@ -18,9 +18,9 @@ interface TreeData {
next: TreeData | null;
}
function narrowed<TValue, TNarrowed extends TValue>(
field: FieldTree<TValue> | undefined,
guard: (value: TValue) => value is TNarrowed,
function narrowed<TModel, TNarrowed extends TModel>(
field: FieldTree<TModel> | undefined,
guard: (value: TModel) => value is TNarrowed,
): Signal<FieldTree<TNarrowed> | undefined> {
return computed(
() => field && (guard(field().value()) ? (field as FieldTree<TNarrowed>) : undefined),

View file

@ -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,
});
});

View file

@ -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,
});