-
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
285 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
}), | ||
); | ||
} |