mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(forms): add debounce option to validateAsync and validateHttp
This adds support for a `debounce` option to the `validateAsync` and `validateHttp` functions. This allows developers to debounce the triggering of async validators to improve performance. A `DebounceTimer` type was also added to `@angular/core` to represent the wait condition parameters uniformly.
This commit is contained in:
committed by
Leon Senft
parent
b5af15a332
commit
24e52d450d
@@ -499,7 +499,7 @@ export const CSP_NONCE: InjectionToken<string | null>;
|
||||
export const CUSTOM_ELEMENTS_SCHEMA: SchemaMetadata;
|
||||
|
||||
// @public
|
||||
export function debounced<T>(source: () => T, wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
|
||||
export function debounced<T>(source: () => T, wait: NoInfer<DebounceTimer<T>>, options?: NoInfer<DebouncedOptions<T>>): Resource<T>;
|
||||
|
||||
// @public
|
||||
export interface DebouncedOptions<T> {
|
||||
@@ -507,6 +507,9 @@ export interface DebouncedOptions<T> {
|
||||
injector?: Injector;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type DebounceTimer<T> = number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);
|
||||
|
||||
// @public (undocumented)
|
||||
export class DebugElement extends DebugNode {
|
||||
constructor(nativeNode: Element);
|
||||
|
||||
@@ -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<E extends ValidationError = ValidationError> =
|
||||
|
||||
// @public
|
||||
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
|
||||
readonly debounce?: DebounceTimer<TParams | undefined>;
|
||||
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
|
||||
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
|
||||
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
@@ -260,6 +262,7 @@ export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path:
|
||||
|
||||
// @public
|
||||
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
|
||||
readonly debounce?: DebounceTimer<string | HttpResourceRequest | undefined>;
|
||||
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
|
||||
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
|
||||
readonly options?: HttpResourceOptions<TResult, unknown>;
|
||||
|
||||
@@ -305,3 +305,11 @@ export interface DebouncedOptions<T> {
|
||||
/** The equality function to use for comparing values. */
|
||||
equal?: ValueEqualityFn<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the wait condition for item debouncing.
|
||||
* Can be a number of milliseconds or a function that returns a Promise.
|
||||
*/
|
||||
export type DebounceTimer<T> =
|
||||
| number
|
||||
| ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void);
|
||||
|
||||
@@ -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<T>(
|
||||
source: () => T,
|
||||
wait: NoInfer<number | ((value: T, lastValue: ResourceSnapshot<T>) => Promise<void> | void)>,
|
||||
wait: NoInfer<DebounceTimer<T>>,
|
||||
options?: NoInfer<DebouncedOptions<T>>,
|
||||
): Resource<T> {
|
||||
if (isInParamsFunction()) {
|
||||
|
||||
@@ -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<TValue, TPathKind>) => 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<TParams | undefined>;
|
||||
|
||||
/**
|
||||
* 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<TValue, TParams, TResult, TPathKind extends PathKi
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
|
||||
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, 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;
|
||||
|
||||
|
||||
@@ -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<TValue, TResult, TPathKind extends PathKin
|
||||
* The options to use when creating the httpResource.
|
||||
*/
|
||||
readonly options?: HttpResourceOptions<TResult, unknown>;
|
||||
|
||||
/**
|
||||
* 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<string | HttpResourceRequest | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +89,10 @@ export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKi
|
||||
opts: HttpValidatorOptions<TValue, TResult, TPathKind>,
|
||||
) {
|
||||
validateAsync(path, {
|
||||
params: opts.request,
|
||||
params: opts.request as (
|
||||
ctx: FieldContext<TValue, TPathKind>,
|
||||
) => string | HttpResourceRequest | undefined,
|
||||
debounce: opts.debounce,
|
||||
factory: (request: Signal<any>) => httpResource(request, opts.options),
|
||||
onSuccess: opts.onSuccess,
|
||||
onError: opts.onError,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user