Skip to content

Commit 0b02460

Browse files
committed
feat(forms): support signal-based schemas in validateStandardSchema
Allow `validateStandardSchema()` to consume a computed schema so validation rules stay in sync when the schema changes over time. This supports schemas stored in computed signals (e.g. zod schemas that depend on input signals) and ensures the effective schema updates after initialization instead of being captured once. Fixes #66867
1 parent a64a90d commit 0b02460

File tree

4 files changed

+215
-11
lines changed

4 files changed

+215
-11
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
573573
export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, opts: HttpValidatorOptions<TValue, TResult, TPathKind>): void;
574574

575575
// @public
576-
export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProperties<TSchema>>(path: SchemaPath<TModel> & SchemaPathTree<TModel>, schema: StandardSchemaV1<TSchema>): void;
576+
export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProperties<TSchema>>(path: SchemaPath<TModel> & SchemaPathTree<TModel>, schema: StandardSchemaV1<TSchema> | LogicFn<TModel, StandardSchemaV1<unknown> | undefined>): void;
577577

578578
// @public
579579
export function validateTree<TValue, TPathKind extends PathKind = PathKind.Root>(path: SchemaPath<TValue, SchemaPathRules.Supported, TPathKind>, logic: NoInfer<TreeValidator<TValue, TPathKind>>): void;

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

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {resource, ɵisPromise} from '@angular/core';
1010
import type {StandardSchemaV1} from '@standard-schema/spec';
1111
import {addDefaultField} from '../../../field/validation';
12-
import type {FieldTree, SchemaPath, SchemaPathTree} from '../../types';
12+
import type {FieldTree, LogicFn, SchemaPath, SchemaPathTree} from '../../types';
1313
import {createMetadataKey, metadata} from '../metadata';
1414
import {validateAsync} from './validate_async';
1515
import {validateTree} from './validate_tree';
@@ -54,7 +54,7 @@ export type IgnoreUnknownProperties<T> =
5454
* See https://github.com/standard-schema/standard-schema for more about standard schema.
5555
*
5656
* @param path The `FieldPath` to the field to validate.
57-
* @param schema The standard schema compatible validator to use for validation.
57+
* @param schema The standard schema compatible validator to use for validation, or a LogicFn that returns the schema.
5858
* @template TSchema The type validated by the schema. This may be either the full `TValue` type,
5959
* or a partial of it.
6060
* @template TValue The type of value stored in the field being validated.
@@ -65,7 +65,7 @@ export type IgnoreUnknownProperties<T> =
6565
*/
6666
export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProperties<TSchema>>(
6767
path: SchemaPath<TModel> & SchemaPathTree<TModel>,
68-
schema: StandardSchemaV1<TSchema>,
68+
schema: StandardSchemaV1<TSchema> | LogicFn<TModel, StandardSchemaV1<unknown> | undefined>,
6969
) {
7070
// We create both a sync and async validator because the standard schema validator can return
7171
// either a sync result or a Promise, and we need to handle both cases. The sync validator
@@ -75,16 +75,19 @@ export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProp
7575
type Result = StandardSchemaV1.Result<TSchema> | Promise<StandardSchemaV1.Result<TSchema>>;
7676
const VALIDATOR_MEMO = metadata(
7777
path as SchemaPath<TModel>,
78-
createMetadataKey<Result>(),
79-
({value}) => {
80-
return schema['~standard'].validate(value());
78+
createMetadataKey<Result | undefined>(),
79+
(ctx) => {
80+
const resolvedSchema = typeof schema === 'function' ? schema(ctx) : schema;
81+
return resolvedSchema
82+
? (resolvedSchema['~standard'].validate(ctx.value()) as Result)
83+
: undefined;
8184
},
8285
);
8386

8487
validateTree<TModel>(path, ({state, fieldTreeOf}) => {
85-
// Skip sync validation if the result is a Promise.
88+
// Skip sync validation if the result is a Promise or undefined.
8689
const result = state.metadata(VALIDATOR_MEMO)!();
87-
if (ɵisPromise(result)) {
90+
if (!result || ɵisPromise(result)) {
8891
return [];
8992
}
9093
return (
@@ -102,7 +105,7 @@ export function validateStandardSchema<TSchema, TModel extends IgnoreUnknownProp
102105
params: ({state}) => {
103106
// Skip async validation if the result is *not* a Promise.
104107
const result = state.metadata(VALIDATOR_MEMO)!();
105-
return ɵisPromise(result) ? result : undefined;
108+
return result && ɵisPromise(result) ? result : undefined;
106109
},
107110
factory: (params) => {
108111
return resource({

packages/forms/signals/test/node/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ng_project(
55
testonly = True,
66
srcs = glob(["**/*.spec.ts"]),
77
deps = [
8+
"//:node_modules/@standard-schema/spec",
89
"//:node_modules/zod",
910
"//packages/common/http",
1011
"//packages/common/http/testing",

packages/forms/signals/test/node/api/validators/standard_schema.spec.ts

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

9-
import {ApplicationRef, Injector, signal} from '@angular/core';
9+
import {ApplicationRef, computed, Injector, linkedSignal, signal} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import * as z from 'zod';
1212
import {form, schema, validateStandardSchema} from '../../../../public_api';
@@ -209,4 +209,204 @@ describe('standard schema integration', () => {
209209

210210
expect(f.age().errors()[0].message).toBe('Age must be non-negative');
211211
});
212+
213+
it('should support reactive schema using computed signal', () => {
214+
const minLength = signal(2);
215+
216+
const zodSchema = computed(() =>
217+
z.object({
218+
first: z.string().min(minLength()),
219+
last: z.string().min(3),
220+
}),
221+
);
222+
223+
const nameForm = form(
224+
signal({first: 'A', last: 'B'}),
225+
(p) => {
226+
validateStandardSchema(p, () => zodSchema());
227+
},
228+
{injector: TestBed.inject(Injector)},
229+
);
230+
231+
// Initially, first name should have error (length 1, min 2)
232+
expect(nameForm.first().errors()).toEqual([
233+
jasmine.objectContaining({
234+
kind: 'standardSchema',
235+
issue: jasmine.objectContaining({
236+
message: jasmine.stringMatching(/Too small|String must contain at least 2 character/),
237+
}),
238+
}),
239+
]);
240+
241+
// Change minLength to 1, should remove error
242+
minLength.set(1);
243+
expect(nameForm.first().errors()).toEqual([]);
244+
245+
// Change minLength to 3, should add error again
246+
minLength.set(3);
247+
expect(nameForm.first().errors()).toEqual([
248+
jasmine.objectContaining({
249+
kind: 'standardSchema',
250+
issue: jasmine.objectContaining({
251+
message: jasmine.stringMatching(/Too small|String must contain at least 3 character/),
252+
}),
253+
}),
254+
]);
255+
});
256+
257+
it('should support reactive schema using signal', () => {
258+
const minLength = signal(2);
259+
260+
const nameForm = form(
261+
signal({first: 'A', last: 'B'}),
262+
(p) => {
263+
validateStandardSchema(p, () =>
264+
z.object({
265+
first: z.string().min(minLength()),
266+
last: z.string().min(3),
267+
}),
268+
);
269+
},
270+
{injector: TestBed.inject(Injector)},
271+
);
272+
273+
// Initially, first name should have error (length 1, min 2)
274+
expect(nameForm.first().errors()).toEqual([
275+
jasmine.objectContaining({
276+
kind: 'standardSchema',
277+
issue: jasmine.objectContaining({
278+
message: jasmine.stringMatching(/Too small|String must contain at least 2 character/),
279+
}),
280+
}),
281+
]);
282+
283+
// Change minLength to 1, should remove error
284+
minLength.set(1);
285+
expect(nameForm.first().errors()).toEqual([]);
286+
287+
// Change minLength to 5, should add error again
288+
minLength.set(5);
289+
expect(nameForm.first().errors()).toEqual([
290+
jasmine.objectContaining({
291+
kind: 'standardSchema',
292+
issue: jasmine.objectContaining({
293+
message: jasmine.stringMatching(/Too small|String must contain at least 5 character/),
294+
}),
295+
}),
296+
]);
297+
});
298+
299+
it('should support reactive schema using linkedSignal', () => {
300+
const minFirstLength = signal(2);
301+
const minLastLength = linkedSignal(() => minFirstLength() + 1);
302+
303+
const nameForm = form(
304+
signal({first: 'A', last: 'BB'}),
305+
(p) => {
306+
validateStandardSchema(p, () =>
307+
z.object({
308+
first: z.string().min(minFirstLength()),
309+
last: z.string().min(minLastLength()),
310+
}),
311+
);
312+
},
313+
{injector: TestBed.inject(Injector)},
314+
);
315+
316+
// Initially, first needs 2 chars, last needs 3 chars (2+1)
317+
expect(nameForm.first().errors()).toEqual([
318+
jasmine.objectContaining({
319+
kind: 'standardSchema',
320+
issue: jasmine.objectContaining({
321+
message: jasmine.stringMatching(/Too small|String must contain at least 2 character/),
322+
}),
323+
}),
324+
]);
325+
expect(nameForm.last().errors()).toEqual([
326+
jasmine.objectContaining({
327+
kind: 'standardSchema',
328+
issue: jasmine.objectContaining({
329+
message: jasmine.stringMatching(/Too small|String must contain at least 3 character/),
330+
}),
331+
}),
332+
]);
333+
334+
// Change minFirstLength to 1, linkedSignal auto-updates to 2
335+
minFirstLength.set(1);
336+
expect(nameForm.first().errors()).toEqual([]);
337+
expect(nameForm.last().errors()).toEqual([]);
338+
339+
// Change minFirstLength to 4, linkedSignal auto-updates to 5
340+
minFirstLength.set(4);
341+
expect(nameForm.first().errors()).toEqual([
342+
jasmine.objectContaining({
343+
kind: 'standardSchema',
344+
issue: jasmine.objectContaining({
345+
message: jasmine.stringMatching(/Too small|String must contain at least 4 character/),
346+
}),
347+
}),
348+
]);
349+
expect(nameForm.last().errors()).toEqual([
350+
jasmine.objectContaining({
351+
kind: 'standardSchema',
352+
issue: jasmine.objectContaining({
353+
message: jasmine.stringMatching(/Too small|String must contain at least 5 character/),
354+
}),
355+
}),
356+
]);
357+
});
358+
359+
it('should support reactive schema using LogicFn with field context', () => {
360+
type FormModel = {
361+
type: 'email' | 'phone';
362+
value: string;
363+
};
364+
365+
const model = signal<FormModel>({type: 'email', value: 'invalid'});
366+
const nameForm = form(
367+
model,
368+
(p) => {
369+
validateStandardSchema(p, (ctx) => {
370+
const formValue = ctx.value();
371+
if (formValue.type === 'email') {
372+
return z.object({
373+
type: z.literal('email'),
374+
value: z.email(),
375+
});
376+
} else {
377+
return z.object({
378+
type: z.literal('phone'),
379+
value: z.string().regex(/^\d{3}-\d{3}-\d{4}$/),
380+
});
381+
}
382+
});
383+
},
384+
{injector: TestBed.inject(Injector)},
385+
);
386+
387+
// Initially type is 'email', value should have email error
388+
expect(nameForm.value().errors()).toEqual([
389+
jasmine.objectContaining({
390+
kind: 'standardSchema',
391+
issue: jasmine.objectContaining({
392+
message: jasmine.stringMatching(/Invalid email/),
393+
}),
394+
}),
395+
]);
396+
397+
// Change to phone type, should validate as phone number
398+
model.set({type: 'phone', value: '123-456-7890'});
399+
expect(nameForm.value().errors()).toEqual([]);
400+
401+
// Invalid phone number
402+
model.set({type: 'phone', value: 'invalid-phone'});
403+
expect(nameForm.value().errors()).toEqual([
404+
jasmine.objectContaining({
405+
kind: 'standardSchema',
406+
issue: jasmine.objectContaining({
407+
message: jasmine.stringMatching(/Invalid/),
408+
}),
409+
}),
410+
]);
411+
});
212412
});

0 commit comments

Comments
 (0)