Skip to content

Commit 5940551

Browse files
committed
feat: path descriptors for typed schemas (#4561)
1 parent e370413 commit 5940551

File tree

13 files changed

+633
-13
lines changed

13 files changed

+633
-13
lines changed

packages/rules/src/toTypedSchema.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { keysOf } from '../../vee-validate/src/utils';
22
import { TypedSchema, RawFormSchema, validateObject, TypedSchemaError, validate } from 'vee-validate';
3-
import { Optional } from '../../shared';
3+
import { Optional, isObject } from '../../shared';
44

55
export function toTypedSchema<TOutput = any, TInput extends Optional<TOutput> = Optional<TOutput>>(
66
rawSchema: RawFormSchema<TInput> | string,
@@ -34,6 +34,29 @@ export function toTypedSchema<TOutput = any, TInput extends Optional<TOutput> =
3434
}),
3535
};
3636
},
37+
describe(path) {
38+
if (isObject(rawSchema) && path in rawSchema) {
39+
const rules = (rawSchema as any)[path];
40+
if (typeof rules === 'string') {
41+
return {
42+
exists: true,
43+
required: rules.includes('required'),
44+
};
45+
}
46+
47+
if (isObject(rules)) {
48+
return {
49+
exists: true,
50+
required: !!rules.required,
51+
};
52+
}
53+
}
54+
55+
return {
56+
required: false,
57+
exists: false,
58+
};
59+
},
3760
};
3861

3962
return schema;

packages/rules/tests/toTypedSchema.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,80 @@ test('validates typed field with global rules', async () => {
100100
await flushPromises();
101101
expect(error.textContent).toBe('');
102102
});
103+
104+
test('reports required state on fields', async () => {
105+
const metaSpy = vi.fn();
106+
mountWithHoc({
107+
setup() {
108+
const schema = toTypedSchema({
109+
name: 'required',
110+
email: { required: true },
111+
nreq: 'min:8',
112+
});
113+
114+
useForm({
115+
validationSchema: schema,
116+
});
117+
118+
const { meta: name } = useField('name');
119+
const { meta: email } = useField('email');
120+
const { meta: nreq } = useField('nreq');
121+
122+
metaSpy({
123+
name: name.required,
124+
email: email.required,
125+
nreq: nreq.required,
126+
});
127+
128+
return {
129+
schema,
130+
};
131+
},
132+
template: `<div></div>`,
133+
});
134+
135+
await flushPromises();
136+
await expect(metaSpy).toHaveBeenLastCalledWith(
137+
expect.objectContaining({
138+
name: true,
139+
email: true,
140+
nreq: false,
141+
}),
142+
);
143+
});
144+
145+
test('reports required state on fields', async () => {
146+
const metaSpy = vi.fn();
147+
mountWithHoc({
148+
setup() {
149+
const schema = toTypedSchema({
150+
name: 'required',
151+
});
152+
153+
useForm({
154+
validationSchema: schema,
155+
});
156+
157+
const { meta: email } = useField('email');
158+
const { meta: req } = useField('req');
159+
160+
metaSpy({
161+
email: email.required,
162+
req: req.required,
163+
});
164+
165+
return {
166+
schema,
167+
};
168+
},
169+
template: `<div></div>`,
170+
});
171+
172+
await flushPromises();
173+
await expect(metaSpy).toHaveBeenLastCalledWith(
174+
expect.objectContaining({
175+
email: false,
176+
req: false,
177+
}),
178+
);
179+
});

packages/valibot/src/index.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { PartialDeep } from 'type-fest';
2-
import type { TypedSchema, TypedSchemaError } from 'vee-validate';
2+
import { cleanupNonNestedPath, isNotNestedPath, type TypedSchema, type TypedSchemaError } from 'vee-validate';
33
import {
44
Output,
55
Input,
@@ -10,8 +10,10 @@ import {
1010
Issue,
1111
getDefault,
1212
optional,
13+
ArraySchema,
14+
ObjectSchema,
1315
} from 'valibot';
14-
import { isObject, merge, normalizeFormPath } from '../../shared';
16+
import { isIndex, isObject, merge, normalizeFormPath } from '../../shared';
1517

1618
export function toTypedSchema<
1719
TSchema extends BaseSchema | BaseSchemaAsync,
@@ -53,6 +55,22 @@ export function toTypedSchema<
5355

5456
return values;
5557
},
58+
describe(path) {
59+
const description = getSchemaForPath(path, valibotSchema);
60+
if (!description) {
61+
return {
62+
required: false,
63+
exists: false,
64+
};
65+
}
66+
67+
const isOptional = (description as any).type === 'optional';
68+
69+
return {
70+
required: !isOptional,
71+
exists: true,
72+
};
73+
},
5674
};
5775

