Skip to content

Commit

Permalink
feat: add zod schema support
Browse files Browse the repository at this point in the history
  • Loading branch information
logaretm committed Aug 11, 2024
1 parent 129ccc0 commit 849932b
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/schema-zod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Zod

This is the typed schema implementation for the `zod` provider.
35 changes: 35 additions & 0 deletions packages/schema-zod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@formwerk/schema-zod",
"version": "0.0.1",
"description": "",
"sideEffects": false,
"module": "dist/schema-zod.esm.js",
"unpkg": "dist/schema-zod.js",
"main": "dist/schema-zod.js",
"types": "dist/schema-zod.d.ts",
"repository": {
"url": "https://github.com/formwerkjs/formwerk.git",
"type": "git",
"directory": "packages/schema-yup"
},
"keywords": [
"VueJS",
"Vue",
"validation",
"validator",
"inputs",
"form",
"zod"
],
"files": [
"dist/*.js",
"dist/*.d.ts"
],
"dependencies": {
"@formwerk/core": "workspace:*",
"type-fest": "^4.24.0",
"zod": "^3.23.8"
},
"author": "",
"license": "MIT"
}
156 changes: 156 additions & 0 deletions packages/schema-zod/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { type Component, nextTick } from 'vue';
import { fireEvent, render, screen } from '@testing-library/vue';
import { useForm, useTextField } from '@formwerk/core';
import { defineSchema } from '.';
import { z } from 'zod';
import flush from 'flush-promises';

describe('schema-yup', () => {
function createInputComponent(): Component {
return {
inheritAttrs: false,
setup: (_, { attrs }) => {
const name = (attrs.name || 'test') as string;
const { errorMessage, inputProps } = useTextField({ name, label: name });

return { errorMessage: errorMessage, inputProps, name };
},
template: `
<input v-bind="inputProps" :data-testid="name" />
<span data-testid="err">{{ errorMessage }}</span>
`,
};
}

test('validates initially with yup schema', async () => {
await render({
components: { Child: createInputComponent() },
setup() {
const { getError, isValid } = useForm({
schema: defineSchema(
z.object({
test: z.string().min(1, 'Required'),
}),
),
});

return { getError, isValid };
},
template: `
<form>
<Child />
<span data-testid="form-err">{{ getError('test') }}</span>
<span data-testid="form-valid">{{ isValid }}</span>
</form>
`,
});

await flush();
expect(screen.getByTestId('form-valid').textContent).toBe('false');
expect(screen.getByTestId('err').textContent).toBe('Required');
expect(screen.getByTestId('form-err').textContent).toBe('Required');
});

test('prevents submission if the form is not valid', async () => {
const handler = vi.fn();

await render({
components: { Child: createInputComponent() },
setup() {
const { handleSubmit } = useForm({
schema: defineSchema(
z.object({
test: z.string().min(1, 'Required'),
}),
),
});

return { onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child />
<button type="submit">Submit</button>
</form>
`,
});

await nextTick();
await fireEvent.click(screen.getByText('Submit'));
expect(handler).not.toHaveBeenCalled();
await fireEvent.update(screen.getByTestId('test'), 'test');
await fireEvent.click(screen.getByText('Submit'));
await flush();
expect(handler).toHaveBeenCalledOnce();
});

test('supports transformations and preprocess', async () => {
const handler = vi.fn();

await render({
components: { Child: createInputComponent() },
setup() {
const { handleSubmit, getError } = useForm({
schema: defineSchema(
z.object({
test: z.string().transform(value => (value ? `epic-${value}` : value)),
age: z.preprocess(arg => Number(arg), z.number()),
}),
),
});

return { getError, onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child name="test" />
<Child name="age" />
<button type="submit">Submit</button>
</form>
`,
});

await flush();
await fireEvent.update(screen.getByTestId('test'), 'test');
await fireEvent.update(screen.getByTestId('age'), '11');
await fireEvent.click(screen.getByText('Submit'));
await flush();
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenLastCalledWith({ test: 'epic-test', age: 11 });
});

test('supports defaults', async () => {
const handler = vi.fn();

await render({
components: { Child: createInputComponent() },
setup() {
const { handleSubmit, getError } = useForm({
schema: defineSchema(
z.object({
test: z.string().min(1, 'Required').default('default-test'),
age: z.number().min(1, 'Required').default(22),
}),
),
});

return { getError, onSubmit: handleSubmit(handler) };
},
template: `
<form @submit="onSubmit" novalidate>
<Child name="test" />
<Child name="age" />
<button type="submit">Submit</button>
</form>
`,
});

await flush();
await expect(screen.getByDisplayValue('default-test')).toBeDefined();
await expect(screen.getByDisplayValue('22')).toBeDefined();
});
});
91 changes: 91 additions & 0 deletions packages/schema-zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ZodObject, input, output, ZodDefault, ZodSchema, ParseParams, ZodIssue } from 'zod';
import { PartialDeep } from 'type-fest';
import type { TypedSchema, TypedSchemaError } from '@formwerk/core';
import { isObject, merge } from '../../shared/src';

/**
* Transforms a Zod object schema to Yup's schema
*/
export function defineSchema<
TSchema extends ZodSchema,
TOutput = output<TSchema>,
TInput = PartialDeep<input<TSchema>>,
>(zodSchema: TSchema, opts?: Partial<ParseParams>): TypedSchema<TInput, TOutput> {
const schema: TypedSchema = {
async parse(value) {
const result = await zodSchema.safeParseAsync(value, opts);
if (result.success) {
return {
output: result.data,
errors: [],
};
}

const errors: Record<string, TypedSchemaError> = {};
processIssues(result.error.issues, errors);

return {
errors: Object.values(errors),
};
},
defaults(values) {
try {
return zodSchema.parse(values);
} catch {
// Zod does not support "casting" or not validating a value, so next best thing is getting the defaults and merging them with the provided values.
const defaults = getDefaults(zodSchema);
if (isObject(defaults) && isObject(values)) {
return merge(defaults, values);
}

return values;
}
},
};

return schema;
}

function processIssues(issues: ZodIssue[], errors: Record<string, TypedSchemaError>): void {
issues.forEach(issue => {
const path = issue.path.join('.');
if (issue.code === 'invalid_union') {
processIssues(
issue.unionErrors.flatMap(ue => ue.issues),
errors,
);

if (!path) {
return;
}
}

if (!errors[path]) {
errors[path] = { messages: [], path };
}

errors[path].messages.push(issue.message);
});
}

// Zod does not support extracting default values so the next best thing is manually extracting them.
// https://github.com/colinhacks/zod/issues/1944#issuecomment-1406566175
function getDefaults<Schema extends ZodSchema>(schema: Schema): unknown {
if (!(schema instanceof ZodObject)) {
return undefined;
}

return Object.fromEntries(
Object.entries(schema.shape).map(([key, value]) => {
if (value instanceof ZodDefault) {
return [key, value._def.defaultValue()];
}

if (value instanceof ZodObject) {
return [key, getDefaults(value)];
}

return [key, undefined];
}),
);
}

0 comments on commit 849932b

Please sign in to comment.