diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 2ce0c090..dbb4fb54 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -38,7 +38,7 @@ extendDevtoolsMeta({ example: 'FormExample' }) import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue' import { useEventBus } from '@vueuse/core' import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField' -import { getYupErrors, isYupSchema, getValibotErrors, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema, getStandardErrors, isStandardSchema, getSuperStructErrors, isSuperStructSchema } from '../utils/form' +import { parseSchema } from '../utils/form' import { FormValidationException } from '../types/form' const props = withDefaults(defineProps>(), { @@ -108,20 +108,11 @@ async function getErrors(): Promise { let errs = props.validate ? (await props.validate(props.state)) ?? [] : [] if (props.schema) { - if (isZodSchema(props.schema)) { - errs = errs.concat(await getZodErrors(props.state, props.schema)) - } else if (isYupSchema(props.schema)) { - errs = errs.concat(await getYupErrors(props.state, props.schema)) - } else if (isJoiSchema(props.schema)) { - errs = errs.concat(await getJoiErrors(props.state, props.schema)) - } else if (isValibotSchema(props.schema)) { - errs = errs.concat(await getValibotErrors(props.state, props.schema)) - } else if (isSuperStructSchema(props.schema)) { - errs = errs.concat(await getSuperStructErrors(props.state, props.schema)) - } else if (isStandardSchema(props.schema)) { - errs = errs.concat(await getStandardErrors(props.state, props.schema)) + const { errors, result } = await parseSchema(props.state, props.schema as FormSchema) + if (errors) { + errs = errs.concat(errors) } else { - throw new Error('Form validation failed: Unsupported form schema') + Object.assign(props.state, result) } } diff --git a/src/runtime/types/form.ts b/src/runtime/types/form.ts index d2b92236..a727c4bd 100644 --- a/src/runtime/types/form.ts +++ b/src/runtime/types/form.ts @@ -85,6 +85,11 @@ export interface FormFieldInjectedOptions { errorPattern?: RegExp } +export interface ValidateReturnSchema { + result: T + errors: FormError[] | null +} + export class FormValidationException extends Error { formId: string | number errors: FormErrorWithId[] diff --git a/src/runtime/utils/form.ts b/src/runtime/utils/form.ts index a11aaffd..487976fa 100644 --- a/src/runtime/utils/form.ts +++ b/src/runtime/utils/form.ts @@ -4,7 +4,7 @@ import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi' import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup' import type { GenericSchema as ValibotSchema, GenericSchemaAsync as ValibotSchemaAsync, SafeParser as ValibotSafeParser, SafeParserAsync as ValibotSafeParserAsync } from 'valibot' import type { Struct } from 'superstruct' -import type { FormError } from '../types/form' +import type { FormSchema, ValidateReturnSchema } from '../types/form' export function isYupSchema(schema: any): schema is YupObjectSchema { return schema.validate && schema.__isYupSchema__ @@ -14,22 +14,6 @@ export function isYupError(error: any): error is YupError { return error.inner !== undefined } -export async function getYupErrors(state: any, schema: YupObjectSchema): Promise { - try { - await schema.validate(state, { abortEarly: false }) - return [] - } catch (error) { - if (isYupError(error)) { - return error.inner.map(issue => ({ - name: issue.path ?? '', - message: issue.message - })) - } else { - throw error - } - } -} - export function isSuperStructSchema(schema: any): schema is Struct { return ( 'schema' in schema @@ -43,17 +27,6 @@ export function isZodSchema(schema: any): schema is ZodSchema { return schema.parse !== undefined } -export async function getZodErrors(state: any, schema: ZodSchema): Promise { - const result = await schema.safeParseAsync(state) - if (result.success === false) { - return result.error.issues.map(issue => ({ - name: issue.path.join('.'), - message: issue.message - })) - } - return [] -} - export function isJoiSchema(schema: any): schema is JoiSchema { return schema.validateAsync !== undefined && schema.id !== undefined } @@ -62,61 +35,177 @@ export function isJoiError(error: any): error is JoiError { return error.isJoi === true } -export async function getJoiErrors(state: any, schema: JoiSchema): Promise { - try { - await schema.validateAsync(state, { abortEarly: false }) - return [] - } catch (error) { - if (isJoiError(error)) { - return error.details.map(detail => ({ - name: detail.path.join('.'), - message: detail.message - })) - } else { - throw error - } - } -} - export function isValibotSchema(schema: any): schema is ValibotSchema | ValibotSchemaAsync | ValibotSafeParser | ValibotSafeParserAsync { return '_run' in schema || (typeof schema === 'function' && 'schema' in schema) } -export async function getValibotErrors( - state: any, - schema: ValibotSchema | ValibotSchemaAsync | ValibotSafeParser | ValibotSafeParserAsync -): Promise { - const result = await ('_run' in schema ? schema._run({ typed: false, value: state }, {}) : schema(state)) - return result.issues?.map(issue => ({ - // We know that the key for a form schema is always a string or a number - name: issue.path?.map((item: any) => item.key).join('.') || '', - message: issue.message - })) || [] -} - export function isStandardSchema(schema: any): schema is v1.StandardSchema { return '~standard' in schema } -export async function getStandardErrors( +export async function validateStandarSchema( state: any, schema: v1.StandardSchema -): Promise { - const result = await schema['~standard'].validate(state) - return result.issues?.map(issue => ({ - name: issue.path?.map(item => typeof item === 'object' ? item.key : item).join('.') || '', - message: issue.message - })) || [] +): Promise> { + const result = await schema['~standard'].validate({ + value: state + }) + + if (result.issues) { + return { + errors: result.issues?.map(issue => ({ + name: issue.path?.map(item => typeof item === 'object' ? item.key : item).join('.') || '', + message: issue.message + })) || [], + result: null + } + } + + return { + errors: null, + result: result.value + } } -export async function getSuperStructErrors(state: any, schema: Struct): Promise { - const [err] = schema.validate(state) +async function validateYupSchema( + state: any, + schema: YupObjectSchema +): Promise> { + try { + const result = schema.validateSync(state, { abortEarly: false }) + return { + errors: null, + result + } + } catch (error) { + if (isYupError(error)) { + const errors = error.inner.map(issue => ({ + name: issue.path ?? '', + message: issue.message + })) + + return { + errors, + result: null + } + } else { + throw error + } + } +} + +async function validateSuperstructSchema(state: any, schema: Struct): Promise> { + const [err, result] = schema.validate(state) if (err) { - const errors = err.failures() - return errors.map(error => ({ + const errors = err.failures().map(error => ({ message: error.message, name: error.path.join('.') })) + + return { + errors, + result: null + } + } + + return { + errors: null, + result + } +} + +async function validateZodSchema( + state: any, + schema: ZodSchema +): Promise> { + const result = await schema.safeParseAsync(state) + if (result.success === false) { + const errors = result.error.issues.map(issue => ({ + name: issue.path.join('.'), + message: issue.message + })) + + return { + errors, + result: null + } + } + return { + result: result.data, + errors: null + } +} + +async function validateJoiSchema( + state: any, + schema: JoiSchema +): Promise> { + try { + const result = await schema.validateAsync(state, { abortEarly: false }) + return { + errors: null, + result + } + } catch (error) { + if (isJoiError(error)) { + const errors = error.details.map(issue => ({ + name: issue.path.join('.'), + message: issue.message + })) + + return { + errors, + result: null + } + } else { + throw error + } + } +} + +async function validateValibotSchema( + state: any, + schema: ValibotSchema | ValibotSchemaAsync | ValibotSafeParser | ValibotSafeParserAsync +): Promise> { + const result = await ('_run' in schema ? schema._run({ typed: false, value: state }, {}) : schema(state)) + + if (!result.issues || result.issues.length === 0) { + const output = ('output' in result + ? result.output + : 'value' in result + ? result.value + : null) + return { + errors: null, + result: output + } + } + + const errors = result.issues.map(issue => ({ + name: issue.path?.map((item: any) => item.key).join('.') || '', + message: issue.message + })) + + return { + errors, + result: null + } +} + +export function parseSchema(state: T, schema: FormSchema): Promise> { + if (isZodSchema(schema)) { + return validateZodSchema(state, schema) + } else if (isJoiSchema(schema)) { + return validateJoiSchema(state, schema) + } else if (isValibotSchema(schema)) { + return validateValibotSchema(state, schema) + } else if (isYupSchema(schema)) { + return validateYupSchema(state, schema) + } else if (isSuperStructSchema(schema)) { + return validateSuperstructSchema(state, schema) + } else if (isStandardSchema(schema)) { + return validateStandarSchema(state, schema) + } else { + throw new Error('Form validation failed: Unsupported form schema') } - return [] } diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts index 5ac2e311..897568ae 100644 --- a/test/components/Form.spec.ts +++ b/test/components/Form.spec.ts @@ -364,6 +364,79 @@ describe('Form', () => { }) }) + describe('apply transform', async () => { + it.each([ + [ + 'zod', + z.object({ + value: z.string().transform(value => value.toUpperCase()) + }), + { value: 'test' }, + { value: 'TEST' } + ], + [ + 'yup', + yup.object({ + value: yup.string().transform(value => value.toUpperCase()) + }), + { value: 'test' }, + { value: 'TEST' } + ], + [ + 'joi', + Joi.object({ + value: Joi.string().custom(value => value.toUpperCase()) + }), + { value: 'test' }, + { value: 'TEST' } + ], + [ + 'valibot', + valibot.object({ + value: valibot.pipe(valibot.string(), valibot.transform(v => v.toUpperCase())) + }), + { value: 'test' }, + { value: 'TEST' } + ] + ])( + '%s schema transform works', + async (_name: string, schema: any, input: any, expected: any) => { + const wrapper = await mountSuspended({ + components: { + UFormField, + UForm, + UInput + }, + setup() { + const form = ref() + const state = reactive({}) + const onSubmit = vi.fn() + return { state, schema, form, onSubmit } + }, + template: ` + + + + + + ` + }) + const form = wrapper.setupState.form + const state = wrapper.setupState.state + + const inputEl = wrapper.find('#input') + inputEl.setValue(input.value) + + form.value.submit() + await flushPromises() + + expect(state.value).toEqual(expected.value) + expect(wrapper.setupState.onSubmit).toHaveBeenCalledWith(expect.objectContaining({ + data: expected + })) + } + ) + }) test('form field errorPattern works', async () => { const wrapper = await mountSuspended({ components: {