Skip to content

Commit 58b470f

Browse files
committed
feat: add required support for field-level schemas
1 parent 5940551 commit 58b470f

File tree

10 files changed

+235
-18
lines changed

10 files changed

+235
-18
lines changed

packages/valibot/src/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,23 @@ export function toTypedSchema<
5656
return values;
5757
},
5858
describe(path) {
59-
const description = getSchemaForPath(path, valibotSchema);
60-
if (!description) {
59+
if (!path) {
60+
return {
61+
required: !queryOptional(valibotSchema),
62+
exists: true,
63+
};
64+
}
65+
66+
const pathSchema = getSchemaForPath(path, valibotSchema);
67+
if (!pathSchema) {
6168
return {
6269
required: false,
6370
exists: false,
6471
};
6572
}
6673

67-
const isOptional = (description as any).type === 'optional';
68-
6974
return {
70-
required: !isOptional,
75+
required: !queryOptional(pathSchema),
7176
exists: true,
7277
};
7378
},
@@ -129,6 +134,10 @@ function getSchemaForPath(path: string, schema: any): BaseSchema | null {
129134
return null;
130135
}
131136

137+
function queryOptional(schema: BaseSchema | BaseSchemaAsync): boolean {
138+
return (schema as any).type === 'optional';
139+
}
140+
132141
function isArraySchema(schema: unknown): schema is ArraySchema<any> {
133142
return isObject(schema) && schema.type === 'array';
134143
}

packages/valibot/tests/valibot.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,56 @@ test('reports required false for non-existent fields', async () => {
472472
}),
473473
);
474474
});
475+
476+
test('reports required state single field schemas', async () => {
477+
const metaSpy = vi.fn();
478+
mountWithHoc({
479+
setup() {
480+
useForm();
481+
const { meta: req } = useField('req', toTypedSchema(string()));
482+
const { meta: nreq } = useField('nreq', toTypedSchema(optional(string())));
483+
484+
metaSpy({
485+
req: req.required,
486+
nreq: nreq.required,
487+
});
488+
489+
return {};
490+
},
491+
template: `<div></div>`,
492+
});
493+
494+
await flushPromises();
495+
await expect(metaSpy).toHaveBeenLastCalledWith(
496+
expect.objectContaining({
497+
req: true,
498+
nreq: false,
499+
}),
500+
);
501+
});
502+
503+
test('reports required state single field schemas without a form context', async () => {
504+
const metaSpy = vi.fn();
505+
mountWithHoc({
506+
setup() {
507+
const { meta: req } = useField('req', toTypedSchema(string()));
508+
const { meta: nreq } = useField('nreq', toTypedSchema(optional(string())));
509+
510+
metaSpy({
511+
req: req.required,
512+
nreq: nreq.required,
513+
});
514+
515+
return {};
516+
},
517+
template: `<div></div>`,
518+
});
519+
520+
await flushPromises();
521+
await expect(metaSpy).toHaveBeenLastCalledWith(
522+
expect.objectContaining({
523+
req: true,
524+
nreq: false,
525+
}),
526+
);
527+
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ export interface TypedSchemaError {
1515
}
1616
export interface TypedSchemaPathDescription {
1717
required: boolean;
18+
exists: boolean;
1819
}
1920

2021
export interface TypedSchema<TInput = any, TOutput = TInput> {
2122
__type: 'VVTypedSchema';
2223
parse(values: TInput): Promise<{ value?: TOutput; errors: TypedSchemaError[] }>;
2324
cast?(values: Partial<TInput>): TInput;
24-
describe?(path: Path<TInput>): Partial<TypedSchemaPathDescription>;
25+
describe?(path?: Path<TInput>): Partial<TypedSchemaPathDescription>;
2526
}
2627

2728
export type InferOutput<TSchema extends TypedSchema> = TSchema extends TypedSchema<any, infer TOutput>
@@ -84,6 +85,7 @@ export interface PathStateConfig {
8485
label: MaybeRefOrGetter<string | undefined>;
8586
type: InputType;
8687
validate: FieldValidator;
88+
schema?: TypedSchema;
8789
}
8890

8991
export interface PathState<TValue = unknown> {

packages/vee-validate/src/useField.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ function _useField<TValue = unknown>(
150150
label,
151151
type,
152152
validate: validator.value ? validate : undefined,
153+
schema: isTypedSchema(rules) ? (rules as any) : undefined,
153154
});
154155

155156
const errorMessage = computed(() => errors.value[0]);

packages/vee-validate/src/useFieldState.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { computed, isRef, reactive, ref, Ref, unref, watch, MaybeRef, MaybeRefOrGetter } from 'vue';
22
import { FieldMeta, FieldState, FieldValidator, InputType, PrivateFormContext, PathState } from './types';
33
import { getFromPath, isEqual, normalizeErrorItem } from './utils';
4+
import { TypedSchema } from '../dist/vee-validate';
45

56
export interface StateSetterInit<TValue = unknown> extends FieldState<TValue> {
67
initialValue: TValue;
@@ -24,6 +25,7 @@ export interface StateInit<TValue = unknown> {
2425
label?: MaybeRefOrGetter<string | undefined>;
2526
type?: InputType;
2627
validate?: FieldValidator;
28+
schema?: TypedSchema<TValue>;
2729
}
2830

2931
let ID_COUNTER = 0;
@@ -37,7 +39,7 @@ export function useFieldState<TValue = unknown>(
3739
if (!init.form) {
3840
const { errors, setErrors } = createFieldErrors();
3941
const id = ID_COUNTER >= Number.MAX_SAFE_INTEGER ? 0 : ++ID_COUNTER;
40-
const meta = createFieldMeta(value, initialValue, errors);
42+
const meta = createFieldMeta(value, initialValue, errors, init.schema);
4143

4244
function setState(state: Partial<StateSetterInit<TValue>>) {
4345
if ('value' in state) {
@@ -74,6 +76,7 @@ export function useFieldState<TValue = unknown>(
7476
label: init.label,
7577
type: init.type,
7678
validate: init.validate,
79+
schema: init.schema,
7780
});
7881

7982
const errors = computed(() => state.errors);
@@ -207,11 +210,15 @@ function createFieldMeta<TValue>(
207210
currentValue: Ref<TValue>,
208211
initialValue: MaybeRef<TValue> | undefined,
209212
errors: Ref<string[]>,
213+
schema?: TypedSchema<TValue>,
210214
) {
215+
const isRequired = schema?.describe?.().required ?? false;
216+
211217
const meta = reactive({
212218
touched: false,
213219
pending: false,
214220
valid: true,
221+
required: isRequired,
215222
validated: !!unref(errors).length,
216223
initialValue: computed(() => unref(initialValue) as TValue | undefined),
217224
dirty: computed(() => {

packages/vee-validate/src/useForm.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,19 @@ export function useForm<
290290

291291
const currentValue = computed(() => getFromPath(formValues, toValue(path)));
292292
const pathValue = toValue(path);
293+
const isRequired = computed(() => {
294+
if (isTypedSchema(schema)) {
295+
return (schema as TypedSchema).describe?.(toValue(path)).required ?? false;
296+
}
297+
298+
// Path own schema
299+
if (isTypedSchema(config?.schema)) {
300+
return (config?.schema as TypedSchema).describe?.().required ?? false;
301+
}
302+
303+
return false;
304+
});
305+
293306
const id = FIELD_ID_COUNTER++;
294307
const state = reactive({
295308
id,
@@ -298,9 +311,7 @@ export function useForm<
298311
pending: false,
299312
valid: true,
300313
validated: !!initialErrors[pathValue]?.length,
301-
required: computed(() =>
302-
isTypedSchema(schema) ? (schema as TypedSchema).describe?.(toValue(path)).required ?? false : false,
303-
),
314+
required: isRequired,
304315
initialValue,
305316
errors: shallowRef([]),
306317
bails: config?.bails ?? false,

packages/yup/src/index.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
ValidateOptions,
88
ValidationError,
99
} from 'yup';
10-
import { TypedSchema, TypedSchemaError, isNotNestedPath, cleanupNonNestedPath } from 'vee-validate';
10+
import {
11+
TypedSchema,
12+
TypedSchemaError,
13+
isNotNestedPath,
14+
cleanupNonNestedPath,
15+
TypedSchemaPathDescription,
16+
} from 'vee-validate';
1117
import { PartialDeep } from 'type-fest';
1218
import { isIndex, isObject, merge } from '../../shared';
1319

@@ -69,26 +75,41 @@ export function toTypedSchema<TSchema extends Schema, TOutput = InferType<TSchem
6975
}
7076
},
7177
describe(path) {
78+
if (!path) {
79+
return getDescriptionFromYupDescription(yupSchema.describe());
80+
}
81+
7282
const description = getDescriptionForPath(path, yupSchema);
73-
if (!description || !('tests' in description)) {
83+
if (!description) {
7484
return {
7585
required: false,
7686
exists: false,
7787
};
7888
}
7989

80-
const required = description?.tests?.some(t => t.name === 'required') || false;
81-
82-
return {
83-
required,
84-
exists: true,
85-
};
90+
return getDescriptionFromYupDescription(description);
8691
},
8792
};
8893

8994
return schema;
9095
}
9196

97+
function getDescriptionFromYupDescription(desc: SchemaFieldDescription): TypedSchemaPathDescription {
98+
if ('tests' in desc) {
99+
const required = desc?.tests?.some(t => t.name === 'required') || false;
100+
101+
return {
102+
required,
103+
exists: true,
104+
};
105+
}
106+
107+
return {
108+
required: false,
109+
exists: false,
110+
};
111+
}
112+
92113
function getDescriptionForPath(path: string, schema: Schema): SchemaFieldDescription | null {
93114
if (!isObjectSchema(schema)) {
94115
return null;

packages/yup/tests/yup.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,3 +429,56 @@ test('reports required false for non-existent fields', async () => {
429429
}),
430430
);
431431
});
432+
433+
test('reports required state single field schemas', async () => {
434+
const metaSpy = vi.fn();
435+
mountWithHoc({
436+
setup() {
437+
useForm();
438+
const { meta: req } = useField('req', toTypedSchema(yup.string().required()));
439+
const { meta: nreq } = useField('nreq', toTypedSchema(yup.string()));
440+
441+
metaSpy({
442+
req: req.required,
443+
nreq: nreq.required,
444+
});
445+
446+
return {};
447+
},
448+
template: `<div></div>`,
449+
});
450+
451+
await flushPromises();
452+
await expect(metaSpy).toHaveBeenLastCalledWith(
453+
expect.objectContaining({
454+
req: true,
455+
nreq: false,
456+
}),
457+
);
458+
});
459+
460+
test('reports required state single field schemas without a form context', async () => {
461+
const metaSpy = vi.fn();
462+
mountWithHoc({
463+
setup() {
464+
const { meta: req } = useField('req', toTypedSchema(yup.string().required()));
465+
const { meta: nreq } = useField('nreq', toTypedSchema(yup.string()));
466+
467+
metaSpy({
468+
req: req.required,
469+
nreq: nreq.required,
470+
});
471+
472+
return {};
473+
},
474+
template: `<div></div>`,
475+
});
476+
477+
await flushPromises();
478+
await expect(metaSpy).toHaveBeenLastCalledWith(
479+
expect.objectContaining({
480+
req: true,
481+
nreq: false,
482+
}),
483+
);
484+
});

packages/zod/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ export function toTypedSchema<
5555
}
5656
},
5757
describe(path) {
58+
if (!path) {
59+
return {
60+
required: !zodSchema.isOptional(),
61+
exists: true,
62+
};
63+
}
64+
5865
const description = getSchemaForPath(path, zodSchema);
5966
if (!description) {
6067
return {

0 commit comments

Comments
 (0)