mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-02-06 07:03:51 +01:00
feat(Form): apply transformations (#2550)
Co-authored-by: Romain Hamel <rom.hml@gmail.com>
This commit is contained in:
@@ -38,7 +38,7 @@ extendDevtoolsMeta({ example: 'FormExample' })
|
|||||||
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
|
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
|
||||||
import { useEventBus } from '@vueuse/core'
|
import { useEventBus } from '@vueuse/core'
|
||||||
import { formOptionsInjectionKey, formInputsInjectionKey, formBusInjectionKey, formLoadingInjectionKey } from '../composables/useFormField'
|
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'
|
import { FormValidationException } from '../types/form'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<FormProps<T>>(), {
|
const props = withDefaults(defineProps<FormProps<T>>(), {
|
||||||
@@ -108,20 +108,11 @@ async function getErrors(): Promise<FormErrorWithId[]> {
|
|||||||
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
|
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
|
||||||
|
|
||||||
if (props.schema) {
|
if (props.schema) {
|
||||||
if (isZodSchema(props.schema)) {
|
const { errors, result } = await parseSchema(props.state, props.schema as FormSchema<typeof props.state>)
|
||||||
errs = errs.concat(await getZodErrors(props.state, props.schema))
|
if (errors) {
|
||||||
} else if (isYupSchema(props.schema)) {
|
errs = errs.concat(errors)
|
||||||
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))
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Form validation failed: Unsupported form schema')
|
Object.assign(props.state, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export interface FormFieldInjectedOptions<T> {
|
|||||||
errorPattern?: RegExp
|
errorPattern?: RegExp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidateReturnSchema<T> {
|
||||||
|
result: T
|
||||||
|
errors: FormError[] | null
|
||||||
|
}
|
||||||
|
|
||||||
export class FormValidationException extends Error {
|
export class FormValidationException extends Error {
|
||||||
formId: string | number
|
formId: string | number
|
||||||
errors: FormErrorWithId[]
|
errors: FormErrorWithId[]
|
||||||
|
|||||||
@@ -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 { 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 { GenericSchema as ValibotSchema, GenericSchemaAsync as ValibotSchemaAsync, SafeParser as ValibotSafeParser, SafeParserAsync as ValibotSafeParserAsync } from 'valibot'
|
||||||
import type { Struct } from 'superstruct'
|
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<any> {
|
export function isYupSchema(schema: any): schema is YupObjectSchema<any> {
|
||||||
return schema.validate && schema.__isYupSchema__
|
return schema.validate && schema.__isYupSchema__
|
||||||
@@ -14,22 +14,6 @@ export function isYupError(error: any): error is YupError {
|
|||||||
return error.inner !== undefined
|
return error.inner !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getYupErrors(state: any, schema: YupObjectSchema<any>): Promise<FormError[]> {
|
|
||||||
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<any, any> {
|
export function isSuperStructSchema(schema: any): schema is Struct<any, any> {
|
||||||
return (
|
return (
|
||||||
'schema' in schema
|
'schema' in schema
|
||||||
@@ -43,17 +27,6 @@ export function isZodSchema(schema: any): schema is ZodSchema {
|
|||||||
return schema.parse !== undefined
|
return schema.parse !== undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getZodErrors(state: any, schema: ZodSchema): Promise<FormError[]> {
|
|
||||||
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 {
|
export function isJoiSchema(schema: any): schema is JoiSchema {
|
||||||
return schema.validateAsync !== undefined && schema.id !== undefined
|
return schema.validateAsync !== undefined && schema.id !== undefined
|
||||||
}
|
}
|
||||||
@@ -62,61 +35,177 @@ export function isJoiError(error: any): error is JoiError {
|
|||||||
return error.isJoi === true
|
return error.isJoi === true
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getJoiErrors(state: any, schema: JoiSchema): Promise<FormError[]> {
|
|
||||||
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<any, any> | ValibotSafeParserAsync<any, any> {
|
export function isValibotSchema(schema: any): schema is ValibotSchema | ValibotSchemaAsync | ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any> {
|
||||||
return '_run' in schema || (typeof schema === 'function' && 'schema' in schema)
|
return '_run' in schema || (typeof schema === 'function' && 'schema' in schema)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getValibotErrors(
|
|
||||||
state: any,
|
|
||||||
schema: ValibotSchema | ValibotSchemaAsync | ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any>
|
|
||||||
): Promise<FormError[]> {
|
|
||||||
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 {
|
export function isStandardSchema(schema: any): schema is v1.StandardSchema {
|
||||||
return '~standard' in schema
|
return '~standard' in schema
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStandardErrors(
|
export async function validateStandarSchema(
|
||||||
state: any,
|
state: any,
|
||||||
schema: v1.StandardSchema
|
schema: v1.StandardSchema
|
||||||
): Promise<FormError[]> {
|
): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
const result = await schema['~standard'].validate(state)
|
const result = await schema['~standard'].validate({
|
||||||
return result.issues?.map(issue => ({
|
value: state
|
||||||
name: issue.path?.map(item => typeof item === 'object' ? item.key : item).join('.') || '',
|
})
|
||||||
message: issue.message
|
|
||||||
})) || []
|
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<any, any>): Promise<FormError[]> {
|
async function validateYupSchema(
|
||||||
const [err] = schema.validate(state)
|
state: any,
|
||||||
|
schema: YupObjectSchema<any>
|
||||||
|
): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
|
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<any, any>): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
|
const [err, result] = schema.validate(state)
|
||||||
if (err) {
|
if (err) {
|
||||||
const errors = err.failures()
|
const errors = err.failures().map(error => ({
|
||||||
return errors.map(error => ({
|
|
||||||
message: error.message,
|
message: error.message,
|
||||||
name: error.path.join('.')
|
name: error.path.join('.')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
result: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors: null,
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateZodSchema(
|
||||||
|
state: any,
|
||||||
|
schema: ZodSchema
|
||||||
|
): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
|
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<ValidateReturnSchema<typeof state>> {
|
||||||
|
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<any, any> | ValibotSafeParserAsync<any, any>
|
||||||
|
): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
|
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<T extends object>(state: T, schema: FormSchema<T>): Promise<ValidateReturnSchema<typeof state>> {
|
||||||
|
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 []
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<UForm ref="form" :state="state" :schema="schema" @submit="onSubmit">
|
||||||
|
<UFormField name="value">
|
||||||
|
<UInput id="input" v-model="state.value" />
|
||||||
|
</UFormField>
|
||||||
|
</UForm>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
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 () => {
|
test('form field errorPattern works', async () => {
|
||||||
const wrapper = await mountSuspended({
|
const wrapper = await mountSuspended({
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
Reference in New Issue
Block a user