5876
return schema;
@@ -79,3 +97,42 @@ function processIssues(issues: Issue[], errors: Record<string, TypedSchemaError>
7997
errors[path].errors.push(issue.message);
8098
});
8199
}
100+
101+
function getSchemaForPath(path: string, schema: any): BaseSchema | null {
102+
if (!isObjectSchema(schema)) {
103+
return null;
104+
}
105+
106+
if (isNotNestedPath(path)) {
107+
return schema.entries[cleanupNonNestedPath(path)];
108+
}
109+
110+
const paths = (path || '').split(/\.|\[(\d+)\]/).filter(Boolean);
111+
112+
let currentSchema: BaseSchema = schema;
113+
for (let i = 0; i <= paths.length; i++) {
114+
const p = paths[i];
115+
if (!p || !currentSchema) {
116+
return currentSchema;
117+
}
118+
119+
if (isObjectSchema(currentSchema)) {
120+
currentSchema = currentSchema.entries[p] || null;
121+
continue;
122+
}
123+
124+
if (isIndex(p) && isArraySchema(currentSchema)) {
125+
currentSchema = currentSchema.item;
126+
}
127+
}
128+
129+
return null;
130+
}
131+
132+
function isArraySchema(schema: unknown): schema is ArraySchema<any> {
133+
return isObject(schema) && schema.type === 'array';
134+
}
135+
136+
function isObjectSchema(schema: unknown): schema is ObjectSchema<any> {
137+
return isObject(schema) && schema.type === 'object';
138+
}

packages/valibot/tests/valibot.spec.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Ref } from 'vue';
22
import { useField, useForm } from '@/vee-validate';
3-
import { string, minLength, email as emailV, object, coerce, any, number, withDefault, optional } from 'valibot';
3+
import { string, minLength, email as emailV, object, coerce, any, number, withDefault, optional, array } from 'valibot';
44
import { toTypedSchema } from '@/valibot';
55
import { mountWithHoc, flushPromises, setValue } from '../../vee-validate/tests/helpers';
66

@@ -365,3 +365,110 @@ describe('valibot', () => {
365365
await expect(initialSpy).toHaveBeenLastCalledWith(expect.objectContaining({}));
366366
});
367367
});
368+
369+
test('reports required state on fields', async () => {
370+
const metaSpy = vi.fn();
371+
mountWithHoc({
372+
setup() {
373+
const schema = toTypedSchema(
374+
object({
375+
'not.nested.path': string(),
376+
name: optional(string()),
377+
email: string(),
378+
nested: object({
379+
arr: array(object({ req: string(), nreq: optional(string()) })),
380+
obj: object({
381+
req: string(),
382+
nreq: optional(string()),
383+
}),
384+
}),
385+
}),
386+
);
387+
388+
useForm({
389+
validationSchema: schema,
390+
});
391+
392+
const { meta: name } = useField('name');
393+
const { meta: email } = useField('email');
394+
const { meta: req } = useField('nested.obj.req');
395+
const { meta: nreq } = useField('nested.obj.nreq');
396+
const { meta: arrReq } = useField('nested.arr.0.req');
397+
const { meta: arrNreq } = useField('nested.arr.1.nreq');
398+
const { meta: notNested } = useField('[not.nested.path]');
399+
400+
metaSpy({
401+
name: name.required,
402+
email: email.required,
403+
objReq: req.required,
404+
objNreq: nreq.required,
405+
arrReq: arrReq.required,
406+
arrNreq: arrNreq.required,
407+
notNested: notNested.required,
408+
});
409+
410+
return {
411+
schema,
412+
};
413+
},
414+
template: `<div></div>`,
415+
});
416+
417+
await flushPromises();
418+
await expect(metaSpy).toHaveBeenLastCalledWith(
419+
expect.objectContaining({
420+
name: false,
421+
email: true,
422+
objReq: true,
423+
objNreq: false,
424+
arrReq: true,
425+
arrNreq: false,
426+
notNested: true,
427+
}),
428+
);
429+
});
430+
431+
test('reports required false for non-existent fields', async () => {
432+
const metaSpy = vi.fn();
433+
mountWithHoc({
434+
setup() {
435+
const schema = toTypedSchema(
436+
object({
437+
name: string(),
438+
nested: object({
439+
arr: array(object({ prop: string() })),
440+
obj: object({}),
441+
}),
442+
}),
443+
);
444+
445+
useForm({
446+
validationSchema: schema,
447+
});
448+
449+
const { meta: email } = useField('email');
450+
const { meta: req } = useField('nested.obj.req');
451+
const { meta: arrReq } = useField('nested.arr.0.req');
452+
453+
metaSpy({
454+
email: email.required,
455+
objReq: req.required,
456+
arrReq: arrReq.required,
457+
});
458+
459+
return {
460+
schema,
461+
};
462+
},
463+
template: `<div></div>`,
464+
});
465+
466+
await flushPromises();
467+
await expect(metaSpy).toHaveBeenLastCalledWith(
468+
expect.objectContaining({
469+
email: false,
470+
objReq: false,
471+
arrReq: false,
472+
}),
473+
);
474+
});

