diff --git a/docs/app/components/content/examples/form/FormExampleSuperstruct.vue b/docs/app/components/content/examples/form/FormExampleSuperstruct.vue new file mode 100644 index 00000000..2033ebb4 --- /dev/null +++ b/docs/app/components/content/examples/form/FormExampleSuperstruct.vue @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + Submit + + + diff --git a/docs/content/3.components/form.md b/docs/content/3.components/form.md index f348aec2..bf75bab8 100644 --- a/docs/content/3.components/form.md +++ b/docs/content/3.components/form.md @@ -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 ``{lang="vue"}. diff --git a/docs/package.json b/docs/package.json index ad9b0b7e..4d7717cc 100644 --- a/docs/package.json +++ b/docs/package.json @@ -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", diff --git a/package.json b/package.json index 6e5ed053..742b8061 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faee184c..496a0b05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index dffd1535..4f74671e 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -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>(), { @@ -113,6 +113,8 @@ async function getErrors(): Promise { 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 { diff --git a/src/runtime/types/form.ts b/src/runtime/types/form.ts index 48963fa8..662930c6 100644 --- a/src/runtime/types/form.ts +++ b/src/runtime/types/form.ts @@ -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 { validate (opts?: { name: string | string[], silent?: false, nested?: boolean }): Promise @@ -19,9 +20,12 @@ export interface Form { export type FormSchema> = | ZodSchema | YupObjectSchema - | ValibotSchema | ValibotSchemaAsync - | ValibotSafeParser | ValibotSafeParserAsync + | ValibotSchema + | ValibotSchemaAsync + | ValibotSafeParser + | ValibotSafeParserAsync | JoiSchema + | SuperstructSchema | StandardSchema export type FormInputEvents = 'input' | 'blur' | 'change' diff --git a/src/runtime/utils/form.ts b/src/runtime/utils/form.ts index 23fb5f64..7cf1f6c1 100644 --- a/src/runtime/utils/form.ts +++ b/src/runtime/utils/form.ts @@ -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 { @@ -29,6 +30,15 @@ export async function getYupErrors(state: any, schema: YupObjectSchema): Pr } } +export function isSuperStructSchema(schema: any): schema is Struct { + 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): Promise { + const [err] = schema.validate(state) + if (err) { + const errors = err.failures() + return errors.map(error => ({ + message: error.message, + name: error.path.join('.') + })) + } + return [] +} diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts index 1650d688..79b398ec 100644 --- a/test/components/Form.spec.ts +++ b/test/components/Form.spec.ts @@ -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 = [] diff --git a/test/components/__snapshots__/Form.spec.ts.snap b/test/components/__snapshots__/Form.spec.ts.snap index 7de8cccd..afe13714 100644 --- a/test/components/__snapshots__/Form.spec.ts.snap +++ b/test/components/__snapshots__/Form.spec.ts.snap @@ -128,6 +128,68 @@ exports[`Form > renders with default slot correctly 1`] = `" renders with state correctly 1`] = `""`; +exports[`Form > superstruct validation works > with error 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + Must be at least 8 characters + + +" +`; + +exports[`Form > superstruct validation works > without error 1`] = ` +" + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; + exports[`Form > valibot safeParser validation works > with error 1`] = ` "
Must be at least 8 characters