Compare commits

...

3 Commits

Author SHA1 Message Date
Romain Hamel
385cbeec6c refactor(Form): remove state assignment and opt-in to nested forms 2025-04-16 18:10:54 +02:00
Romain Hamel
4d875c03a2 chore: up 2025-04-15 12:48:10 +02:00
Romain Hamel
5aea866057 fix(Form): handle schema output types 2025-04-14 19:51:57 +02:00
6 changed files with 128 additions and 53 deletions

View File

@@ -34,9 +34,10 @@ const schema = z.object({
pin: z.string().regex(/^\d$/).array().length(5)
})
type Schema = z.input<typeof schema>
type Input = z.input<typeof schema>
type Output = z.output<typeof schema>
const state = reactive<Partial<Schema>>({})
const state = reactive<Partial<Input>>({})
const form = useTemplateRef('form')
@@ -47,7 +48,7 @@ const items = [
]
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
async function onSubmit(event: FormSubmitEvent<Output>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}

View File

@@ -39,7 +39,7 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
<UCheckbox v-model="state.news" name="news" label="Register to our newsletter" @update:model-value="state.email = undefined" />
</div>
<UForm v-if="state.news" :state="state" :schema="nestedSchema">
<UForm v-if="state.news" :state="state" :schema="nestedSchema" nested>
<UFormField label="Email" name="email">
<UInput v-model="state.email" placeholder="john@lennon.com" />
</UFormField>

View File

@@ -51,7 +51,14 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
<UInput v-model="state.customer" placeholder="Wonka Industries" />
</UFormField>
<UForm v-for="item, count in state.items" :key="count" :state="item" :schema="itemSchema" class="flex gap-2">
<UForm
v-for="item, count in state.items"
:key="count"
:state="item"
:schema="itemSchema"
nested
class="flex gap-2"
>
<UFormField :label="!count ? 'Description' : undefined" name="description">
<UInput v-model="item.description" />
</UFormField>

View File

@@ -0,0 +1,71 @@
<template>
<UContainer>
<UForm :schema :state @submit="onSubmit">
<UFormField label="A" name="a">
<UInput v-model="state.a" />
</UFormField>
<UFormField label="B" name="b">
<UInput v-model="state.b" />
</UFormField>
<UButton type="submit">
Submit
</UButton>
</UForm>
{{ output }}
</UContainer>
</template>
<script lang="ts" setup>
import type { FormSubmitEvent } from '@nuxt/ui'
import * as v from 'valibot'
const _schemaStringFiltered = v.pipe(v.string(), v.trim())
const schema = v.object({
a: v.string(),
b: v.union([
v.pipe(
v.array(_schemaStringFiltered),
v.filterItems((item, index, array) => (array.indexOf(item) === index || item !== ''))
),
v.pipe(
v.string(),
v.trim(),
v.transform(
(item) => {
if (item === '') return undefined
return item
.split(',')
.map(val => val.trim())
.filter(val => val !== '')
}
)
)
])
})
const state = reactive<{
a: string
b: string
}>({
a: 'hello, world',
b: 'hello, world'
})
const output = reactive<{
a: string
b?: string[]
}>({
a: '',
b: []
})
function onSubmit(event: FormSubmitEvent<v.InferOutput<typeof schema>>) {
console.log('typeof `a`:', typeof event.data.a) // should be string
console.log('typeof `b`:', typeof event.data.b) // should be object (array of strings)
output.a = event.data.a
output.b = event.data.b
}
</script>

View File

