mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
refactor(forms): add compatForm
This allows using reactive form controls in signal forms
(cherry picked from commit 60447945bc)
This commit is contained in:
parent
3bed9f0f16
commit
3bbcae6ce8
36 changed files with 2039 additions and 240 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
20
packages/forms/signals/compat/BUILD.bazel
Normal file
20
packages/forms/signals/compat/BUILD.bazel
Normal 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",
|
||||
],
|
||||
)
|
||||
132
packages/forms/signals/compat/src/compat_field_adapter.ts
Normal file
132
packages/forms/signals/compat/src/compat_field_adapter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
133
packages/forms/signals/compat/src/compat_field_node.ts
Normal file
133
packages/forms/signals/compat/src/compat_field_node.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {computed, 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),
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
127
packages/forms/signals/compat/src/compat_form.ts
Normal file
127
packages/forms/signals/compat/src/compat_form.ts
Normal 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>;
|
||||
}
|
||||
54
packages/forms/signals/compat/src/compat_node_state.ts
Normal file
54
packages/forms/signals/compat/src/compat_node_state.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {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();
|
||||
}
|
||||
}
|
||||
128
packages/forms/signals/compat/src/compat_structure.ts
Normal file
128
packages/forms/signals/compat/src/compat_structure.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
64
packages/forms/signals/compat/src/compat_validation_error.ts
Normal file
64
packages/forms/signals/compat/src/compat_validation_error.ts
Normal 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;
|
||||
}
|
||||
63
packages/forms/signals/compat/src/compat_validation_state.ts
Normal file
63
packages/forms/signals/compat/src/compat_validation_state.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,` +
|
||||
|
|
|
|||
37
packages/forms/signals/src/util/normalize_form_args.ts
Normal file
37
packages/forms/signals/src/util/normalize_form_args.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
829
packages/forms/signals/test/node/compat/compat.spec.ts
Normal file
829
packages/forms/signals/test/node/compat/compat.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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})]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue