mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(Form): add superstruct validation (#2363)
Co-authored-by: Benjamin Canac <canacb1@gmail.com> Co-authored-by: Romain Hamel <rom.hml@gmail.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { object, string, nonempty, refine, type Infer } from 'superstruct'
|
||||
import type { FormSubmitEvent } from '#ui/types'
|
||||
|
||||
const schema = object({
|
||||
email: nonempty(string()),
|
||||
password: refine(string(), 'Password', (value) => {
|
||||
if (value.length >= 8) return true
|
||||
return 'Must be at least 8 characters'
|
||||
})
|
||||
})
|
||||
|
||||
const state = reactive({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
type Schema = Infer<typeof schema>
|
||||
|
||||
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||
console.log(event.data)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||
<UFormField label="Email" name="email">
|
||||
<UInput v-model="state.email" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Password" name="password">
|
||||
<UInput v-model="state.password" type="password" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit">
|
||||
Submit
|
||||
</UButton>
|
||||
</UForm>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ links:
|
||||
|
||||
## Usage
|
||||
|
||||
Use the Form component to validate form data using schema libraries such as [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Joi](https://github.com/hapijs/joi), [Valibot](https://github.com/fabian-hiller/valibot), or your own validation logic.
|
||||
Use the Form component to validate form data using schema libraries such as [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Joi](https://github.com/hapijs/joi), [Valibot](https://github.com/fabian-hiller/valibot), [Superstruct](https://github.com/ianstormtaylor/superstruct) or your own validation logic.
|
||||
|
||||
It works with the [FormField](/components/form-field) component to display error messages around form elements automatically.
|
||||
|
||||
@@ -16,7 +16,7 @@ It works with the [FormField](/components/form-field) component to display error
|
||||
|
||||
It requires two props:
|
||||
- `state` - a reactive object holding the form's state.
|
||||
- `schema` - a schema object from a validation library like [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Joi](https://github.com/hapijs/joi) or [Valibot](https://github.com/fabian-hiller/valibot).
|
||||
- `schema` - a schema object from a validation library like [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Joi](https://github.com/hapijs/joi), [Valibot](https://github.com/fabian-hiller/valibot) or [Superstruct](https://github.com/ianstormtaylor/superstruct).
|
||||
|
||||
::warning
|
||||
**No validation library is included** by default, ensure you **install the one you need**.
|
||||
@@ -54,6 +54,14 @@ It requires two props:
|
||||
class: 'w-60'
|
||||
---
|
||||
::
|
||||
|
||||
::component-example{label="Superstruct"}
|
||||
---
|
||||
name: 'form-example-superstruct'
|
||||
props:
|
||||
class: 'w-60'
|
||||
---
|
||||
::
|
||||
::
|
||||
|
||||
Errors are reported directly to the [FormField](/components/form-field) component based on the `name` prop. This means the validation rules defined for the `email` attribute in your schema will be applied to `<FormField name="email">`{lang="vue"}.
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"nuxt-og-image": "^3.0.6",
|
||||
"prettier": "^3.3.3",
|
||||
"shiki-transformer-color-highlight": "^0.2.0",
|
||||
"superstruct": "^2.0.2",
|
||||
"ufo": "^1.5.4",
|
||||
"valibot": "^0.42.1",
|
||||
"yup": "^1.4.0",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"joi": "^17.13.3",
|
||||
"nuxt": "^3.13.2",
|
||||
"release-it": "^17.10.0",
|
||||
"superstruct": "^2.0.2",
|
||||
"valibot": "^0.42.1",
|
||||
"vitest": "^2.1.3",
|
||||
"vitest-environment-nuxt": "^1.0.1",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -125,6 +125,9 @@ importers:
|
||||
release-it:
|
||||
specifier: ^17.10.0
|
||||
version: 17.10.0(typescript@5.6.3)
|
||||
superstruct:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
valibot:
|
||||
specifier: ^0.42.1
|
||||
version: 0.42.1(typescript@5.6.3)
|
||||
@@ -212,6 +215,9 @@ importers:
|
||||
shiki-transformer-color-highlight:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
superstruct:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
ufo:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -6184,6 +6190,10 @@ packages:
|
||||
resolution: {integrity: sha512-8iGv75BYOa0xRJHK5vRLEjE2H/i4lulTjzpUXic3Eg8akftYjkmQDa8JARQ42rlczXyFR3IeRoeFCc7RxHsYZA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
superstruct@2.0.2:
|
||||
resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
supports-color@5.5.0:
|
||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -14036,6 +14046,8 @@ snapshots:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
superstruct@2.0.2: {}
|
||||
|
||||
supports-color@5.5.0:
|
||||
dependencies:
|
||||
has-flag: 3.0.0
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface FormSlots {
|
||||
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 } from '../utils/form'
|
||||
import { getYupErrors, isYupSchema, getValibotErrors, isValibotSchema, getZodErrors, isZodSchema, getJoiErrors, isJoiSchema, getStandardErrors, isStandardSchema, getSuperStructErrors, isSuperStructSchema } from '../utils/form'
|
||||
import { FormValidationException } from '../types/form'
|
||||
|
||||
const props = withDefaults(defineProps<FormProps<T>>(), {
|
||||
@@ -113,6 +113,8 @@ async function getErrors(): Promise<FormErrorWithId[]> {
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Schema as JoiSchema } from 'joi'
|
||||
import type { ObjectSchema as YupObjectSchema } from 'yup'
|
||||
import type { GenericSchema as ValibotSchema, GenericSchemaAsync as ValibotSchemaAsync, SafeParser as ValibotSafeParser, SafeParserAsync as ValibotSafeParserAsync } from 'valibot'
|
||||
import type { GetObjectField } from './utils'
|
||||
import type { Struct as SuperstructSchema } from 'superstruct'
|
||||
|
||||
export interface Form<T> {
|
||||
validate (opts?: { name: string | string[], silent?: false, nested?: boolean }): Promise<T | false>
|
||||
@@ -19,9 +20,12 @@ export interface Form<T> {
|
||||
export type FormSchema<T extends Record<string, any>> =
|
||||
| ZodSchema
|
||||
| YupObjectSchema<T>
|
||||
| ValibotSchema | ValibotSchemaAsync
|
||||
| ValibotSafeParser<any, any> | ValibotSafeParserAsync<any, any>
|
||||
| ValibotSchema
|
||||
| ValibotSchemaAsync
|
||||
| ValibotSafeParser<any, any>
|
||||
| ValibotSafeParserAsync<any, any>
|
||||
| JoiSchema<T>
|
||||
| SuperstructSchema<any, any>
|
||||
| StandardSchema
|
||||
|
||||
export type FormInputEvents = 'input' | 'blur' | 'change'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ZodSchema } from 'zod'
|
||||
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'
|
||||
|
||||
export function isYupSchema(schema: any): schema is YupObjectSchema<any> {
|
||||
@@ -29,6 +30,15 @@ export async function getYupErrors(state: any, schema: YupObjectSchema<any>): Pr
|
||||
}
|
||||
}
|
||||
|
||||
export function isSuperStructSchema(schema: any): schema is Struct<any, any> {
|
||||
return (
|
||||
'schema' in schema
|
||||
&& typeof schema.coercer === 'function'
|
||||
&& typeof schema.validator === 'function'
|
||||
&& typeof schema.refiner === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function isZodSchema(schema: any): schema is ZodSchema {
|
||||
return schema.parse !== undefined
|
||||
}
|
||||
@@ -98,3 +108,15 @@ export async function getStandardErrors(
|
||||
message: issue.message
|
||||
})) || []
|
||||
}
|
||||
|
||||
export async function getSuperStructErrors(state: any, schema: Struct<any, any>): Promise<FormError[]> {
|
||||
const [err] = schema.validate(state)
|
||||
if (err) {
|
||||
const errors = err.failures()
|
||||
return errors.map(error => ({
|
||||
message: error.message,
|
||||
name: error.path.join('.')
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod'
|
||||
import * as yup from 'yup'
|
||||
import Joi from 'joi'
|
||||
import * as valibot from 'valibot'
|
||||
import { object, string, nonempty, refine } from 'superstruct'
|
||||
import ComponentRender from '../component-render'
|
||||
import type { FormProps, FormSlots } from '../../src/runtime/components/Form.vue'
|
||||
import { renderForm } from '../utils/form'
|
||||
@@ -65,6 +66,15 @@ describe('Form', () => {
|
||||
}))
|
||||
}
|
||||
],
|
||||
['superstruct', {
|
||||
schema: object({
|
||||
email: nonempty(string()),
|
||||
password: refine(string(), 'Password', (value) => {
|
||||
if (value.length >= 8) return true
|
||||
return 'Must be at least 8 characters'
|
||||
})
|
||||
})
|
||||
}],
|
||||
['custom', {
|
||||
async validate(state: any) {
|
||||
const errs = []
|
||||
|
||||
@@ -128,6 +128,68 @@ exports[`Form > renders with default slot correctly 1`] = `"<form id="v-0-0" cla
|
||||
|
||||
exports[`Form > renders with state correctly 1`] = `"<form id="v-0-0" class=""></form>"`;
|
||||
|
||||
exports[`Form > superstruct validation works > with error 1`] = `
|
||||
"<form id="42" class="">
|
||||
<div class="text-sm">
|
||||
<div class="">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="relative inline-flex items-center"><input id="email" type="text" name="email" class="w-full rounded-[calc(var(--ui-radius)*1.5)] border-0 placeholder:text-[var(--ui-text-dimmed)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-[var(--ui-text-highlighted)] bg-[var(--ui-bg)] ring ring-inset ring-[var(--ui-border-accented)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ui-primary)]" autocomplete="off" value="bob@dylan.com">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="relative inline-flex items-center"><input id="password" type="text" name="password" autocomplete="off" class="w-full rounded-[calc(var(--ui-radius)*1.5)] border-0 placeholder:text-[var(--ui-text-dimmed)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-[var(--ui-text-highlighted)] bg-[var(--ui-bg)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ui-error)] ring ring-inset ring-[var(--ui-error)]" value="short">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--ui-error)]">Must be at least 8 characters</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>"
|
||||
`;
|
||||
|
||||
exports[`Form > superstruct validation works > without error 1`] = `
|
||||
"<form id="42" class="">
|
||||
<div class="text-sm">
|
||||
<div class="">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="relative inline-flex items-center"><input id="email" type="text" name="email" class="w-full rounded-[calc(var(--ui-radius)*1.5)] border-0 placeholder:text-[var(--ui-text-dimmed)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-[var(--ui-text-highlighted)] bg-[var(--ui-bg)] ring ring-inset ring-[var(--ui-border-accented)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ui-primary)]" autocomplete="off" value="bob@dylan.com">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div class="">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<div class="">
|
||||
<div class="relative inline-flex items-center"><input id="password" type="text" name="password" autocomplete="off" class="w-full rounded-[calc(var(--ui-radius)*1.5)] border-0 placeholder:text-[var(--ui-text-dimmed)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors px-2.5 py-1.5 text-sm gap-1.5 text-[var(--ui-text-highlighted)] bg-[var(--ui-bg)] ring ring-inset ring-[var(--ui-border-accented)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ui-primary)]" value="validpassword">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</form>"
|
||||
`;
|
||||
|
||||
exports[`Form > valibot safeParser validation works > with error 1`] = `
|
||||
"<form id="42" class="">
|
||||
<div class="text-sm">
|
||||
|
||||
Reference in New Issue
Block a user