Skip to content

Commit 74f76d8

Browse files
alxhubleonsenft
authored andcommitted
feat(forms): add reloadValidation to Signal Forms to manually trigger async validation
This commit introduces a formal mechanism to manually re-trigger asynchronous validations in Signal Forms, addressing #66994. It exposes a `reloadValidation` method on the `FieldState` interface that recursively cascades down the form tree and invokes the underlying `ResourceRef`'s `reload()` method for any metadata keys tagged with the internal `IS_ASYNC_VALIDATION_RESOURCE` symbol. Fixes #66994
1 parent c1312da commit 74f76d8

7 files changed

Lines changed: 98 additions & 6 deletions

File tree

adev/src/content/examples/aria/tree/src/single-select/basic/app/app.html

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,7 @@
2323
node.children ? 'folder' : 'docs'
2424
}}</span>
2525
{{ node.name }}
26-
<span
27-
aria-hidden="true"
28-
class="material-symbols-outlined selected-icon"
29-
translate="no"
26+
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no"
3027
>check</span
3128
>
3229
</li>

goldens/public-api/forms/signals/index.api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
132132
readonly fieldTree: FieldTree<unknown, TKey>;
133133
markAsDirty(): void;
134134
markAsTouched(options?: MarkAsTouchedOptions): void;
135+
reloadValidation(): void;
135136
reset(value?: TValue): void;
136137
readonly value: WritableSignal<TValue>;
137138
}
@@ -269,6 +270,9 @@ export type IgnoreUnknownProperties<T> = T extends Record<PropertyKey, unknown>
269270
[K in keyof T as RemoveStringIndexUnknownKey<K, T[K]>]: IgnoreUnknownProperties<T[K]>;
270271
} : T;
271272

273+
// @public
274+
export const IS_ASYNC_VALIDATION_RESOURCE: unique symbol;
275+
272276
// @public
273277
export interface ItemFieldContext<TValue> extends ChildFieldContext<TValue> {
274278
readonly index: Signal<number>;

packages/forms/signals/src/api/rules/metadata.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ function override<T>(getInitial?: () => T): MetadataReducer<T | undefined, T> {
121121
};
122122
}
123123

124+
/**
125+
* A symbol used to tag a `MetadataKey` as representing an asynchronous validation resource.
126+
*
127+
* @category validation
128+
* @experimental 21.0.0
129+
*/
130+
export const IS_ASYNC_VALIDATION_RESOURCE: unique symbol = Symbol('IS_ASYNC_VALIDATION_RESOURCE');
131+
124132
/**
125133
* Represents metadata that is aggregated from multiple parts according to the key's reducer
126134
* function. A value can be contributed to the aggregated value for a field using an
@@ -136,6 +144,9 @@ function override<T>(getInitial?: () => T): MetadataReducer<T | undefined, T> {
136144
export class MetadataKey<TRead, TWrite, TAcc> {
137145
private brand!: [TRead, TWrite, TAcc];
138146

147+
/** @internal */
148+
[IS_ASYNC_VALIDATION_RESOURCE]?: true;
149+
139150
/** Use {@link reducedMetadataKey}. */
140151
protected constructor(
141152
readonly reducer: MetadataReducer<TAcc, TWrite>,

packages/forms/signals/src/api/rules/validation/validate_async.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
SchemaPathRules,
1919
TreeValidationResult,
2020
} from '../../types';
21-
import {createManagedMetadataKey, metadata} from '../metadata';
21+
import {IS_ASYNC_VALIDATION_RESOURCE, createManagedMetadataKey, metadata} from '../metadata';
2222

