From 00992183a8dffa1fb27406c373b8a4c7c5235cba Mon Sep 17 00:00:00 2001 From: brysonbw Date: Fri, 7 Aug 2026 14:45:16 -0500 Subject: [PATCH] feat(forms): add validateAsync overloads for simple function and resource options Add validateAsync overloads for simple function with optional config and full resource options. --- .../guide/forms/signals/async-operations.md | 51 +++++- .../content/guide/forms/signals/validation.md | 63 ++++++- .../api/rules/validation/validate_async.ts | 163 +++++++++++++++++- packages/forms/signals/src/api/types.ts | 20 +++ .../test/node/validation_status.spec.ts | 30 ++++ .../forms/signals/test/web/form_field.spec.ts | 34 ++++ 6 files changed, 349 insertions(+), 12 deletions(-) diff --git a/adev/src/content/guide/forms/signals/async-operations.md b/adev/src/content/guide/forms/signals/async-operations.md index 95ce8f7a9efa..5c975f60b958 100644 --- a/adev/src/content/guide/forms/signals/async-operations.md +++ b/adev/src/content/guide/forms/signals/async-operations.md @@ -244,7 +244,7 @@ TIP: See the [httpResource API documentation](api/common/http/httpResource) for Most applications should use `validateHttp()` for async validation. It handles HTTP requests with minimal configuration and covers the majority of use cases. -`validateAsync()` is a lower-level API that exposes Angular's resource primitive directly. It offers complete control but requires more code and familiarity with Angular's resource API. +`validateAsync()` is a lower-level API that exposes Angular's resource primitive. It supports both a simple async function for standard checks and full resource configuration for complete control, which requires more code and familiarity with Angular's resource API. Consider `validateAsync()` only when `validateHttp()` can't meet your needs. Some examples include: @@ -253,9 +253,54 @@ Consider `validateAsync()` only when `validateHttp()` can't meet your needs. Som - **Complex retry logic** - Custom backoff strategies or conditional retries - **Direct resource access** - When you need the full resource lifecycle -### Creating a custom validation rule +### Using a simple async validator -The `validateAsync()` function requires four properties: `params`, `factory`, `onSuccess`, and `onError`. The `params` function returns the parameters for your resource, while `factory` creates the resource: +Pass an async validator function that receives the field context and returns validation errors or `null` or `undefined`. You can optionally supply a config object to handle debouncing, conditional execution (`when`), or error handling (`onError`): + +```ts +import {Component, inject, signal} from '@angular/core'; +import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals'; +import {UsernameValidator} from './username-validator'; + +@Component({ + selector: 'app-registration', + imports: [FormField], + template: `...`, +}) +export class Registration { + registrationModel = signal({username: ''}); + private usernameValidator = inject(UsernameValidator); + + registrationForm = form(this.registrationModel, (schemaPath) => { + validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx), { + debounce: 300, + when: ({value}) => value().length >= 3, + onError: (error) => ({ + kind: 'serverError', + message: 'Could not verify username', + }), + }); + }); + + private async validateUsername({value}: FieldContext) { + const result = await this.usernameValidator.checkAvailability(value()); + + return result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'}; + } +} +``` + +#### Optional Configuration Options + +| **Config Option** | **Type** | **Description** | +| ----------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `debounce` | `number \| DebounceTimer` | Duration in milliseconds to wait before triggering the async operation, or a function returning a timing promise. | +| `when` | `LogicFn` | A function that receives the field context and returns `true` if the async validation should run. | +| `onError` | `Function` | A handler for errors thrown by the validator. Defaults to an `{ kind: 'asyncError', message: ... }` object if omitted. | + +### Using Resouce-based validator + +For advanced control, pass an options object containing `params`, `factory`, `onSuccess`, and `onError`. The params function returns parameters for your resource, while `factory` creates the resource: ```ts import {Component, inject, signal, resource, Signal} from '@angular/core'; diff --git a/adev/src/content/guide/forms/signals/validation.md b/adev/src/content/guide/forms/signals/validation.md index 5f3b293eb812..7560863aea25 100644 --- a/adev/src/content/guide/forms/signals/validation.md +++ b/adev/src/content/guide/forms/signals/validation.md @@ -492,6 +492,61 @@ NOTE: Child fields also have a `key` signal, and array item fields have both `ke Return an error object with `kind` and `message` when validation fails. Return `null` or `undefined` when validation passes. +### Using validateAsync() + +The `validateAsync()` function creates custom asynchronous validation rules. It receives a validator function that accesses the field context and returns a `Promise` resolving to: + +| Return Value | Meaning | +| --------------------- | ---------------- | +| Error object | Value is invalid | +| `null` or `undefined` | Value is valid | + +```angular-ts +import {Component, inject, signal} from '@angular/core'; +import {FieldContext, form, FormField, validateAsync} from '@angular/forms/signals'; +import {UserService} from './user.service'; + +@Component({ + selector: 'app-username-form', + imports: [FormField], + template: ` +
+ +
+ `, +}) +export class UsernameFormComponent { + private userService = inject(UserService); + + usernameModel = signal({username: ''}); + + usernameForm = form(this.usernameModel, (schemaPath) => { + validateAsync(schemaPath.username, (ctx) => this.validateUsername(ctx)); + }); + + private async validateUsername({value}: FieldContext) { + const username = value(); + const available = await this.userService.checkUsernameAvailability(username); + + if (!available) { + return { + kind: 'usernameTaken', + message: 'Username is already taken', + }; + } + + return null; + } +} +``` + +The validator function receives the same `FieldContext` object as synchronous `validate()`. + +NOTE: For advanced async validation and complex scenarios requiring execution delays, conditional checks, unhandled error fallbacks, or direct access to Angular's `resource()` primitive, see the [Custom async validation with validateAsync() guide](guide/forms/signals/async-operations#custom-async-validation-with-validateasync). + ### Using validateTree() The `validateTree()` function creates custom validation rules that can target multiple fields or provide complex validation logic for a whole subtree. @@ -505,9 +560,7 @@ interface User { lastName: string; } -@Component({ - /* ... */ -}) +@Component({/* ... */}) export class UserFormComponent { readonly userModel = model({ firstName: '', @@ -750,9 +803,7 @@ import {Component, computed, signal} from '@angular/core'; import {form, FormField, validateStandardSchema} from '@angular/forms/signals'; import z from 'zod'; -@Component({ - /* ... */ -}) +@Component({/* ... */}) export class DynamicSchema { model = signal({document: '', type: 'dni'}); diff --git a/packages/forms/signals/src/api/rules/validation/validate_async.ts b/packages/forms/signals/src/api/rules/validation/validate_async.ts index 4cc9682e9e99..f34ddc3a175c 100644 --- a/packages/forms/signals/src/api/rules/validation/validate_async.ts +++ b/packages/forms/signals/src/api/rules/validation/validate_async.ts @@ -6,13 +6,22 @@ * found in the LICENSE file at https://angular.dev/license */ -import {DebounceTimer, Resource, Signal, computed, debounced, ɵchain} from '@angular/core'; +import { + DebounceTimer, + Resource, + resource, + Signal, + computed, + debounced, + ɵchain, +} from '@angular/core'; import {FieldNode} from '../../../field/node'; import {addDefaultField} from '../../../field/validation'; import {FieldPathNode} from '../../../schema/path_node'; import {assertPathIsCurrent} from '../../../schema/schema'; import { FieldContext, + FieldValidatorAsync, LogicFn, PathKind, SchemaPath, @@ -117,19 +126,167 @@ export interface AsyncValidatorOptions< * @param opts The async validation options. * @template TValue The type of value stored in the field being validated. * @template TParams The type of parameters to the resource. - * @template TResult The type of result returned by the resource - * @template TPathKind The kind of path being validated (a root path, child path, or item of an array) + * @template TResult The type of result returned by the resource. + * @template TPathKind The kind of path being validated (a root path, child path, or item of an array). * * @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation) * @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync) + * @see [Custom validation rules](guide/forms/signals/validation#using-validateasync) * @category validation * @publicApi 22.0 */ export function validateAsync( path: SchemaPath, opts: AsyncValidatorOptions, +): void; + +/** + * Adds async validation to the field corresponding to the given path using a simple validator function. + * + * @param path A path indicating the field to bind the async validation logic to. + * @param logic A validator function that returns the validation errors asynchronously. + * @param config Optional, allows providing any of the following options for the async validation over the simple validator function: + * - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that + * returns a promise that resolves when the update should proceed. + * - `when`: A function that receives the field context and returns true if the async validation should be run. + * - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.). + * Receives the error and the field context, returns a list of validation errors. + * @template TValue The type of value stored in the field being validated. + * @template TPathKind The kind of path being validated (a root path, child path, or item of an array). + * + * @see [Signal Form Async Validation](guide/forms/signals/validation#async-validation) + * @see [Custom async validation](guide/forms/signals/async-operations#custom-async-validation-with-validateasync) + * @see [Custom validation rules](guide/forms/signals/validation#using-validateasync) + * @category validation + * @publicApi 22.0 + */ +export function validateAsync( + path: SchemaPath, + logic: NoInfer>, + config?: { + debounce?: DebounceTimer | undefined>; + when?: NoInfer>; + onError?: (error: unknown, ctx: FieldContext) => TreeValidationResult; + }, +): void; + +/** + * Internal implementation for registering async validation on a field via resource options + * or a simple validator function. + * + * @param path A path indicating the field to bind the async validation logic to. + * @param optsOrLogic Either an object with full resource validator options or a simple async validator function. + * @param config Optional, allows providing any of the following options for the async validation over the simple validator function: + * - `debounce`: Duration in milliseconds to wait before triggering the async operation, or a function that + * returns a promise that resolves when the update should proceed. + * - `when`: A function that receives the field context and returns true if the async validation should be run. + * - `onError`: A function to handle errors thrown by the async validator (HTTP errors, network errors, etc.). + * Receives the error and the field context, returns a list of validation errors. + * @template TValue The type of value stored in the field being validated. + * @template TParams The type of parameters to the resource. + * @template TResult The type of result returned by the resource. + * @template TPathKind The kind of path being validated (a root path, child path, or item of an array). + * + * @internal + */ +export function validateAsync< + TValue, + TParams = unknown, + TResult = unknown, + TPathKind extends PathKind = PathKind.Root, +>( + path: SchemaPath, + optsOrLogic: + | AsyncValidatorOptions + | FieldValidatorAsync, + config?: { + debounce?: DebounceTimer | undefined>; + when?: NoInfer>; + onError?: (error: unknown, ctx: FieldContext) => TreeValidationResult; + }, ): void { assertPathIsCurrent(path); + + if (typeof optsOrLogic === 'function') { + const logic = optsOrLogic; + const configOpts = config ?? {}; + + // Check if debounce option is a function, wrap it to pass the field context and last value. + const debounce = + typeof configOpts.debounce === 'function' + ? ( + value: {ctx: FieldContext; value: TValue} | undefined, + lastValue: unknown, + ) => (configOpts.debounce as Function)(value?.ctx, lastValue) + : configOpts.debounce; + + // Map the simple validator function to the full AsyncValidatorOptions structure. + // Reading `ctx.value()` inside `params` ensures signal dependencies are tracked + // so the resource loader re-runs whenever the field value changes. + const mappedOpts: AsyncValidatorOptions< + TValue, + {ctx: FieldContext; value: TValue} | undefined, + TreeValidationResult | undefined, + TPathKind + > = { + params: (ctx: FieldContext) => ({ + ctx, + value: ctx.value(), + }), + debounce: debounce as DebounceTimer< + {ctx: FieldContext; value: TValue} | undefined + >, + factory: ( + paramsSignal: Signal<{ctx: FieldContext; value: TValue} | undefined>, + ) => + resource({ + params: () => paramsSignal(), + loader: async ({ + params, + }: { + params: {ctx: FieldContext; value: TValue} | undefined; + }) => { + if (!params) return undefined; + const res = await logic(params.ctx); + return res as TreeValidationResult | undefined; + }, + }), + onSuccess: (result: TreeValidationResult | undefined) => result, + onError: + configOpts.onError ?? + ((error: unknown) => ({ + kind: 'asyncError', + message: String(error ?? 'Async validation failed'), + })), + when: configOpts.when, + }; + + registerAsyncResourceValidator(path, mappedOpts); + return; + } + + registerAsyncResourceValidator(path, optsOrLogic); +} + +/** + * Registers an async resource validator for the given path and options. + * + * @template TValue The type of value stored in the field being validated. + * @template TParams The type of parameters to the resource. + * @template TResult The type of result returned by the resource + * @template TPathKind The kind of path being validated (a root path, child path, or item of an array) + * @param path + * @param opts + */ +function registerAsyncResourceValidator< + TValue, + TParams, + TResult, + TPathKind extends PathKind = PathKind.Root, +>( + path: SchemaPath, + opts: AsyncValidatorOptions, +): void { const pathNode = FieldPathNode.unwrapFieldPath(path); const RESOURCE = createManagedMetadataKey, TParams | undefined>( diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index abbc7f3e3903..5d840d19908d 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -900,6 +900,26 @@ export type FieldValidator = TPathKind >; +/** + * A function that takes the `FieldContext` for the field being validated and returns a + * `ValidationResult` or pending status indicating errors for the field asynchronously. + * + * @template TValue The type of value stored in the field being validated + * @template TPathKind The kind of path being validated (root field, child field, or item of an array) + * + * @see [Custom validation rules](guide/forms/signals/validation#using-validateasync) + * + * @category validation + * @publicApi 22.0 + */ +export type FieldValidatorAsync = LogicFn< + TValue, + | ValidationResult + | Promise> + | 'pending', + TPathKind +>; + /** * A function that takes the `FieldContext` for the field being validated and returns a * `TreeValidationResult` indicating errors for the field and its sub-fields. diff --git a/packages/forms/signals/test/node/validation_status.spec.ts b/packages/forms/signals/test/node/validation_status.spec.ts index 95ad5b589d21..cf3d0c892e1a 100644 --- a/packages/forms/signals/test/node/validation_status.spec.ts +++ b/packages/forms/signals/test/node/validation_status.spec.ts @@ -244,6 +244,36 @@ describe('validation status', () => { expect(f().invalid()).toBe(true); }); + it('should support the simple async validator overload', async () => { + const f = form( + signal('VALID'), + (p) => { + validateAsync(p, async ({value}) => { + return value() === 'VALID' ? null : [{kind: 'custom'}]; + }); + }, + {injector}, + ); + + await Promise.resolve(); + await appRef.whenStable(); + + expect(f().pending()).toBe(false); + expect(f().valid()).toBe(true); + expect(f().invalid()).toBe(false); + + f().value.set('INVALID'); + + // Trigger change detection to notify resource signal dependencies + appRef.tick(); + await Promise.resolve(); + await appRef.whenStable(); + + expect(f().pending()).toBe(false); + expect(f().valid()).toBe(false); + expect(f().invalid()).toBe(true); + }); + it('should affect validity of targeted field', async () => { let res: Resource; diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 68714bac47db..5998b44036cf 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -136,6 +136,40 @@ describe('field directive', () => { }); expect(component.model()).toEqual({x: 'a', y: 'c'}); }); + + it('should support simple async validator functions with formField bindings', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('VALID'), (p) => { + validateAsync(p, async ({value}) => { + return value() === 'VALID' ? null : [{kind: 'custom'}]; + }); + }); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + + await Promise.resolve(); + await fixture.whenStable(); + + expect(component.f().pending()).toBe(false); + expect(component.f().valid()).toBe(true); + + act(() => { + component.f().value.set('INVALID'); + fixture.detectChanges(); + }); + + await Promise.resolve(); + await fixture.whenStable(); + + expect(component.f().pending()).toBe(false); + expect(component.f().invalid()).toBe(true); + }); }); describe('host directive mapping', () => {