packages/vee-validate/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export { validate, validateObjectSchema as validateObject } from './validate';
22
export { defineRule } from './defineRule';
33
export { configure } from './config';
4-
export { normalizeRules } from './utils';
4+
export { normalizeRules, isNotNestedPath, cleanupNonNestedPath } from './utils';
55
export { Field } from './Field';
66
export { Form } from './Form';
77
export { FieldArray } from './FieldArray';

packages/vee-validate/src/types/forms.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@ export interface TypedSchemaError {
1313
path?: string;
1414
errors: string[];
1515
}
16+
export interface TypedSchemaPathDescription {
17+
required: boolean;
18+
}
1619

1720
export interface TypedSchema<TInput = any, TOutput = TInput> {
1821
__type: 'VVTypedSchema';
1922
parse(values: TInput): Promise<{ value?: TOutput; errors: TypedSchemaError[] }>;
2023
cast?(values: Partial<TInput>): TInput;
24+
describe?(path: Path<TInput>): Partial<TypedSchemaPathDescription>;
2125
}
2226

2327
export type InferOutput<TSchema extends TypedSchema> = TSchema extends TypedSchema<any, infer TOutput>
@@ -39,6 +43,7 @@ export interface FieldMeta<TValue> {
3943
dirty: boolean;
4044
valid: boolean;
4145
validated: boolean;
46+
required: boolean;
4247
pending: boolean;
4348
initialValue?: TValue;
4449
}
@@ -87,6 +92,7 @@ export interface PathState<TValue = unknown> {
8792
touched: boolean;
8893
dirty: boolean;
8994
valid: boolean;
95+
required: boolean;
9096
validated: boolean;
9197
pending: boolean;
9298
initialValue: TValue | undefined;

packages/vee-validate/src/useForm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ export function useForm<
298298
pending: false,
299299
valid: true,
300300
validated: !!initialErrors[pathValue]?.length,
301+
required: computed(() =>
302+
isTypedSchema(schema) ? (schema as TypedSchema).describe?.(toValue(path)).required ?? false : false,
303+
),
301304
initialValue,
302305
errors: shallowRef([]),
303306
bails: config?.bails ?? false,

packages/vee-validate/src/utils/common.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { isContainerValue, isEmptyContainer, isEqual, isNotNestedPath } from './
1717
import { GenericObject, MaybePromise } from '../types';
1818
import { FormContextKey, FieldContextKey } from '../symbols';
1919

20-
function cleanupNonNestedPath(path: string) {
20+
export function cleanupNonNestedPath(path: string) {
2121
if (isNotNestedPath(path)) {
2222
return path.replace(/\[|\]/gi, '');
2323
}

packages/vee-validate/tests/useForm.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ describe('useForm()', () => {
763763

764764
// #4320
765765
test('Initial values are merged with previous values to ensure meta.dirty is stable', async () => {
766-
let meta!: Ref<FieldMeta<any>>;
766+
let meta!: Ref<FormMeta<any>>;
767767

768768
mountWithHoc({
769769
setup() {

0 commit comments

Comments
 (0)