2323
/**
2424
* A function that takes the result of an async operation and the current field context, and maps it
@@ -120,6 +120,8 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
120120
const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
121121
opts.factory,
122122
);
123+
RESOURCE[IS_ASYNC_VALIDATION_RESOURCE] = true;
124+
123125
metadata(path, RESOURCE, (ctx) => {
124126
const node = ctx.stateOf(path) as FieldNode;
125127
const validationState = node.validationState;

packages/forms/signals/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,10 @@ export interface FieldState<
544544
* @param value Optional value to set to the form. If not passed, the value will not be changed.
545545
*/
546546
reset(value?: TValue): void;
547+
/**
548+
* Reloads all asynchronous validators for this field and its descendants.
549+
*/
550+
reloadValidation(): void;
547551
}
548552

549553
/**

packages/forms/signals/src/field/node.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, linkedSignal, type Signal, untracked, type WritableSignal} from '@angular/core';
9+
import {
10+
computed,
11+
linkedSignal,
12+
type Signal,
13+
untracked,
14+
type WritableSignal,
15+
type Resource,
16+
type WritableResource,
17+
} from '@angular/core';
1018
import {
1119
MAX,
1220
MAX_LENGTH,
@@ -15,6 +23,7 @@ import {
1523
MIN_LENGTH,
1624
PATTERN,
1725
REQUIRED,
26+
IS_ASYNC_VALIDATION_RESOURCE,
1827
} from '../api/rules/metadata';
1928
import type {ValidationError} from '../api/rules/validation/validation_errors';
2029
import type {
@@ -309,6 +318,28 @@ export class FieldNode implements FieldState<unknown> {
309318
}
310319
}
311320

321+
/**
322+
* Reloads all asynchronous validators for this field and its descendants.
323+
*/
324+
reloadValidation(): void {
325+
untracked(() => this._reloadValidation());
326+
}
327+
328+
private _reloadValidation(): void {
329+
const keys = this.logicNode.logic.getMetadataKeys();
330+
for (const key of keys) {
331+
if (key[IS_ASYNC_VALIDATION_RESOURCE]) {
332+
const resource = this.metadata(key)! as Resource<unknown> &
333+
Partial<Pick<WritableResource<unknown>, 'reload'>>;
334+
resource.reload?.();
335+
}
336+
}
337+
338+
for (const child of this.structure.children()) {
339+
child._reloadValidation();
340+
}
341+
}
342+
312343
/**
313344
* Creates a linked signal that initiates a {@link debounceSync} when set.
314345
*/

packages/forms/signals/test/node/resource.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,47 @@ describe('resources', () => {
396396

397397
expect(f().metadata(RES)).toBe(undefined);
398398
});
399+
400+
describe('reloadValidation', () => {
401+
it('should trigger a reload of async http validation', async () => {
402+
const usernameForm = form(
403+
signal('unique-user'),
404+
(p) => {
405+
validateHttp(p, {
406+
request: ({value}) => `/api/check?username=${value()}`,
407+
onSuccess: (available: boolean) => (available ? undefined : {kind: 'username-taken'}),
408+
onError: () => null,
409+
});
410+
},
411+
{injector},
412+
);
413+
414+
TestBed.tick();
415+
const req1 = backend.expectOne('/api/check?username=unique-user');
416+
417+
expect(usernameForm().pending()).toBe(true);
418+
419+
req1.flush(true);
420+
await appRef.whenStable();
421+
422+
expect(usernameForm().valid()).toBe(true);
423+
expect(usernameForm().pending()).toBe(false);
424+
425+
// Trigger reload
426+
usernameForm().reloadValidation();
427+
428+
// Expect a new request to be made even though the value hasn't changed
429+
TestBed.tick();
430+
const req2 = backend.expectOne('/api/check?username=unique-user');
431+
432+
expect(usernameForm().pending()).toBe(true);
433+
expect(usernameForm().valid()).toBe(false);
434+
435+
req2.flush(false);
436+
await appRef.whenStable();
437+
438+
expect(usernameForm().invalid()).toBe(true);
439+
expect(usernameForm().pending()).toBe(false);
440+
});
441+
});
399442
});

0 commit comments

Comments
 (0)