diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index 2c0ce02988f..bcea6c7a0dd 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -499,7 +499,7 @@ export const CSP_NONCE: InjectionToken; export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata; // @public -export function debounced(source: () => T, wait: NoInfer) => Promise | void)>, options?: NoInfer>): Resource; +export function debounced(source: () => T, wait: NoInfer>, options?: NoInfer>): Resource; // @public export interface DebouncedOptions { @@ -507,6 +507,9 @@ export interface DebouncedOptions { injector?: Injector; } +// @public +export type DebounceTimer = number | ((value: T, lastValue: ResourceSnapshot) => Promise | void); + // @public (undocumented) export class DebugElement extends DebugNode { constructor(nativeNode: Element); diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 9b56c22da17..019e94e907e 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -6,6 +6,7 @@ import { AbstractControl } from '@angular/forms'; import { ControlValueAccessor } from '@angular/forms'; +import { DebounceTimer } from '@angular/core'; import { FormControlStatus } from '@angular/forms'; import { HttpResourceOptions } from '@angular/common/http'; import { HttpResourceRequest } from '@angular/common/http'; @@ -47,6 +48,7 @@ export type AsyncValidationResult = // @public export interface AsyncValidatorOptions { + readonly debounce?: DebounceTimer; readonly factory: (params: Signal) => ResourceRef; readonly onError: (error: unknown, ctx: FieldContext) => TreeValidationResult; readonly onSuccess: MapToErrorsFn; @@ -260,6 +262,7 @@ export function hidden(path: // @public export interface HttpValidatorOptions { + readonly debounce?: DebounceTimer; readonly onError: (error: unknown, ctx: FieldContext) => TreeValidationResult; readonly onSuccess: MapToErrorsFn; readonly options?: HttpResourceOptions; diff --git a/packages/core/src/resource/api.ts b/packages/core/src/resource/api.ts index 52bdf142712..b4333239a0d 100644 --- a/packages/core/src/resource/api.ts +++ b/packages/core/src/resource/api.ts @@ -305,3 +305,11 @@ export interface DebouncedOptions { /** The equality function to use for comparing values. */ equal?: ValueEqualityFn; } + +/** + * Represents the wait condition for item debouncing. + * Can be a number of milliseconds or a function that returns a Promise. + */ +export type DebounceTimer = + | number + | ((value: T, lastValue: ResourceSnapshot) => Promise | void); diff --git a/packages/core/src/resource/debounce.ts b/packages/core/src/resource/debounce.ts index 341b00e6f3f..a4ee0b87d73 100644 --- a/packages/core/src/resource/debounce.ts +++ b/packages/core/src/resource/debounce.ts @@ -12,7 +12,7 @@ import {effect} from '../render3/reactivity/effect'; import {linkedSignal} from '../render3/reactivity/linked_signal'; import {signal} from '../render3/reactivity/signal'; import {untracked} from '../render3/reactivity/untracked'; -import {Resource, ResourceSnapshot, type DebouncedOptions} from './api'; +import {Resource, ResourceSnapshot, type DebounceTimer, type DebouncedOptions} from './api'; import {resourceFromSnapshots} from './from_snapshots'; import { invalidResourceCreationInParams, @@ -33,7 +33,7 @@ import { */ export function debounced( source: () => T, - wait: NoInfer) => Promise | void)>, + wait: NoInfer>, options?: NoInfer>, ): Resource { if (isInParamsFunction()) { 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 b6c16890f93..8785386475a 100644 --- a/packages/forms/signals/src/api/rules/validation/validate_async.ts +++ b/packages/forms/signals/src/api/rules/validation/validate_async.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {ResourceRef, Signal} from '@angular/core'; +import {DebounceTimer, ResourceRef, ResourceSnapshot, Signal, debounced} from '@angular/core'; import {FieldNode} from '../../../field/node'; import {addDefaultField} from '../../../field/validation'; import {FieldPathNode} from '../../../schema/path_node'; @@ -67,6 +67,12 @@ export interface AsyncValidatorOptions< */ readonly params: (ctx: FieldContext) => TParams; + /** + * Duration in milliseconds to wait before triggering the async operation, or a function that + * returns a promise that resolves when the update should proceed. + */ + readonly debounce?: DebounceTimer; + /** * A function that receives the resource params and returns a resource of the given params. * The given params should be used as is to create the resource. @@ -118,7 +124,13 @@ export function validateAsync, TParams | undefined>( - (_state, params) => opts.factory(params), + (_state, params) => { + if (opts.debounce !== undefined) { + const debouncedResource = debounced(() => params(), opts.debounce); + return opts.factory(debouncedResource.value); + } + return opts.factory(params); + }, ); RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true; diff --git a/packages/forms/signals/src/api/rules/validation/validate_http.ts b/packages/forms/signals/src/api/rules/validation/validate_http.ts index 6e3413389d9..7a6c4e2fdc3 100644 --- a/packages/forms/signals/src/api/rules/validation/validate_http.ts +++ b/packages/forms/signals/src/api/rules/validation/validate_http.ts @@ -7,7 +7,7 @@ */ import {httpResource, HttpResourceOptions, HttpResourceRequest} from '@angular/common/http'; -import {Signal} from '@angular/core'; +import {DebounceTimer, ResourceSnapshot, Signal} from '@angular/core'; import { FieldContext, SchemaPath, @@ -62,6 +62,12 @@ export interface HttpValidatorOptions; + + /** + * Duration in milliseconds to wait before triggering the async operation, or a function that + * returns a promise that resolves when the update should proceed. + */ + readonly debounce?: DebounceTimer; } /** @@ -83,7 +89,10 @@ export function validateHttp, ) { validateAsync(path, { - params: opts.request, + params: opts.request as ( + ctx: FieldContext, + ) => string | HttpResourceRequest | undefined, + debounce: opts.debounce, factory: (request: Signal) => httpResource(request, opts.options), onSuccess: opts.onSuccess, onError: opts.onError, diff --git a/packages/forms/signals/test/node/resource.spec.ts b/packages/forms/signals/test/node/resource.spec.ts index 684b7f15ded..dedd5265f88 100644 --- a/packages/forms/signals/test/node/resource.spec.ts +++ b/packages/forms/signals/test/node/resource.spec.ts @@ -16,7 +16,7 @@ import { type Signal, } from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {isNode} from '@angular/private/testing'; +import {isNode, timeout, useAutoTick} from '@angular/private/testing'; import { applyEach, @@ -43,6 +43,8 @@ interface Address { } describe('resources', () => { + useAutoTick(); + let appRef: ApplicationRef; let backend: HttpTestingController; let injector: Injector; @@ -404,6 +406,41 @@ describe('resources', () => { expect(f().metadata(RES)).toBe(undefined); }); + it('should support debounce in validateHttp', async () => { + const usernameForm = form( + signal('unique-user'), + (p) => { + validateHttp(p, { + request: ({value}) => `/api/check?username=${value()}`, + debounce: 50, // Short debounce + onSuccess: (available: boolean) => (available ? undefined : {kind: 'username-taken'}), + onError: () => null, + }); + }, + {injector}, + ); + + TestBed.tick(); + const req1 = backend.expectOne('/api/check?username=unique-user'); + req1.flush(true); + await appRef.whenStable(); + expect(usernameForm().valid()).toBe(true); + usernameForm().value.set('taken-user'); + TestBed.tick(); + + // Should not have triggered a new request yet + backend.expectNone('/api/check?username=taken-user'); + + // Wait for debounce + await timeout(80); + TestBed.tick(); + const req2 = backend.expectOne('/api/check?username=taken-user'); + req2.flush(false); + await appRef.whenStable(); + + expect(usernameForm().valid()).toBe(false); + }); + describe('reloadValidation', () => { it('should trigger a reload of async http validation', async () => { const usernameForm = form(