From a3aba1abadd569b69f15697bcc5908b49e0a7f8a Mon Sep 17 00:00:00 2001 From: Romain Hamel Date: Mon, 31 Jul 2023 15:22:14 +0200 Subject: [PATCH] feat(Form): new component (#439) Co-authored-by: Benjamin Canac --- .../content/examples/FormExampleBasic.vue | 44 +++ .../content/examples/FormExampleElements.vue | 100 ++++++ .../content/examples/FormExampleJoi.vue | 44 +++ .../content/examples/FormExampleYup.vue | 47 +++ .../content/examples/FormExampleZod.vue | 45 +++ docs/content/3.forms/10.form.md | 325 ++++++++++++++++++ docs/package.json | 5 +- package.json | 5 +- pnpm-lock.yaml | 43 +++ src/runtime/components/forms/Checkbox.vue | 14 +- src/runtime/components/forms/Form.ts | 160 +++++++++ src/runtime/components/forms/FormGroup.ts | 14 +- src/runtime/components/forms/Input.vue | 14 +- src/runtime/components/forms/Radio.vue | 9 +- src/runtime/components/forms/Range.vue | 16 +- src/runtime/components/forms/Select.vue | 14 +- src/runtime/components/forms/SelectMenu.vue | 7 +- src/runtime/components/forms/Textarea.vue | 20 +- src/runtime/components/forms/Toggle.vue | 4 + src/runtime/composables/useFormEvents.ts | 18 + src/runtime/types/form.d.ts | 13 + src/runtime/types/index.d.ts | 1 + 22 files changed, 945 insertions(+), 17 deletions(-) create mode 100644 docs/components/content/examples/FormExampleBasic.vue create mode 100644 docs/components/content/examples/FormExampleElements.vue create mode 100644 docs/components/content/examples/FormExampleJoi.vue create mode 100644 docs/components/content/examples/FormExampleYup.vue create mode 100644 docs/components/content/examples/FormExampleZod.vue create mode 100644 docs/content/3.forms/10.form.md create mode 100644 src/runtime/components/forms/Form.ts create mode 100644 src/runtime/composables/useFormEvents.ts create mode 100644 src/runtime/types/form.d.ts diff --git a/docs/components/content/examples/FormExampleBasic.vue b/docs/components/content/examples/FormExampleBasic.vue new file mode 100644 index 00000000..d60d356d --- /dev/null +++ b/docs/components/content/examples/FormExampleBasic.vue @@ -0,0 +1,44 @@ + + + diff --git a/docs/components/content/examples/FormExampleElements.vue b/docs/components/content/examples/FormExampleElements.vue new file mode 100644 index 00000000..b399f3a9 --- /dev/null +++ b/docs/components/content/examples/FormExampleElements.vue @@ -0,0 +1,100 @@ + + + diff --git a/docs/components/content/examples/FormExampleJoi.vue b/docs/components/content/examples/FormExampleJoi.vue new file mode 100644 index 00000000..7519a90f --- /dev/null +++ b/docs/components/content/examples/FormExampleJoi.vue @@ -0,0 +1,44 @@ + + + diff --git a/docs/components/content/examples/FormExampleYup.vue b/docs/components/content/examples/FormExampleYup.vue new file mode 100644 index 00000000..44b11447 --- /dev/null +++ b/docs/components/content/examples/FormExampleYup.vue @@ -0,0 +1,47 @@ + + + diff --git a/docs/components/content/examples/FormExampleZod.vue b/docs/components/content/examples/FormExampleZod.vue new file mode 100644 index 00000000..5053f7ea --- /dev/null +++ b/docs/components/content/examples/FormExampleZod.vue @@ -0,0 +1,45 @@ + + + diff --git a/docs/content/3.forms/10.form.md b/docs/content/3.forms/10.form.md new file mode 100644 index 00000000..6a180296 --- /dev/null +++ b/docs/content/3.forms/10.form.md @@ -0,0 +1,325 @@ +--- +description: Collect and validate form data. +links: + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxtlabs/ui/blob/dev/src/runtime/components/forms/Form.ts +navigation: + badge: Edge +--- + +## Usage + +Use the Form component to validate form data using schema libraries such as [Yup](https://github.com/jquense/yup), [Zod](https://github.com/colinhacks/zod), [Joi](https://github.com/hapijs/joi) or your own validation logic. It works seamlessly with the FormGroup component to automatically display error messages around form elements. + +The Form component requires the `validate` and `state` props for form validation. + +- `state` - a reactive object that holds the current state of the form. +- `validate` - a function that takes the form's state as input and returns an array of `FormError` objects with the following fields: + - `message` - the error message to display. + - `path` - the path to the form element matching the `name`. + +::component-example +#default +:form-example-basic{class="space-y-4 w-60"} + +#code +```vue + + + +``` +:: + +## Schema + +You can provide a schema from [Yup](#yup), [Zod](#zod) or [Joi](#joi) through the `schema` prop to validate the state. It's important to note that **none of these libraries are included** by default, so make sure to **install the one you need**. + +### Yup + +::component-example +#default +:form-example-yup{class="space-y-4 w-60"} + +#code +```vue + + + +``` +:: + +### Zod + +::component-example +#default +:form-example-zod{class="space-y-4 w-60"} + +#code +```vue + + + +``` +:: + +### Joi + +::component-example +#default +:form-example-joi{class="space-y-4 w-60"} + +#code +```vue + + + +``` +:: + +## Type Inference + +You can utilize Zod and Yup's type inference feature to automatically infer the types of your schema and form data. This allows for strong type checking and better code validation, reducing the likelihood of errors. + +```vue + +// [...] +``` + +## Other libraries + +For other validation libraries, you can define your own component with custom validation logic. + +Here is an example with [Vuelidate](https://github.com/vuelidate/vuelidate): + +```vue + + + +``` + +## Input events + +The Form component automatically triggers validation upon input `blur` or `change` events. This ensures that any errors are displayed as soon as the user interacts with the form elements. + +::component-example +#default +:form-example-elements{class="space-y-4 w-60"} +:: + +## Props + +:component-props diff --git a/docs/package.json b/docs/package.json index 77030e84..50164bd7 100644 --- a/docs/package.json +++ b/docs/package.json @@ -21,6 +21,9 @@ "nuxt-component-meta": "^0.5.3", "nuxt-lodash": "^2.5.0", "typescript": "^5.1.6", - "v-calendar": "^3.0.3" + "v-calendar": "^3.0.3", + "yup": "^1.2.0", + "joi": "^17.9.2", + "zod": "^3.21.4" } } diff --git a/package.json b/package.json index bd181395..02e1fd77 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "release-it": "^16.1.3", "typescript": "^5.1.6", "unbuild": "^1.2.1", - "vue-tsc": "^1.8.8" + "vue-tsc": "^1.8.8", + "yup": "^1.2.0", + "joi": "^17.9.2", + "zod": "^3.21.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 740ba673..1f961899 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: eslint: specifier: ^8.45.0 version: 8.45.0 + joi: + specifier: ^17.9.2 + version: 17.9.2 nuxt: specifier: ^3.6.5 version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8) @@ -86,6 +89,12 @@ importers: vue-tsc: specifier: ^1.8.8 version: 1.8.8(typescript@5.1.6) + yup: + specifier: ^1.2.0 + version: 1.2.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 docs: dependencies: @@ -129,6 +138,9 @@ importers: eslint: specifier: ^8.45.0 version: 8.45.0 + joi: + specifier: ^17.9.2 + version: 17.9.2 nuxt: specifier: ^3.6.5 version: 3.6.5(@types/node@20.4.5)(eslint@8.45.0)(rollup@3.26.2)(typescript@5.1.6)(vue-tsc@1.8.8) @@ -144,6 +156,12 @@ importers: v-calendar: specifier: ^3.0.3 version: 3.0.3(@popperjs/core@2.11.8)(vue@3.3.4) + yup: + specifier: ^1.2.0 + version: 1.2.0 + zod: + specifier: ^3.21.4 + version: 3.21.4 packages: @@ -9677,6 +9695,10 @@ packages: sisteransi: 1.0.5 dev: true + /property-expr@2.0.5: + resolution: {integrity: sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==} + dev: true + /property-information@6.2.0: resolution: {integrity: sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==} dev: true @@ -11002,6 +11024,10 @@ packages: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true + /tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + dev: true + /tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} dev: true @@ -11040,6 +11066,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + /toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + dev: true + /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -12388,6 +12418,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + /yup@1.2.0: + resolution: {integrity: sha512-PPqYKSAXjpRCgLgLKVGPA33v5c/WgEx3wi6NFjIiegz90zSwyMpvTFp/uGcVnnbx6to28pgnzp/q8ih3QRjLMQ==} + dependencies: + property-expr: 2.0.5 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: true + /zhead@2.0.9: resolution: {integrity: sha512-Y3g6EegQc6PVrYXPq2OS7/s27UGVS5Y6NY6SY3XGH4Hg+yQWbQTtWsjCgmpR8kZnYrv8auB54sz+x5FEDrvqzQ==} dev: true @@ -12401,6 +12440,10 @@ packages: readable-stream: 3.6.2 dev: true + /zod@3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + dev: true + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: true diff --git a/src/runtime/components/forms/Checkbox.vue b/src/runtime/components/forms/Checkbox.vue index 99b3396e..7bfa57f2 100644 --- a/src/runtime/components/forms/Checkbox.vue +++ b/src/runtime/components/forms/Checkbox.vue @@ -14,6 +14,7 @@ class="form-checkbox" :class="inputClass" v-bind="$attrs" + @change="onChange" >
@@ -33,6 +34,7 @@ import { computed, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' import { classNames } from '../../utils' +import { useFormEvents } from '../../composables/useFormEvents' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error @@ -91,13 +93,15 @@ export default defineComponent({ default: () => appConfig.ui.checkbox } }, - emits: ['update:modelValue'], + emits: ['update:modelValue', 'change'], setup (props, { emit }) { // TODO: Remove const appConfig = useAppConfig() const ui = computed>(() => defu({}, props.ui, appConfig.ui.checkbox)) + const { emitFormBlur } = useFormEvents() + const toggle = computed({ get () { return props.modelValue @@ -107,6 +111,11 @@ export default defineComponent({ } }) + const onChange = (event: Event) => { + emit('change', event) + emitFormBlur() + } + const inputClass = computed(() => { return classNames( ui.value.base, @@ -122,7 +131,8 @@ export default defineComponent({ // eslint-disable-next-line vue/no-dupe-keys ui, toggle, - inputClass + inputClass, + onChange } } }) diff --git a/src/runtime/components/forms/Form.ts b/src/runtime/components/forms/Form.ts new file mode 100644 index 00000000..9a5c8b38 --- /dev/null +++ b/src/runtime/components/forms/Form.ts @@ -0,0 +1,160 @@ +import { provide, ref, type PropType, h, defineComponent } from 'vue' +import { useEventBus } from '@vueuse/core' +import type { ZodSchema, ZodError } from 'zod' +import type { ValidationError as JoiError, Schema as JoiSchema } from 'joi' +import type { ObjectSchema as YupObjectSchema, ValidationError as YupError } from 'yup' +import type { FormError, FormEvent } from '../../types' + +export default defineComponent({ + props: { + schema: { + type: Object as + | PropType + | PropType> + | PropType, + default: undefined + }, + state: { + type: Object, + required: true + }, + validate: { + type: Function as PropType<(state: any) => Promise> | PropType<(state: any) => FormError[]>, + default: () => [] + } + }, + setup (props, { slots, expose }) { + const seed = Math.random().toString(36).substring(7) + const bus = useEventBus(`form-${seed}`) + + bus.on(async (event) => { + if (event.type === 'blur') { + const otherErrors = errors.value.filter( + (error) => error.path !== event.path + ) + const pathErrors = (await getErrors()).filter( + (error) => error.path === event.path + ) + errors.value = otherErrors.concat(pathErrors) + } + }) + + const errors = ref([]) + provide('form-errors', errors) + provide('form-events', bus) + + async function getErrors (): Promise { + let errs = 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 { + throw new Error('Form validation failed: Unsupported form schema') + } + } + + return errs + } + + async function validate () { + errors.value = await getErrors() + if (errors.value.length > 0) { + throw new Error( + `Form validation failed: ${JSON.stringify(errors.value, null, 2)}` + ) + } + } + + expose({ + validate + }) + + return () => h('form', slots.default?.()) + } +}) + +function isYupSchema (schema: any): schema is YupObjectSchema { + return schema.validate && schema.__isYupSchema__ +} + +function isYupError (error: any): error is YupError { + return error.inner !== undefined +} + +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) => ({ + path: issue.path ?? '', + message: issue.message + })) + } else { + throw error + } + } +} + +function isZodSchema (schema: any): schema is ZodSchema { + return schema.parse !== undefined +} + +function isZodError (error: any): error is ZodError { + return error.issues !== undefined +} + +async function getZodErrors ( + state: any, + schema: ZodSchema +): Promise { + try { + schema.parse(state) + return [] + } catch (error) { + if (isZodError(error)) { + return error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message + })) + } else { + throw error + } + } +} + +function isJoiSchema (schema: any): schema is JoiSchema { + return schema.validateAsync !== undefined && schema.id !== undefined +} + +function isJoiError (error: any): error is JoiError { + return error.isJoi === true +} + +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) => ({ + path: detail.path.join('.'), + message: detail.message + })) + } else { + throw error + } + } +} diff --git a/src/runtime/components/forms/FormGroup.ts b/src/runtime/components/forms/FormGroup.ts index 2aad8d3b..ad6a9901 100644 --- a/src/runtime/components/forms/FormGroup.ts +++ b/src/runtime/components/forms/FormGroup.ts @@ -2,7 +2,9 @@ import { h, cloneVNode, computed, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' import { getSlotsChildren } from '../../utils' +import type { FormError } from '../../types' import { useAppConfig } from '#imports' + // TODO: Remove // @ts-expect-error import appConfig from '#build/app.config' @@ -57,12 +59,20 @@ export default defineComponent({ const ui = computed>(() => defu({}, props.ui, appConfig.ui.formGroup)) + provide('form-path', props.name) + const formErrors = inject | null>('form-errors', null) + const errorMessage = computed(() => { + return props.error && typeof props.error === 'string' + ? props.error + : formErrors?.value?.find((error) => error.path === props.name)?.message + }) + const children = computed(() => getSlotsChildren(slots)) const clones = computed(() => children.value.map((node) => { const vProps: any = {} - if (props.error) { + if (errorMessage.value) { vProps.oldColor = node.props?.color vProps.color = 'red' } else if (vProps.oldColor) { @@ -89,7 +99,7 @@ export default defineComponent({ ] }, props.description), h('div', { class: [!!props.label && ui.value.container] }, [ ...clones.value, - props.error && typeof props.error === 'string' ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, props.error) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null + errorMessage.value ? h('p', { class: [ui.value.error, ui.value.size[props.size]] }, errorMessage.value) : props.help ? h('p', { class: [ui.value.help, ui.value.size[props.size]] }, props.help) : null ]) ]) } diff --git a/src/runtime/components/forms/Input.vue b/src/runtime/components/forms/Input.vue index c992cdeb..0ac13efc 100644 --- a/src/runtime/components/forms/Input.vue +++ b/src/runtime/components/forms/Input.vue @@ -13,6 +13,7 @@ :class="inputClass" v-bind="$attrs" @input="onInput" + @blur="onBlur" > @@ -35,6 +36,7 @@ import { ref, computed, onMounted, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' import UIcon from '../elements/Icon.vue' +import { useFormEvents } from '../../composables/useFormEvents' import { classNames } from '../../utils' import { useAppConfig } from '#imports' // TODO: Remove @@ -138,13 +140,15 @@ export default defineComponent({ default: () => appConfig.ui.input } }, - emits: ['update:modelValue'], + emits: ['update:modelValue', 'blur'], setup (props, { emit, slots }) { // TODO: Remove const appConfig = useAppConfig() const ui = computed>(() => defu({}, props.ui, appConfig.ui.input)) + const { emitFormBlur } = useFormEvents() + const input = ref(null) const autoFocus = () => { @@ -157,6 +161,11 @@ export default defineComponent({ emit('update:modelValue', (event.target as HTMLInputElement).value) } + const onBlur = (event: FocusEvent) => { + emitFormBlur() + emit('blur', event) + } + onMounted(() => { setTimeout(() => { autoFocus() @@ -249,7 +258,8 @@ export default defineComponent({ trailingIconName, trailingIconClass, trailingWrapperIconClass, - onInput + onInput, + onBlur } } }) diff --git a/src/runtime/components/forms/Radio.vue b/src/runtime/components/forms/Radio.vue index 3d401af4..650c6158 100644 --- a/src/runtime/components/forms/Radio.vue +++ b/src/runtime/components/forms/Radio.vue @@ -12,6 +12,7 @@ class="form-radio" :class="inputClass" v-bind="$attrs" + @change="onChange" >
@@ -31,6 +32,7 @@ import { computed, defineComponent } from 'vue' import type { PropType } from 'vue' import { defu } from 'defu' import { classNames } from '../../utils' +import { useFormEvents } from '../../composables/useFormEvents' import { useAppConfig } from '#imports' // TODO: Remove // @ts-expect-error @@ -81,19 +83,24 @@ export default defineComponent({ default: () => appConfig.ui.radio } }, - emits: ['update:modelValue'], + emits: ['update:modelValue', 'change'], setup (props, { emit }) { // TODO: Remove const appConfig = useAppConfig() const ui = computed>(() => defu({}, props.ui, appConfig.ui.radio)) + const { emitFormBlur } = useFormEvents() + const pick = computed({ get () { return props.modelValue }, set (value) { emit('update:modelValue', value) + if (value) { + emitFormBlur() + } } }) diff --git a/src/runtime/components/forms/Range.vue b/src/runtime/components/forms/Range.vue index 41da29cf..7d59fafa 100644 --- a/src/runtime/components/forms/Range.vue +++ b/src/runtime/components/forms/Range.vue @@ -1,4 +1,4 @@ -