@@ -7,18 +7,19 @@ import type { ComponentConfig } from '../types/utils'
type FormConfig = ComponentConfig<typeof theme, AppConfig, 'form'>
export interface FormProps<T extends object> {
export interface FormProps<I extends object, O extends object = I> {
id?: string | number
/** Schema to validate the form state. Supports Standard Schema objects, Yup, Joi, and Superstructs. */
schema?: FormSchema<T>
schema?: FormSchema<I, O>
/** An object representing the current state of the form. */
state: Partial<T>
state: Partial<I>
/**
* Custom validation function to validate the form state.
* @param state - The current state of the form.
* @returns A promise that resolves to an array of FormError objects, or an array of FormError objects directly.
*/
validate?: (state: Partial<T>) => Promise<FormError[]> | FormError[]
validate?: (state: Partial<I>) => Promise<FormError[]> | FormError[]
/**
* The list of input events that trigger the form validation.
* @defaultValue `['blur', 'change', 'input']`
@@ -31,11 +32,11 @@ export interface FormProps<T extends object> {
* @defaultValue `300`
*/
validateOnInputDelay?: number
/**
* If true, schema transformations will be applied to the state on submit.
* @defaultValue `true`
* If true and nested in another form, this form will attach to its parent and validate at the same time.
*/
transform?: boolean
nested?: boolean
/**
* When `true`, all form elements will be disabled on `@submit` event.
* This will cause any focused input elements to lose their focus state.
@@ -43,11 +44,11 @@ export interface FormProps<T extends object> {
*/
loadingAuto?: boolean
class?: any
onSubmit?: ((event: FormSubmitEvent<T>) => void | Promise<void>) | (() => void | Promise<void>)
onSubmit?: ((event: FormSubmitEvent<O>) => void | Promise<void>) | (() => void | Promise<void>)
}
export interface FormEmits<T extends object> {
(e: 'submit', payload: FormSubmitEvent<T>): void
export interface FormEmits<I extends object, O extends object = I> {
(e: 'submit', payload: FormSubmitEvent<O>): void
(e: 'error', payload: FormErrorEvent): void
}
@@ -56,7 +57,7 @@ export interface FormSlots {
}
</script>
<script lang="ts" setup generic="T extends object">
<script lang="ts" setup generic="I extends object, O extends object = I">
import { provide, inject, nextTick, ref, onUnmounted, onMounted, computed, useId, readonly } from 'vue'
import { useEventBus } from '@vueuse/core'
import { useAppConfig } from '#imports'
@@ -65,16 +66,15 @@ import { tv } from '../utils/tv'
import { validateSchema } from '../utils/form'
import { FormValidationException } from '../types/form'
const props = withDefaults(defineProps<FormProps<T>>(), {
const props = withDefaults(defineProps<FormProps<I, O>>(), {
validateOn() {
return ['input', 'blur', 'change'] as FormInputEvents[]
},
validateOnInputDelay: 300,
transform: true,
loadingAuto: true
})
const emits = defineEmits<FormEmits<T>>()
const emits = defineEmits<FormEmits<I, O>>()
defineSlots<FormSlots>()
const appConfig = useAppConfig() as FormConfig['AppConfig']
@@ -83,7 +83,7 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.form || {})
const formId = props.id ?? useId() as string
const bus = useEventBus<FormEvent<T>>(`form-${formId}`)
const bus = useEventBus<FormEvent<I>>(`form-${formId}`)
const parentBus = inject(
formBusInjectionKey,
undefined
@@ -126,14 +126,14 @@ onUnmounted(() => {
})
onMounted(async () => {
if (parentBus) {
if (props.nested && parentBus) {
await nextTick()
parentBus.emit({ type: 'attach', validate: _validate, formId })
}
})
onUnmounted(() => {
if (parentBus) {
if (props.nested && parentBus) {
parentBus.emit({ type: 'detach', formId })
}
})
@@ -141,12 +141,12 @@ onUnmounted(() => {
const errors = ref<FormErrorWithId[]>([])
provide('form-errors', errors)
const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({})
const inputs = ref<{ [P in keyof I]?: { id?: string, pattern?: RegExp } }>({})
provide(formInputsInjectionKey, inputs as any)
const dirtyFields = new Set<keyof T>()
const touchedFields = new Set<keyof T>()
const blurredFields = new Set<keyof T>()
const dirtyFields = new Set<keyof I>()
const touchedFields = new Set<keyof I>()
const blurredFields = new Set<keyof I>()
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
return errs.map(err => ({
@@ -155,7 +155,7 @@ function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
}))
}
const transformedState = ref<T | null>(null)
const transformedState = ref<I | null>(null)
async function getErrors(): Promise<FormErrorWithId[]> {
let errs = props.validate ? (await props.validate(props.state)) ?? [] : []
@@ -172,8 +172,8 @@ async function getErrors(): Promise<FormErrorWithId[]> {
return resolveErrorIds(errs)
}
async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof T)[]
async function _validate(opts: { name?: keyof I | (keyof I)[], silent?: boolean, nested?: boolean } = { silent: false, nested: true }): Promise<O | false> {
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof I)[]
const nestedValidatePromises = !names && opts.nested
? Array.from(nestedForms.value.values()).map(
@@ -209,11 +209,7 @@ async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean,
throw new FormValidationException(formId, errors.value, childErrors)
}
if (opts.transform) {
Object.assign(props.state, transformedState.value)
}
return props.state as T
return transformedState.value
}
const loading = ref(false)
@@ -225,7 +221,7 @@ async function onSubmitWrapper(payload: Event) {
const event = payload as FormSubmitEvent<any>
try {
event.data = await _validate({ nested: true, transform: props.transform })
event.data = await _validate({ nested: true })
await props.onSubmit?.(event)
dirtyFields.clear()
} catch (error) {
@@ -251,11 +247,11 @@ provide(formOptionsInjectionKey, computed(() => ({
validateOnInputDelay: props.validateOnInputDelay
})))
defineExpose<Form<T>>({
defineExpose<Form<I, O>>({
validate: _validate,
errors,
setErrors(errs: FormError[], name?: keyof T) {
setErrors(errs: FormError[], name?: keyof I) {
if (name) {
errors.value = errors.value
.filter(error => error.name !== name)
@@ -269,7 +265,7 @@ defineExpose<Form<T>>({
await onSubmitWrapper(new Event('submit'))
},
getErrors(name?: keyof T) {
getErrors(name?: keyof I) {
if (name) {
return errors.value.filter(err => err.name === name)
}
@@ -288,9 +284,9 @@ defineExpose<Form<T>>({
loading,
dirty: computed(() => !!dirtyFields.size),
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof T>>,
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof T>>,
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof T>>
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof I>>,
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof I>>,
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof I>>
})
</script>

View File

@@ -5,27 +5,27 @@ import type { ObjectSchema as YupObjectSchema } from 'yup'
import type { GetObjectField } from './utils'
import type { Struct as SuperstructSchema } from 'superstruct'
export interface Form<T extends object> {
validate (opts?: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise<T | false>
export interface Form<I extends object, O extends object = I> {
validate (opts?: { name?: keyof I | (keyof I)[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise<O | false>
clear (path?: string): void
errors: Ref<FormError[]>
setErrors (errs: FormError[], name?: keyof T): void
getErrors (name?: keyof T): FormError[]
setErrors (errs: FormError[], name?: keyof I): void
getErrors (name?: keyof I): FormError[]
submit (): Promise<void>
disabled: ComputedRef<boolean>
dirty: ComputedRef<boolean>
loading: Ref<boolean>
dirtyFields: DeepReadonly<Set<keyof T>>
touchedFields: DeepReadonly<Set<keyof T>>
blurredFields: DeepReadonly<Set<keyof T>>
dirtyFields: DeepReadonly<Set<keyof I>>
touchedFields: DeepReadonly<Set<keyof I>>
blurredFields: DeepReadonly<Set<keyof I>>
}
export type FormSchema<T extends object> =
| YupObjectSchema<T>
| JoiSchema<T>
| SuperstructSchema<any, any>
| StandardSchemaV1
export type FormSchema<I extends object, O extends object = I> =
| YupObjectSchema<I>
| JoiSchema<I>
| SuperstructSchema<I>
| StandardSchemaV1<I, O>
export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus'