Skip to content

Commit

Permalink
fix: render zod multiple errors in nested objects closes #4078
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Mar 11, 2023
1 parent 3c2d51c commit f74fb69
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 24 deletions.
32 changes: 22 additions & 10 deletions packages/vee-validate/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async function _validate<TValue = unknown>(field: FieldValidationContext<TValue>
name: field.name,
label: field.label,
form: field.formData,
value: value,
value,
};

// Normalize the pipeline
Expand Down Expand Up @@ -141,6 +141,17 @@ async function _validate<TValue = unknown>(field: FieldValidationContext<TValue>
};
}

interface YupError {
name: 'ValidationError';
path?: string;
errors: string[];
inner: { path?: string; errors: string[] }[];
}

function isYupError(err: unknown): err is YupError {
return !!err && (err as any).name === 'ValidationError';
}

function yupToTypedSchema(yupSchema: YupSchema): TypedSchema {
const schema: TypedSchema = {
__type: 'VVTypedSchema',
Expand All @@ -152,23 +163,24 @@ function yupToTypedSchema(yupSchema: YupSchema): TypedSchema {
output,
errors: [],
};
} catch (err: any) {
} catch (err: unknown) {
// Yup errors have a name prop one them.
// https://github.com/jquense/yup#validationerrorerrors-string--arraystring-value-any-path-string
if (err.name !== 'ValidationError') {
if (!isYupError(err)) {
throw err;
}

const errors: Record<string, TypedSchemaError> = err.inner.reduce((acc: any, curr: any) => {
if (!curr.path) {
return acc;
}
if (!err.inner?.length && err.errors.length) {
return { errors: [{ path: err.path, errors: err.errors }] };
}

if (!acc[curr.path]) {
acc[curr.path] = { errors: [], path: curr.path };
const errors: Record<string, TypedSchemaError> = err.inner.reduce((acc, curr) => {
const path = curr.path || '';
if (!acc[path]) {
acc[path] = { errors: [], path };
}

acc[curr.path].errors.push(...curr.errors);
acc[path].errors.push(...curr.errors);

return acc;
}, {} as Record<string, TypedSchemaError>);
Expand Down
11 changes: 4 additions & 7 deletions packages/yup/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,12 @@ export function toTypedSchema<TSchema extends Schema, TOutput = InferType<TSchem
}

const errors: Record<string, TypedSchemaError> = error.inner.reduce((acc, curr) => {
if (!curr.path) {
return acc;
const path = curr.path || '';
if (!acc[path]) {
acc[path] = { errors: [], path };
}

if (!acc[curr.path]) {
acc[curr.path] = { errors: [], path: curr.path };
}

acc[curr.path].errors.push(...curr.errors);
acc[path].errors.push(...curr.errors);

return acc;
}, {} as Record<string, TypedSchemaError>);
Expand Down
21 changes: 14 additions & 7 deletions packages/zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ZodObject, input, output, ZodDefault, ZodSchema } from 'zod';
import { ZodObject, input, output, ZodDefault, ZodSchema, ParseParams } from 'zod';
import { PartialDeep } from 'type-fest';
import type { TypedSchema, TypedSchemaError } from 'vee-validate';
import { isIndex, merge } from '../../shared';
Expand All @@ -10,24 +10,31 @@ export function toTypedSchema<
TSchema extends ZodSchema,
TOutput = output<TSchema>,
TInput = PartialDeep<input<TSchema>>
>(zodSchema: TSchema): TypedSchema<TInput, TOutput> {
>(zodSchema: TSchema, opts?: Partial<ParseParams>): TypedSchema<TInput, TOutput> {
const schema: TypedSchema = {
__type: 'VVTypedSchema',
async validate(value) {
const result = await zodSchema.safeParseAsync(value);
const result = await zodSchema.safeParseAsync(value, opts);
if (result.success) {
return {
value: result.data,
errors: [],
};
}

const errors: TypedSchemaError[] = result.error.issues.map<TypedSchemaError>(issue => {
return { path: joinPath(issue.path), errors: [issue.message] };
});
const errors: Record<string, TypedSchemaError> = result.error.issues.reduce((acc, issue) => {
const path = joinPath(issue.path);
if (!acc[path]) {
acc[path] = { errors: [], path };
}

acc[path].errors.push(issue.message);

return acc;
}, {} as Record<string, TypedSchemaError>);

return {
errors,
errors: Object.values(errors),
};
},
parse(values) {
Expand Down
85 changes: 85 additions & 0 deletions packages/zod/tests/zod.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useField, useForm } from '@/vee-validate';
import { toTypedSchema } from '@/zod';
import { mountWithHoc, flushPromises, setValue } from 'vee-validate/tests/helpers';
import { Ref } from 'vue';
import * as zod from 'zod';

const REQUIRED_MSG = 'field is required';
Expand Down Expand Up @@ -40,6 +41,90 @@ test('validates typed field with yup', async () => {
expect(error.textContent).toBe('');
});

test('generates multiple errors for any given field', async () => {
let errors!: Ref<string[]>;
const wrapper = mountWithHoc({
setup() {
const rules = toTypedSchema(zod.string().min(1, REQUIRED_MSG).min(8, MIN_MSG));
const { value, errors: fieldErrors } = useField('test', rules);

errors = fieldErrors;
return {
value,
};
},
template: `
<div>
<input v-model="value" type="text">
</div>
`,
});

const input = wrapper.$el.querySelector('input');

setValue(input, '');
await flushPromises();
expect(errors.value).toHaveLength(2);
expect(errors.value).toEqual([REQUIRED_MSG, MIN_MSG]);
});

test('shows multiple errors using error bag', async () => {
const wrapper = mountWithHoc({
setup() {
const schema = toTypedSchema(
zod.object({
email: zod.string().email(EMAIL_MSG).min(7, MIN_MSG),
password: zod.string().min(8, MIN_MSG),
})
);

const { useFieldModel, errorBag } = useForm({
validationSchema: schema,
validateOnMount: true,
});

const [email, password] = useFieldModel(['email', 'password']);

return {
schema,
email,
password,
errorBag,
};
},
template: `
<div>
<input id="email" name="email" v-model="email" />
<span id="emailErr">{{ errorBag.email?.join(',') }}</span>
<input id="password" name="password" type="password" v-model="password" />
<span id="passwordErr">{{ errorBag.password?.join(',') }}</span>
</div>
`,
});

const email = wrapper.$el.querySelector('#email');
const password = wrapper.$el.querySelector('#password');
const emailError = wrapper.$el.querySelector('#emailErr');
const passwordError = wrapper.$el.querySelector('#passwordErr');

await flushPromises();

setValue(email, 'hello@');
setValue(password, '1234');
await flushPromises();

expect(emailError.textContent).toBe([EMAIL_MSG, MIN_MSG].join(','));
expect(passwordError.textContent).toBe([MIN_MSG].join(','));

setValue(email, 'hello@email.com');
setValue(password, '12346789');
await flushPromises();

expect(emailError.textContent).toBe('');
expect(passwordError.textContent).toBe('');
});

test('validates typed schema form with yup', async () => {
const wrapper = mountWithHoc({
setup() {
Expand Down

0 comments on commit f74fb69

Please sign in to comment.