Skip to content

Commit

Permalink
feat: add zod schema support (#46)
Browse files Browse the repository at this point in the history
* feat: add zod schema support

* chore: add zod and yup build configs
  • Loading branch information
logaretm authored Aug 11, 2024
1 parent 129ccc0 commit ddf1df5
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 39 deletions.
6 changes: 5 additions & 1 deletion packages/playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@
},
"dependencies": {
"@formwerk/core": "workspace:*",
"@formwerk/schema-yup": "workspace:*",
"@formwerk/schema-zod": "workspace:*",
"tailwindcss": "^3.4.9",
"vue": "^3.5.0-alpha.4"
"vue": "^3.5.0-alpha.4",
"yup": "^1.3.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
Expand Down
47 changes: 10 additions & 37 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,52 +1,25 @@
<template>
<div class="flex gap-4 relative">
<form class="w-full" @submit="onSubmit" novalidate>
<div v-for="(field, idx) in fields" :key="field.id" class="flex items-center">
<InputText :name="`field.${idx}`" :label="`Field ${idx}`" required />
<div class="flex flex-col">
<InputText label="Test" name="test" />

<button type="button" class="bg-red-500 rounded text-white p-2" @click="fields.splice(idx, 1)">X</button>
</div>

<button class="bg-zinc-700 text-white rounded p-1" type="button" @click="add">+ Add Field</button>
<button class="bg-zinc-700 text-white rounded p-1" type="button" @click="swap">Swap</button>
<button class="bg-zinc-700 text-white rounded p-1" type="button" @click="reverse">Reverse</button>

{{ isValid }}
<!-- <InputSearch name="search" label="Search" :min-length="10" @submit="onSearchSubmit" /> -->

<button type="submit" class="bg-blue-700 text-white rounded p-1">Submit</button>
</form>

<div class="w-1/3 relative">
<pre class="max-h-[95vh] overflow-y-auto bg-gray-200 rounded-lg p-4 sticky top-4">{{ values }}</pre>
</div>
<pre>{{ isValid }}</pre>
</div>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import InputText from '@/components/InputText.vue';
import { useForm } from '@formwerk/core';
import { defineSchema } from '@formwerk/schema-yup';
import * as yup from 'yup';
const { values, isValid, handleSubmit } = useForm({
// initialValues: getInitials,
schema: defineSchema(
yup.object({
test: yup.string().required(),
}),
),
});
const fields = ref([{ type: 'text', id: Date.now() }]);
function add() {
fields.value.unshift({ type: 'text', id: Date.now() });
}
function swap() {
const [f1, f2, f3] = fields.value;
fields.value = [f2, f1, f3];
}
function reverse() {
fields.value = [...fields.value].reverse();
}
const onSubmit = handleSubmit(values => {
console.log(values);
});
Expand Down
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];
}),
);
}
Loading

0 comments on commit ddf1df5

Please sign in to comment.