mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(forms): add debounce() rule for signal forms
The `debounce()` rule allows developers to control when changes to a
form control are synchronized to the form model.
This feature necessitated some changes to `FieldState`:
* `controlValue` is a new signal property that represents the current
value of a form field as it appears in its corresponding control.
* `value` conceptually remains unchanged; however, its value may lag
behind that of `controlValue` if a `debounce()` rule is applied.
The `debounce()` rule essentially manages when changes to `controlValue` are
synchronized to `value`. The intent is that an expensive or slow
validation rule can react to the debounced `value`, rather than a more
frequently changing `controlValue`.
Directly updating `value` immediately updates `controlValue`, and cancels any
pending debounced updates.
When multiple `debounce()` rules are applied to the same field, the last
currently active rule is used to debounce an update. These rules are
applied to child fields as well, unless they override them with their
own rule.
This commit is contained in:
parent
8866934334
commit
d337cfb68f
12 changed files with 633 additions and 28 deletions
|
|
@ -107,6 +107,12 @@ export class CustomValidationError implements ValidationError {
|
|||
readonly message?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, durationOrDebouncer: number | Debouncer<TValue, TPathKind>): void;
|
||||
|
||||
// @public
|
||||
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (context: FieldContext<TValue, TPathKind>) => Promise<void> | void;
|
||||
|
||||
// @public
|
||||
export function disabled<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic?: string | NoInfer<LogicFn<TValue, boolean | string, TPathKind>>): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -258,11 +258,7 @@ function listenToCustomControl(
|
|||
componentIndex,
|
||||
outputName,
|
||||
outputName,
|
||||
wrapListener(tNode, lView, (newValue: unknown) => {
|
||||
const state = control.state();
|
||||
state.value.set(newValue);
|
||||
state.markAsDirty();
|
||||
}),
|
||||
wrapListener(tNode, lView, (value: unknown) => control.state().setControlValue(value)),
|
||||
);
|
||||
|
||||
const tView = getTView();
|
||||
|
|
@ -275,9 +271,7 @@ function listenToCustomControl(
|
|||
componentIndex,
|
||||
touchedOutputName,
|
||||
touchedOutputName,
|
||||
wrapListener(tNode, lView, () => {
|
||||
control.state().markAsTouched();
|
||||
}),
|
||||
wrapListener(tNode, lView, () => control.state().markAsTouched()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -336,8 +330,7 @@ function listenToNativeControl(lView: LView<{} | null>, tNode: TNode, control:
|
|||
|
||||
const inputListener = () => {
|
||||
const state = control.state();
|
||||
state.value.set(getNativeControlValue(element, state.value));
|
||||
state.markAsDirty();
|
||||
state.setControlValue(getNativeControlValue(element, state.value));
|
||||
};
|
||||
listenToDomEvent(
|
||||
tNode,
|
||||
|
|
@ -463,7 +456,7 @@ function updateCustomControl(
|
|||
const state = control.state();
|
||||
const bindings = getControlBindings(lView);
|
||||
|
||||
maybeUpdateInput(componentDef, component, bindings, state, VALUE, modelName);
|
||||
maybeUpdateInput(componentDef, component, bindings, state, CONTROL_VALUE, modelName);
|
||||
|
||||
for (const key of CONTROL_BINDING_KEYS) {
|
||||
const inputName = CONTROL_BINDING_NAMES[key];
|
||||
|
|
@ -510,7 +503,7 @@ function updateInteropControl(lView: LView, control: ɵControl<unknown>): void {
|
|||
const state = control.state();
|
||||
|
||||
const value = state.value();
|
||||
if (controlBindingUpdated(bindings, VALUE, value)) {
|
||||
if (controlBindingUpdated(bindings, CONTROL_VALUE, value)) {
|
||||
// We don't know if the interop control has underlying signals, so we must use `untracked` to
|
||||
// prevent writing to a signal in a reactive context.
|
||||
untracked(() => interopControl.writeValue(value));
|
||||
|
|
@ -538,9 +531,9 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unkn
|
|||
const state = control.state();
|
||||
const bindings = getControlBindings(lView);
|
||||
|
||||
const value = state.value();
|
||||
if (controlBindingUpdated(bindings, VALUE, value)) {
|
||||
setNativeControlValue(element, value);
|
||||
const controlValue = state.controlValue();
|
||||
if (controlBindingUpdated(bindings, CONTROL_VALUE, controlValue)) {
|
||||
setNativeControlValue(element, controlValue);
|
||||
}
|
||||
|
||||
const name = state.name();
|
||||
|
|
@ -783,8 +776,10 @@ const REQUIRED = /* @__PURE__ */ getClosureSafeProperty({
|
|||
required: getClosureSafeProperty,
|
||||
}) as 'required';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'value'. */
|
||||
const VALUE = /* @__PURE__ */ getClosureSafeProperty({value: getClosureSafeProperty}) as 'value';
|
||||
/** A property-renaming safe reference to a property named 'controlValue'. */
|
||||
const CONTROL_VALUE = /* @__PURE__ */ getClosureSafeProperty({
|
||||
controlValue: getClosureSafeProperty,
|
||||
}) as 'controlValue';
|
||||
|
||||
/**
|
||||
* A utility type that extracts the keys from `T` where the value type matches `TCondition`.
|
||||
|
|
@ -797,9 +792,13 @@ type KeysWithValueType<T, TCondition> = keyof {
|
|||
|
||||
/**
|
||||
* The keys of `ɵFieldState` that can be bound to a control.
|
||||
* These are the properties of `ɵFieldState` that are signals or undefined.
|
||||
* These are the properties of `ɵFieldState` that are signals or undefined, except for `value`
|
||||
* which is not bound directly, but updated indirectly through the `controlValue` binding.
|
||||
*/
|
||||
type ControlBindingKeys = KeysWithValueType<ɵFieldState<unknown>, Signal<any> | undefined>;
|
||||
type ControlBindingKeys = Exclude<
|
||||
KeysWithValueType<ɵFieldState<unknown>, Signal<any> | undefined>,
|
||||
'value'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A map of control binding keys to their values.
|
||||
|
|
@ -812,7 +811,8 @@ type ControlBindings = {
|
|||
/**
|
||||
* A map of field state properties to control binding name.
|
||||
*
|
||||
* This excludes `value` whose corresponding control binding name differs between control types.
|
||||
* This excludes `controlValue` whose corresponding control binding name differs between control
|
||||
* types.
|
||||
*
|
||||
* The control binding name can be used for inputs or attributes (since DOM attributes are case
|
||||
* insensitive).
|
||||
|
|
@ -831,7 +831,7 @@ const CONTROL_BINDING_NAMES = {
|
|||
readonly: 'readonly',
|
||||
required: 'required',
|
||||
touched: 'touched',
|
||||
} as const satisfies Record<Exclude<ControlBindingKeys, 'value'>, string>;
|
||||
} as const satisfies Record<Exclude<ControlBindingKeys, 'controlValue'>, string>;
|
||||
|
||||
/** The keys of {@link CONTROL_BINDING_NAMES} */
|
||||
const CONTROL_BINDING_KEYS = /* @__PURE__ */ (() => Object.keys(CONTROL_BINDING_NAMES))() as Array<
|
||||
|
|
|
|||
|
|
@ -128,11 +128,24 @@ export interface ɵFieldState<T> {
|
|||
readonly touched: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A writable signal containing the value for this field. Updating this signal will update the
|
||||
* data model that the field is bound to.
|
||||
* A writable signal containing the value for this field.
|
||||
*
|
||||
* Updating this signal will update the data model that the field is bound to.
|
||||
*
|
||||
* While updates from the UI control are eventually reflected here, they may be delayed if
|
||||
* debounced.
|
||||
*/
|
||||
readonly value: WritableSignal<T>;
|
||||
|
||||
/**
|
||||
* A signal containing the value of the control to which this field is bound.
|
||||
*
|
||||
* This differs from {@link value} in that it's not subject to debouncing, and thus is used to
|
||||
* buffer debounced updates from the control to the field. This will also not take into account
|
||||
* the {@link controlValue} of children.
|
||||
*/
|
||||
readonly controlValue: Signal<T>;
|
||||
|
||||
/**
|
||||
* Sets the dirty status of the field to `true`.
|
||||
*/
|
||||
|
|
@ -142,4 +155,9 @@ export interface ɵFieldState<T> {
|
|||
* Sets the touched status of the field to `true`.
|
||||
*/
|
||||
markAsTouched(): void;
|
||||
|
||||
/**
|
||||
* Sets {@link controlValue} immediately and triggers synchronization to {@link value}.
|
||||
*/
|
||||
setControlValue(value: T): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
*/
|
||||
export * from './src/api/async';
|
||||
export * from './src/api/control';
|
||||
export * from './src/api/debounce';
|
||||
export * from './src/api/field_directive';
|
||||
export * from './src/api/logic';
|
||||
export * from './src/api/metadata';
|
||||
|
|
|
|||
48
packages/forms/signals/src/api/debounce.ts
Normal file
48
packages/forms/signals/src/api/debounce.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @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 {DEBOUNCER} from '../field/debounce';
|
||||
import {FieldPathNode} from '../schema/path_node';
|
||||
import {assertPathIsCurrent} from '../schema/schema';
|
||||
import type {Debouncer, PathKind, SchemaPath, SchemaPathRules} from './types';
|
||||
|
||||
/**
|
||||
* Configures the frequency at which a form field is updated by UI events.
|
||||
*
|
||||
* When this rule is applied, updates from the UI to the form model will be delayed until either
|
||||
* the field is touched, or the most recently debounced update resolves.
|
||||
*
|
||||
* @param path The target path to debounce.
|
||||
* @param durationOrDebouncer Either a debounce duration in milliseconds, or a custom
|
||||
* {@link Debouncer} function.
|
||||
*
|
||||
* @experimental 21.0.0
|
||||
*/
|
||||
export function debounce<TValue, TPathKind extends PathKind = PathKind.Root>(
|
||||
path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>,
|
||||
durationOrDebouncer: number | Debouncer<TValue, TPathKind>,
|
||||
): void {
|
||||
assertPathIsCurrent(path);
|
||||
|
||||
const pathNode = FieldPathNode.unwrapFieldPath(path);
|
||||
const debouncer =
|
||||
typeof durationOrDebouncer === 'function'
|
||||
? durationOrDebouncer
|
||||
: durationOrDebouncer > 0
|
||||
? debounceForDuration(durationOrDebouncer)
|
||||
: immediate;
|
||||
pathNode.builder.addAggregateMetadataRule(DEBOUNCER, () => debouncer);
|
||||
}
|
||||
|
||||
function debounceForDuration(durationInMilliseconds: number): Debouncer<unknown> {
|
||||
return () => {
|
||||
return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds));
|
||||
};
|
||||
}
|
||||
|
||||
function immediate() {}
|
||||
|
|
@ -611,3 +611,18 @@ export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
|
|||
* @experimental 21.0.0
|
||||
*/
|
||||
export type ItemType<T extends Object> = T extends ReadonlyArray<any> ? T[number] : T[keyof T];
|
||||
|
||||
/**
|
||||
* A function that defines custom debounce logic for a field.
|
||||
*
|
||||
* This function receives the {@link FieldContext} for the field and should return a `Promise<void>`
|
||||
* to delay an update, or `void` to apply an update immediately.
|
||||
*
|
||||
* @template TValue The type of value stored in the field.
|
||||
* @template TPathKind The kind of path the debouncer is applied to (root field, child field, or item of an array).
|
||||
*
|
||||
* @experimental 21.0.0
|
||||
*/
|
||||
export type Debouncer<TValue, TPathKind extends PathKind = PathKind.Root> = (
|
||||
context: FieldContext<TValue, TPathKind>,
|
||||
) => Promise<void> | void;
|
||||
|
|
|
|||
23
packages/forms/signals/src/field/debounce.ts
Normal file
23
packages/forms/signals/src/field/debounce.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @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 {AggregateMetadataKey, reducedMetadataKey} from '../api/metadata';
|
||||
import {Debouncer} from '../api/types';
|
||||
|
||||
/**
|
||||
* A private {@link AggregateMetadataKey} used to aggregate `debounce()` rules.
|
||||
*
|
||||
* This will pick the last `debounce()` rule on a field that is currently applied, if conditional.
|
||||
*/
|
||||
export const DEBOUNCER: AggregateMetadataKey<
|
||||
Debouncer<any> | undefined,
|
||||
Debouncer<any>
|
||||
> = reducedMetadataKey(
|
||||
(_, item) => item,
|
||||
() => undefined,
|
||||
);
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {computed, type Signal, type WritableSignal} from '@angular/core';
|
||||
import {computed, linkedSignal, type Signal, type WritableSignal} from '@angular/core';
|
||||
import type {Field} from '../api/field_directive';
|
||||
import {
|
||||
AggregateMetadataKey,
|
||||
|
|
@ -57,10 +57,9 @@ export class FieldNode implements FieldState<unknown> {
|
|||
readonly metadataState: FieldMetadataState;
|
||||
readonly nodeState: FieldNodeState;
|
||||
readonly submitState: FieldSubmitState;
|
||||
|
||||
private _context: FieldContext<unknown> | undefined = undefined;
|
||||
readonly fieldAdapter: FieldAdapter;
|
||||
|
||||
private _context: FieldContext<unknown> | undefined = undefined;
|
||||
get context(): FieldContext<unknown> {
|
||||
return (this._context ??= new FieldNodeContext(this));
|
||||
}
|
||||
|
|
@ -79,6 +78,15 @@ export class FieldNode implements FieldState<unknown> {
|
|||
this.submitState = new FieldSubmitState(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent promise returned by the debouncer, or `undefined` if no debounce is active.
|
||||
* This is used to ensure that only the most recent debounce operation updates the field's value.
|
||||
*/
|
||||
private readonly pendingSync: WritableSignal<Promise<void> | undefined> = linkedSignal({
|
||||
source: () => this.value(),
|
||||
computation: () => undefined,
|
||||
});
|
||||
|
||||
get logicNode(): LogicNode {
|
||||
return this.structure.logic;
|
||||
}
|
||||
|
|
@ -87,6 +95,11 @@ export class FieldNode implements FieldState<unknown> {
|
|||
return this.structure.value;
|
||||
}
|
||||
|
||||
private _controlValue = linkedSignal(() => this.value());
|
||||
get controlValue(): Signal<unknown> {
|
||||
return this._controlValue.asReadonly();
|
||||
}
|
||||
|
||||
get keyInParent(): Signal<string | number> {
|
||||
return this.structure.keyInParent;
|
||||
}
|
||||
|
|
@ -189,6 +202,7 @@ export class FieldNode implements FieldState<unknown> {
|
|||
*/
|
||||
markAsTouched(): void {
|
||||
this.nodeState.markAsTouched();
|
||||
this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -212,6 +226,48 @@ export class FieldNode implements FieldState<unknown> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the control value of the field. This value may be debounced before it is synchronized with
|
||||
* the field's {@link value} signal, depending on the debounce configuration.
|
||||
*/
|
||||
setControlValue(newValue: unknown): void {
|
||||
this._controlValue.set(newValue);
|
||||
this.markAsDirty();
|
||||
this.debounceSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes the {@link controlValue} with the {@link value} signal immediately.
|
||||
*
|
||||
* This also clears any pending debounce operations.
|
||||
*/
|
||||
private sync() {
|
||||
this.value.set(this.controlValue());
|
||||
this.pendingSync.set(undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiates a debounced {@link sync}.
|
||||
*
|
||||
* If a debouncer is configured, the synchronization will occur after the debouncer. If no
|
||||
* debouncer is configured, the synchronization happens immediately. If a new
|
||||
* {@link setControlValue} call occurs while a debounce is pending, the previous debounce
|
||||
* operation is ignored in favor of the new one.
|
||||
*/
|
||||
private debounceSync() {
|
||||
const promise = this.nodeState.debouncer();
|
||||
if (promise) {
|
||||
promise.then(() => {
|
||||
if (promise === this.pendingSync()) {
|
||||
this.sync();
|
||||
}
|
||||
});
|
||||
this.pendingSync.set(promise);
|
||||
} else {
|
||||
this.sync();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new root field node for a new form.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import {computed, signal, Signal} from '@angular/core';
|
||||
import type {Field} from '../api/field_directive';
|
||||
import type {DisabledReason} from '../api/types';
|
||||
import {DEBOUNCER} from './debounce';
|
||||
import type {FieldNode} from './node';
|
||||
import {reduceChildren, shortCircuitTrue} from './util';
|
||||
|
||||
|
|
@ -157,6 +158,24 @@ export class FieldNodeState {
|
|||
return `${parent.name()}.${this.node.structure.keyInParent()}`;
|
||||
});
|
||||
|
||||
debouncer(): Promise<void> | void {
|
||||
if (this.node.logicNode.logic.hasAggregateMetadata(DEBOUNCER)) {
|
||||
const debouncerLogic = this.node.logicNode.logic.getAggregateMetadata(DEBOUNCER);
|
||||
|
||||
// Even if this field has a `debounce()` rule, it could be applied conditionally and currently
|
||||
// inactive, in which case `compute()` will return undefined.
|
||||
const debouncer = debouncerLogic.compute(this.node.context);
|
||||
if (debouncer) {
|
||||
return debouncer(this.node.context);
|
||||
}
|
||||
}
|
||||
|
||||
// Inherit its parent's debouncer, if any. If there is no debouncer configured all the way up
|
||||
// to the root field, this simply returns `undefined` indicating that the operation should not
|
||||
// be debounced.
|
||||
return this.node.structure.parent?.nodeState.debouncer();
|
||||
}
|
||||
|
||||
/** Whether this field is considered non-interactive.
|
||||
*
|
||||
* A field is considered non-interactive if one of the following is true:
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ export class LogicContainer {
|
|||
* @param key The `AggregateMetadataKey` for which to get the logic.
|
||||
* @returns The `AbstractLogic` associated with the key.
|
||||
*/
|
||||
getAggregateMetadata<T>(key: AggregateMetadataKey<unknown, T>): AbstractLogic<T> {
|
||||
getAggregateMetadata<T>(key: AggregateMetadataKey<any, T>): AbstractLogic<T> {
|
||||
if (!this.aggregateMetadataKeys.has(key as AggregateMetadataKey<unknown, unknown>)) {
|
||||
this.aggregateMetadataKeys.set(
|
||||
key as AggregateMetadataKey<unknown, unknown>,
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export class LogicNodeBuilder extends AbstractLogicNodeBuilder {
|
|||
}
|
||||
|
||||
override addAggregateMetadataRule<T>(
|
||||
key: AggregateMetadataKey<unknown, T>,
|
||||
key: AggregateMetadataKey<any, T>,
|
||||
logic: LogicFn<any, T>,
|
||||
): void {
|
||||
this.getCurrent().addAggregateMetadataRule(key, logic);
|
||||
|
|
|
|||
419
packages/forms/signals/test/node/api/debounce.spec.ts
Normal file
419
packages/forms/signals/test/node/api/debounce.spec.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* @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 {Injector, signal} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {applyWhenValue, debounce, form} from '@angular/forms/signals';
|
||||
|
||||
describe('debounce', () => {
|
||||
describe('by duration', () => {
|
||||
it('should synchronize value immediately if non-positive', () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, 0);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should synchronize value after duration', async () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, 1);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
await timeout(0);
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should synchronize value immediately on touch', () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, 1);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
street.markAsTouched();
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('by function', () => {
|
||||
it('should synchronize value immediately by default', () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, () => {});
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should synchronize value immediately on touch', () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, forever);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
street.markAsTouched();
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should synchronize value after promise resolves', async () => {
|
||||
const {promise, resolve} = promiseWithResolvers<void>();
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, () => promise);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
resolve();
|
||||
await promise;
|
||||
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should synchronize value after most recently returned promise resolves', async () => {
|
||||
const first = promiseWithResolvers();
|
||||
const second = promiseWithResolvers();
|
||||
const debounceFn = jasmine
|
||||
.createSpy('debounceFn')
|
||||
.and.returnValues(first.promise, second.promise);
|
||||
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, debounceFn);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
street.setControlValue('2000 N Shoreline Blvd');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
first.resolve();
|
||||
await first.promise;
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
second.resolve();
|
||||
await second.promise;
|
||||
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
||||
});
|
||||
|
||||
it('should be ignored if value is directly set before it resolves', async () => {
|
||||
const debounceResult = promiseWithResolvers();
|
||||
const debounceFn = jasmine.createSpy('debounceFn').and.returnValues(debounceResult.promise);
|
||||
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, debounceFn);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
// Set `controlValue` which will trigger a debounce update.
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
// Directly set value during debounce duration.
|
||||
street.value.set('2000 N Shoreline Blvd');
|
||||
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
||||
expect(street.controlValue()).toBe('2000 N Shoreline Blvd');
|
||||
|
||||
// Wait for the debounced update, which should be ignored.
|
||||
await debounceResult.resolve;
|
||||
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inheritance', () => {
|
||||
it('should inherit debounce from parent', async () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address, 1);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
await timeout(0);
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('can override inherited debounce', async () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address, 1);
|
||||
debounce(address.street, 0);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it(`should not affect parent's debounce`, async () => {
|
||||
const address = signal({street: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address, 1);
|
||||
debounce(address.street, 0);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
|
||||
addressForm().setControlValue({street: '1600 Amphitheatre Pkwy'});
|
||||
expect(addressForm().controlValue()).toEqual({street: '1600 Amphitheatre Pkwy'});
|
||||
expect(addressForm().value()).toEqual({street: ''});
|
||||
|
||||
await timeout(0);
|
||||
expect(addressForm().value()).toEqual({street: '1600 Amphitheatre Pkwy'});
|
||||
});
|
||||
|
||||
it(`should not affect a sibling's debounce`, async () => {
|
||||
const address = signal({street: '', city: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, 1);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
|
||||
addressForm.street().setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(addressForm().value()).toEqual({street: '', city: ''});
|
||||
|
||||
addressForm.city().setControlValue('Mountain View');
|
||||
expect(addressForm().value()).toEqual({street: '', city: 'Mountain View'});
|
||||
|
||||
await timeout(0);
|
||||
expect(addressForm().value()).toEqual({
|
||||
street: '1600 Amphitheatre Pkwy',
|
||||
city: 'Mountain View',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregation', () => {
|
||||
it('should apply the last debounce rule', () => {
|
||||
const address = signal({street: '', city: ''});
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
debounce(address.street, 1);
|
||||
debounce(address.street, 0);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should apply the last debounce rule from schemas', async () => {
|
||||
const address = signal({street: '', city: ''});
|
||||
const schema1 = (address: any) => {
|
||||
debounce(address.street, 0);
|
||||
};
|
||||
const schema2 = (address: any) => {
|
||||
debounce(address.street, 1);
|
||||
};
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
schema1(address);
|
||||
schema2(address);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
await timeout(0);
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
});
|
||||
|
||||
it('should apply the last debounce rule from conditional schemas', async () => {
|
||||
const address = signal({street: '', city: ''});
|
||||
const debounced = signal(false);
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
applyWhenValue(
|
||||
address,
|
||||
() => debounced(),
|
||||
(address) => {
|
||||
debounce(address.street, 0);
|
||||
},
|
||||
);
|
||||
applyWhenValue(
|
||||
address,
|
||||
() => debounced(),
|
||||
(address) => {
|
||||
debounce(address.street, 1);
|
||||
},
|
||||
);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
|
||||
debounced.set(true);
|
||||
street.setControlValue('2000 N Shoreline Blvd');
|
||||
expect(street.controlValue()).toBe('2000 N Shoreline Blvd');
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
|
||||
await timeout(0);
|
||||
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
||||
});
|
||||
|
||||
it('should apply debounce rule conditionally', async () => {
|
||||
const address = signal({street: '', city: ''});
|
||||
const debounced = signal(true);
|
||||
const addressForm = form(
|
||||
address,
|
||||
(address) => {
|
||||
applyWhenValue(
|
||||
address.street,
|
||||
() => debounced(),
|
||||
(street) => {
|
||||
debounce(street, 1);
|
||||
},
|
||||
);
|
||||
},
|
||||
options(),
|
||||
);
|
||||
const street = addressForm.street();
|
||||
|
||||
street.setControlValue('1600 Amphitheatre Pkwy');
|
||||
expect(street.controlValue()).toBe('1600 Amphitheatre Pkwy');
|
||||
expect(street.value()).toBe('');
|
||||
|
||||
await timeout(0);
|
||||
expect(street.value()).toBe('1600 Amphitheatre Pkwy');
|
||||
|
||||
debounced.set(false);
|
||||
street.setControlValue('2000 N Shoreline Blvd');
|
||||
expect(street.value()).toBe('2000 N Shoreline Blvd');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Options for testing. */
|
||||
function options() {
|
||||
return {injector: TestBed.inject(Injector)};
|
||||
}
|
||||
|
||||
/** Returns a promise that will resolve after {@link durationInMilliseconds}. */
|
||||
function timeout(durationInMilliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, durationInMilliseconds));
|
||||
}
|
||||
|
||||
/** Returns a promise that will never resolve. */
|
||||
function forever(): Promise<never> {
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace with `Promise.withResolvers()` once it's available.
|
||||
*
|
||||
* See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers.
|
||||
*/
|
||||
// TODO: share this with submit.spec.ts
|
||||
function promiseWithResolvers<T = void>(): {
|
||||
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};
|
||||
}
|
||||
Loading…
Reference in a new issue