diff --git a/docs/framework/angular/guides/validation.md b/docs/framework/angular/guides/validation.md
index b50560561..d5b2f44b2 100644
--- a/docs/framework/angular/guides/validation.md
+++ b/docs/framework/angular/guides/validation.md
@@ -469,6 +469,38 @@ export class AppComponent {
}
```
+### Form Level Adapter Validation
+
+You can also use the adapter at the form level:
+
+```typescript
+import { zodValidator } from '@tanstack/zod-form-adapter'
+import { z } from 'zod'
+
+// ...
+
+const form = injectForm({
+ validatorAdapter: zodValidator(),
+ validators: {
+ onChange: z.object({
+ age: z.number().gte(13, 'You must be 13 to make an account'),
+ }),
+ },
+})
+```
+
+If you use the adapter at the form level, it will pass the validation to the fields of the same name.
+
+This means that:
+
+```html
+
+
+
+```
+
+Will still display the error message from the form-level validation.
+
## Preventing invalid forms from being submitted
The `onChange`, `onBlur` etc... callbacks are also run when the form is submitted and the submission is blocked if the form is invalid.
diff --git a/docs/framework/react/guides/validation.md b/docs/framework/react/guides/validation.md
index e5bfdc295..0f3a9833d 100644
--- a/docs/framework/react/guides/validation.md
+++ b/docs/framework/react/guides/validation.md
@@ -477,6 +477,42 @@ These adapters also support async operations using the proper property names:
/>
```
+### Form Level Adapter Validation
+
+You can also use the adapter at the form level:
+
+```tsx
+import { zodValidator } from '@tanstack/zod-form-adapter'
+import { z } from 'zod'
+
+// ...
+
+const form = useForm({
+ validatorAdapter: zodValidator(),
+ validators: {
+ onChange: z.object({
+ age: z.number().gte(13, 'You must be 13 to make an account'),
+ }),
+ },
+})
+```
+
+If you use the adapter at the form level, it will pass the validation to the fields of the same name.
+
+This means that:
+
+```tsx
+
+
{
+ return <>{/* ... */}>
+ }}
+/>
+```
+
+Will still display the error message from the form-level validation.
+
## Preventing invalid forms from being submitted
The `onChange`, `onBlur` etc... callbacks are also run when the form is submitted and the submission is blocked if the form is invalid.
diff --git a/docs/framework/solid/guides/validation.md b/docs/framework/solid/guides/validation.md
index 354b721bb..50ef40a7a 100644
--- a/docs/framework/solid/guides/validation.md
+++ b/docs/framework/solid/guides/validation.md
@@ -376,6 +376,44 @@ These adapters also support async operations using the proper property names:
/>
```
+### Form Level Adapter Validation
+
+
+You can also use the adapter at the form level:
+
+```tsx
+import { zodValidator } from '@tanstack/zod-form-adapter'
+import { z } from 'zod'
+
+// ...
+
+const form = createForm(() => ({
+ validatorAdapter: zodValidator(),
+ validators: {
+ onChange: z.object({
+ age: z.number().gte(13, 'You must be 13 to make an account'),
+ }),
+ },
+}))
+```
+
+If you use the adapter at the form level, it will pass the validation to the fields of the same name.
+
+This means that:
+
+```tsx
+
+ {
+ return <>{/* ... */}>
+ }}
+/>
+```
+
+Will still display the error message from the form-level validation.
+
+
## Preventing invalid forms from being submitted
The `onChange`, `onBlur` etc... callbacks are also run when the form is submitted and the submission is blocked if the form is invalid.
diff --git a/docs/framework/vue/guides/validation.md b/docs/framework/vue/guides/validation.md
index 6819a2d6f..a9aea2c5f 100644
--- a/docs/framework/vue/guides/validation.md
+++ b/docs/framework/vue/guides/validation.md
@@ -18,20 +18,20 @@ Here is an example:
```vue
-
Age:
- field.handleChange((e.target as HTMLInputElement).valueAsNumber)
- "
+ "
/>
{{ field.state.meta.errors.join(', ') }}
@@ -45,8 +45,8 @@ In the example above, the validation is done at each keystroke (`onChange`). If,
``` vue
-
@@ -54,14 +54,14 @@ In the example above, the validation is done at each keystroke (`onChange`). If,
Age:
- field.handleChange((e.target as HTMLInputElement).valueAsNumber)
- "
+ "
/>
{{ field.state.meta.errors.join(', ') }}
@@ -75,8 +75,8 @@ So you can control when the validation is done by implementing the desired callb
```vue
- Age:
- field.handleChange((e.target as HTMLInputElement).valueAsNumber)
- "
+ "
/>
{{ field.state.meta.errors.join(', ') }}
@@ -110,8 +110,8 @@ Once you have your validation in place, you can map the errors from an array to
```vue
-
@@ -129,8 +129,8 @@ Or use the `errorMap` property to access the specific error you're looking for:
```vue
-
@@ -205,20 +205,20 @@ const onChangeAge = async ({ value }) => {
-
Age:
- field.handleChange((e.target as HTMLInputElement).valueAsNumber)
- "
+ "
/>
{{ field.state.meta.errors.join(', ') }}
@@ -243,8 +243,8 @@ const onBlurAgeAsync = async ({ value }) => {
-
Age:
- field.handleChange((e.target as HTMLInputElement).valueAsNumber)
- "
+ "
/>
{{ field.state.meta.errors.join(', ') }}
@@ -279,8 +279,8 @@ Instead, we enable an easy method for debouncing your `async` calls by adding a
```vue
-
-
+
+
+
+
+
+```
+
+Will still display the error message from the form-level validation.
+
## Preventing invalid forms from being submitted
The `onChange`, `onBlur` etc... callbacks are also run when the form is submitted and the submission is blocked if the form is invalid.
diff --git a/examples/react/field-errors-from-form-validators/src/index.tsx b/examples/react/field-errors-from-form-validators/src/index.tsx
index ce3d9aa79..462661849 100644
--- a/examples/react/field-errors-from-form-validators/src/index.tsx
+++ b/examples/react/field-errors-from-form-validators/src/index.tsx
@@ -111,7 +111,10 @@ export default function App() {
children={([errorMap]) =>
errorMap.onSubmit ? (
- There was an error on the form: {errorMap.onSubmit}
+
+ There was an error on the form:{' '}
+ {errorMap.onSubmit?.toString()}
+
) : null
}
diff --git a/packages/form-core/src/FieldApi.ts b/packages/form-core/src/FieldApi.ts
index 9b7dba287..b0815af2b 100644
--- a/packages/form-core/src/FieldApi.ts
+++ b/packages/form-core/src/FieldApi.ts
@@ -6,6 +6,7 @@ import type {
ValidationCause,
ValidationError,
ValidationErrorMap,
+ ValidationSource,
Validator,
} from './types'
import type { AsyncValidator, SyncValidator, Updater } from './utils'
@@ -493,7 +494,11 @@ export class FieldApi<
* @private
*/
runValidator<
- TValue extends { value: TData; fieldApi: FieldApi },
+ TValue extends {
+ value: TData
+ fieldApi: FieldApi
+ validationSource: ValidationSource
+ },
TType extends 'validate' | 'validateAsync',
>(props: {
validate: TType extends 'validate'
@@ -501,7 +506,8 @@ export class FieldApi<
: FieldAsyncValidateOrFn
value: TValue
type: TType
- }): ReturnType>[TType]> {
+ // When `api` is 'field', the return type cannot be `FormValidationError`
+ }): TType extends 'validate' ? ValidationError : Promise {
const adapters = [
this.form.options.validatorAdapter,
this.options.validatorAdapter,
@@ -548,6 +554,7 @@ export class FieldApi<
value: {
value: this.state.value,
fieldApi: this,
+ validationSource: 'field',
},
type: 'validate',
})
@@ -763,7 +770,11 @@ export class FieldApi<
? normalizeError(
field.runValidator({
validate: validateObj.validate,
- value: { value: field.getValue(), fieldApi: field },
+ value: {
+ value: field.getValue(),
+ validationSource: 'field',
+ fieldApi: field,
+ },
type: 'validate',
}),
)
@@ -890,6 +901,7 @@ export class FieldApi<
value: field.getValue(),
fieldApi: field,
signal: controller.signal,
+ validationSource: 'field',
},
type: 'validateAsync',
}),
diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts
index 7e4714f35..b618efcfb 100644
--- a/packages/form-core/src/FormApi.ts
+++ b/packages/form-core/src/FormApi.ts
@@ -13,11 +13,13 @@ import type { DeepKeys, DeepValue } from './util-types'
import type { FieldApi, FieldMeta } from './FieldApi'
import type {
FormValidationError,
+ FormValidationErrorMap,
UpdateMetaOptions,
ValidationCause,
ValidationError,
ValidationErrorMap,
ValidationErrorMapKeys,
+ ValidationSource,
Validator,
} from './types'
@@ -234,7 +236,7 @@ export type FormState = {
/**
* The error map for the form itself.
*/
- errorMap: ValidationErrorMap
+ errorMap: FormValidationErrorMap
/**
* An internal mechanism used for keeping track of validation logic in a form.
*/
@@ -325,6 +327,12 @@ function getDefaultFormState(
}
}
+const isFormValidationError = (
+ error: unknown,
+): error is FormValidationError => {
+ return typeof error === 'object'
+}
+
/**
* A class representing the Form API. It handles the logic and interactions with the form state.
*
@@ -399,9 +407,18 @@ export class FormApi<
const isPristine = !isDirty
const isValidating = isFieldsValidating || state.isFormValidating
- state.errors = Object.values(state.errorMap).filter(
- (val: unknown) => val !== undefined,
- )
+ state.errors = Object.values(state.errorMap).reduce((prev, curr) => {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (curr === undefined) return prev
+ if (typeof curr === 'string') {
+ prev.push(curr)
+ return prev
+ } else if (curr && isFormValidationError(curr)) {
+ prev.push(curr.form)
+ return prev
+ }
+ return prev
+ }, [] as ValidationError[])
const isFormValid = state.errors.length === 0
const isValid = isFieldsValid && isFormValid
const canSubmit =
@@ -449,7 +466,11 @@ export class FormApi<
* @private
*/
runValidator<
- TValue extends { value: TFormData; formApi: FormApi },
+ TValue extends {
+ value: TFormData
+ formApi: FormApi
+ validationSource: ValidationSource
+ },
TType extends 'validate' | 'validateAsync',
>(props: {
validate: TType extends 'validate'
@@ -474,6 +495,7 @@ export class FormApi<
value: {
value: this.state.values,
formApi: this,
+ validationSource: 'form',
},
type: 'validate',
})
@@ -662,6 +684,7 @@ export class FormApi<
value: {
value: this.state.values,
formApi: this,
+ validationSource: 'form',
},
type: 'validate',
})
@@ -767,7 +790,10 @@ export class FormApi<
promises.push(
new Promise>(async (resolve) => {
- let rawError!: ValidationError | undefined
+ let rawError!:
+ | ValidationError
+ | FormValidationError
+ | undefined
try {
rawError = await new Promise((rawResolve, rawReject) => {
setTimeout(async () => {
@@ -779,6 +805,7 @@ export class FormApi<
value: {
value: this.state.values,
formApi: this,
+ validationSource: 'form',
signal: controller.signal,
},
type: 'validateAsync',
@@ -1228,7 +1255,7 @@ export class FormApi<
}
}
-function normalizeError(rawError?: FormValidationError): {
+function normalizeError(rawError?: FormValidationError): {
formError: ValidationError
fieldErrors?: Partial, ValidationError>>
} {
@@ -1236,7 +1263,7 @@ function normalizeError(rawError?: FormValidationError): {
if (typeof rawError === 'object') {
const formError = normalizeError(rawError.form).formError
const fieldErrors = rawError.fields
- return { formError, fieldErrors }
+ return { formError, fieldErrors } as never
}
if (typeof rawError !== 'string') {
diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts
index e3b6e4bc3..cff62ae7c 100644
--- a/packages/form-core/src/types.ts
+++ b/packages/form-core/src/types.ts
@@ -2,13 +2,21 @@ import type { DeepKeys } from './util-types'
export type ValidationError = undefined | false | null | string
+export type ValidationSource = 'form' | 'field'
+
/**
* If/when TypeScript supports higher-kinded types, this should not be `unknown` anymore
* @private
*/
export type Validator = () => {
- validate(options: { value: Type }, fn: Fn): ValidationError
- validateAsync(options: { value: Type }, fn: Fn): Promise
+ validate(
+ options: { value: Type; validationSource: ValidationSource },
+ fn: Fn,
+ ): ValidationError | FormValidationError
+ validateAsync(
+ options: { value: Type; validationSource: ValidationSource },
+ fn: Fn,
+ ): Promise>
}
/**
@@ -37,6 +45,13 @@ export type ValidationErrorMap = {
[K in ValidationErrorMapKeys]?: ValidationError
}
+/**
+ * @private
+ */
+export type FormValidationErrorMap = {
+ [K in ValidationErrorMapKeys]?: ValidationError | FormValidationError
+}
+
/**
* @private
*
diff --git a/packages/form-core/src/utils.ts b/packages/form-core/src/utils.ts
index 535b1d3a0..46849d518 100644
--- a/packages/form-core/src/utils.ts
+++ b/packages/form-core/src/utils.ts
@@ -139,7 +139,11 @@ const intReplace = `${intPrefix}$1`
/**
* @private
*/
-export function makePathArray(str: string) {
+export function makePathArray(str: string | Array) {
+ if (Array.isArray(str)) {
+ return [...str]
+ }
+
if (typeof str !== 'string') {
throw new Error('Path must be a string.')
}
diff --git a/packages/react-form/src/nextjs/createServerValidate.ts b/packages/react-form/src/nextjs/createServerValidate.ts
index b1df864a5..dc45691c7 100644
--- a/packages/react-form/src/nextjs/createServerValidate.ts
+++ b/packages/react-form/src/nextjs/createServerValidate.ts
@@ -2,6 +2,7 @@ import { decode } from 'decode-formdata'
import { ServerValidateError } from './error'
import type {
FormOptions,
+ FormValidationError,
ValidationError,
Validator,
} from '@tanstack/form-core'
@@ -26,6 +27,12 @@ interface CreateServerValidateOptions<
onServerValidate: OnServerValidateOrFn
}
+const isFormValidationError = (
+ error: unknown,
+): error is FormValidationError => {
+ return typeof error === 'object'
+}
+
export const createServerValidate =
<
TFormData,
@@ -38,7 +45,10 @@ export const createServerValidate =
async (formData: FormData, info?: Parameters[1]) => {
const { validatorAdapter, onServerValidate } = defaultOpts
- const runValidator = async (propsValue: { value: TFormData }) => {
+ const runValidator = async (propsValue: {
+ value: TFormData
+ validationSource: 'form'
+ }) => {
if (validatorAdapter && typeof onServerValidate !== 'function') {
return validatorAdapter().validateAsync(propsValue, onServerValidate)
}
@@ -48,16 +58,26 @@ export const createServerValidate =
const values = decode(formData, info) as never as TFormData
- const onServerError = await runValidator({ value: values })
+ const onServerError = await runValidator({
+ value: values,
+ validationSource: 'form',
+ })
if (!onServerError) return
+ const onServerErrorStr =
+ onServerError &&
+ typeof onServerError !== 'string' &&
+ isFormValidationError(onServerError)
+ ? onServerError.form
+ : onServerError
+
const formState: ServerFormState = {
errorMap: {
onServer: onServerError,
},
values,
- errors: onServerError ? [onServerError] : [],
+ errors: onServerErrorStr ? [onServerErrorStr] : [],
}
throw new ServerValidateError({
diff --git a/packages/react-form/src/start/createServerValidate.tsx b/packages/react-form/src/start/createServerValidate.tsx
index a657c06f9..b3c0041c7 100644
--- a/packages/react-form/src/start/createServerValidate.tsx
+++ b/packages/react-form/src/start/createServerValidate.tsx
@@ -3,6 +3,7 @@ import { _tanstackInternalsCookie } from './utils'
import { ServerValidateError } from './error'
import type {
FormOptions,
+ FormValidationError,
ValidationError,
Validator,
} from '@tanstack/form-core'
@@ -30,6 +31,12 @@ interface CreateServerValidateOptions<
onServerValidate: OnServerValidateOrFn
}
+const isFormValidationError = (
+ error: unknown,
+): error is FormValidationError => {
+ return typeof error === 'object'
+}
+
export const createServerValidate =
<
TFormData,
@@ -42,7 +49,10 @@ export const createServerValidate =
async (ctx: Ctx, formData: FormData, info?: Parameters[1]) => {
const { validatorAdapter, onServerValidate } = defaultOpts
- const runValidator = async (propsValue: { value: TFormData }) => {
+ const runValidator = async (propsValue: {
+ value: TFormData
+ validationSource: 'form'
+ }) => {
if (validatorAdapter && typeof onServerValidate !== 'function') {
return validatorAdapter().validateAsync(propsValue, onServerValidate)
}
@@ -54,16 +64,26 @@ export const createServerValidate =
const data = decode(formData, info) as never as TFormData
- const onServerError = await runValidator({ value: data })
+ const onServerError = await runValidator({
+ value: data,
+ validationSource: 'form',
+ })
if (!onServerError) return
+ const onServerErrorStr =
+ onServerError &&
+ typeof onServerError !== 'string' &&
+ isFormValidationError(onServerError)
+ ? onServerError.form
+ : onServerError
+
const formState: ServerFormState = {
errorMap: {
onServer: onServerError,
},
values: data,
- errors: onServerError ? [onServerError] : [],
+ errors: onServerErrorStr ? [onServerErrorStr] : [],
}
const cookie = await _tanstackInternalsCookie.serialize(formState)
diff --git a/packages/react-form/tests/useForm.test.tsx b/packages/react-form/tests/useForm.test.tsx
index 298d25328..6a7eeb655 100644
--- a/packages/react-form/tests/useForm.test.tsx
+++ b/packages/react-form/tests/useForm.test.tsx
@@ -190,7 +190,7 @@ describe('useForm', () => {
/>
)}
/>
- {onChangeError}
+ {onChangeError?.toString()}
>
)
}
@@ -287,7 +287,7 @@ describe('useForm', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
- {errors.onChange}
+ {errors.onChange?.toString()}
)}
/>
@@ -338,8 +338,8 @@ describe('useForm', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
- {errors.onChange}
- {errors.onBlur}
+ {errors.onChange?.toString()}
+ {errors.onBlur?.toString()}
)}
/>
@@ -402,8 +402,8 @@ describe('useForm', () => {
)}
/>
- {errors.onChange}
- {errors.onBlur}
+ {errors.onChange?.toString()}
+ {errors.onBlur?.toString()}
>
)
}
@@ -462,7 +462,7 @@ describe('useForm', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
- {errors.onChange}
+ {errors.onChange?.toString()}
)}
/>
@@ -523,7 +523,7 @@ describe('useForm', () => {
)}
/>
- {errors.onChange}
+ {errors.onChange?.toString()}
>
)
}
@@ -588,8 +588,8 @@ describe('useForm', () => {
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
- {errors.onChange}
- {errors.onBlur}
+ {errors.onChange?.toString()}
+ {errors.onBlur?.toString()}
)}
/>
@@ -708,7 +708,7 @@ describe('useForm', () => {
>
)}
/>
- {onChangeError}
+ {onChangeError?.toString()}
>
)
}
diff --git a/packages/solid-form/tests/createForm.test.tsx b/packages/solid-form/tests/createForm.test.tsx
index e002631f4..b8f1944b9 100644
--- a/packages/solid-form/tests/createForm.test.tsx
+++ b/packages/solid-form/tests/createForm.test.tsx
@@ -4,7 +4,7 @@ import { userEvent } from '@testing-library/user-event'
import { Show, createSignal, onCleanup } from 'solid-js'
import { createForm } from '../src/index'
import { sleep } from './utils'
-import type { ValidationErrorMap } from '@tanstack/form-core'
+import type { FormValidationErrorMap } from '../src/index'
const user = userEvent.setup()
@@ -211,7 +211,7 @@ describe('createForm', () => {
},
}))
- const [errors, setErrors] = createSignal()
+ const [errors, setErrors] = createSignal()
onCleanup(form.store.subscribe(() => setErrors(form.state.errorMap)))
return (
@@ -233,7 +233,7 @@ describe('createForm', () => {
})
}
/>
- {errors()?.onChange}
+ {errors()?.onChange?.toString()}
)
}}
@@ -267,7 +267,7 @@ describe('createForm', () => {
},
}))
- const [errors, setErrors] = createSignal()
+ const [errors, setErrors] = createSignal()
onCleanup(form.store.subscribe(() => setErrors(form.state.errorMap)))
return (
@@ -284,8 +284,8 @@ describe('createForm', () => {
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
/>
- {errors()?.onChange}
- {errors()?.onBlur}
+ {errors()?.onChange?.toString()}
+ {errors()?.onBlur?.toString()}
)}
/>
@@ -320,7 +320,7 @@ describe('createForm', () => {
},
}))
- const [errors, setErrors] = createSignal()
+ const [errors, setErrors] = createSignal()
onCleanup(form.store.subscribe(() => setErrors(form.state.errorMap)))
return (
@@ -337,7 +337,7 @@ describe('createForm', () => {
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
/>
- {errors()?.onChange}
+ {errors()?.onChange?.toString()}
)}
/>
@@ -375,7 +375,7 @@ describe('createForm', () => {
},
}))
- const [errors, setErrors] = createSignal()
+ const [errors, setErrors] = createSignal()
onCleanup(form.store.subscribe(() => setErrors(form.state.errorMap)))
return (
@@ -392,8 +392,8 @@ describe('createForm', () => {
onBlur={field().handleBlur}
onInput={(e) => field().handleChange(e.currentTarget.value)}
/>
- {errors()?.onChange}
- {errors()?.onBlur}
+ {errors()?.onChange?.toString()}
+ {errors()?.onBlur?.toString()}
)}
/>
@@ -437,7 +437,7 @@ describe('createForm', () => {
const [errors, setErrors] = createSignal()
onCleanup(
form.store.subscribe(() =>
- setErrors(form.state.errorMap.onChange || ''),
+ setErrors(form.state.errorMap.onChange?.toString() || ''),
),
)
diff --git a/packages/valibot-form-adapter/src/validator.ts b/packages/valibot-form-adapter/src/validator.ts
index 604e2a30c..60118dfd0 100644
--- a/packages/valibot-form-adapter/src/validator.ts
+++ b/packages/valibot-form-adapter/src/validator.ts
@@ -1,31 +1,91 @@
-import { safeParse, safeParseAsync } from 'valibot'
-import type { GenericIssue, GenericSchema, GenericSchemaAsync } from 'valibot'
-import type { Validator, ValidatorAdapterParams } from '@tanstack/form-core'
+import { getDotPath, safeParse, safeParseAsync } from 'valibot'
+import { setBy } from '@tanstack/form-core'
+import type {
+ ValidationError,
+ Validator,
+ ValidatorAdapterParams,
+} from '@tanstack/form-core'
+import type {
+ BaseIssue,
+ GenericIssue,
+ GenericSchema,
+ GenericSchemaAsync,
+ ValiError,
+} from 'valibot'
type Params = ValidatorAdapterParams
+type TransformFn = NonNullable
+
+export function prefixSchemaToErrors(
+ valiErrors: GenericIssue[],
+ transformErrors: TransformFn,
+) {
+ const schema = new Map()
+
+ for (const valiError of valiErrors) {
+ if (!valiError.path) continue
+
+ const path = valiError.path
+ .map(({ key: segment }) =>
+ typeof segment === 'number' ? `[${segment}]` : segment,
+ )
+ .join('.')
+ .replace(/\.\[/g, '[')
+ schema.set(path, (schema.get(path) ?? []).concat(valiError))
+ }
+
+ const transformedSchema = {} as Record
+
+ schema.forEach((value, key) => {
+ transformedSchema[key] = transformErrors(value)
+ })
+
+ return transformedSchema
+}
+
+export function defaultFormTransformer(transformErrors: TransformFn) {
+ return (zodErrors: GenericIssue[]) => ({
+ form: transformErrors(zodErrors),
+ fields: prefixSchemaToErrors(zodErrors, transformErrors),
+ })
+}
export const valibotValidator =
(
params: Params = {},
): Validator =>
() => {
+ const transformFieldErrors =
+ params.transformErrors ??
+ ((issues: GenericIssue[]) =>
+ issues.map((issue) => issue.message).join(', '))
+
+ const getTransformStrategy = (validationSource: 'form' | 'field') =>
+ validationSource === 'form'
+ ? defaultFormTransformer(transformFieldErrors)
+ : transformFieldErrors
+
return {
- validate({ value }, fn) {
+ validate({ value, validationSource }, fn) {
if (fn.async) return
- const result = safeParse(fn, value)
+ const result = safeParse(fn, value, {
+ abortPipeEarly: false,
+ })
if (result.success) return
- if (params.transformErrors) {
- return params.transformErrors(result.issues)
- }
- return result.issues.map((i) => i.message).join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(result.issues)
},
- async validateAsync({ value }, fn) {
- const result = await safeParseAsync(fn, value)
+ async validateAsync({ value, validationSource }, fn) {
+ const result = await safeParseAsync(fn, value, {
+ abortPipeEarly: false,
+ })
if (result.success) return
- if (params.transformErrors) {
- return params.transformErrors(result.issues)
- }
- return result.issues.map((i) => i.message).join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(result.issues)
},
}
}
diff --git a/packages/valibot-form-adapter/tests/FormApi.spec.ts b/packages/valibot-form-adapter/tests/FormApi.spec.ts
index 5d90b838b..282c8301f 100644
--- a/packages/valibot-form-adapter/tests/FormApi.spec.ts
+++ b/packages/valibot-form-adapter/tests/FormApi.spec.ts
@@ -58,4 +58,143 @@ describe('valibot form api', () => {
field.setValue('asdf')
expect(field.getMeta().errors).toEqual([])
})
+
+ it("should set field errors with the form validator's onChange", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ surname: '',
+ },
+ validatorAdapter: valibotValidator(),
+ validators: {
+ onChange: v.object({
+ name: v.pipe(
+ v.string(),
+ v.minLength(3, 'You must have a length of at least 3'),
+ v.endsWith('a', 'You must end with an "a"'),
+ ),
+ surname: v.pipe(
+ v.string(),
+ v.minLength(3, 'You must have a length of at least 3'),
+ ),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const surnameField = new FieldApi({
+ form,
+ name: 'surname',
+ })
+
+ nameField.mount()
+ surnameField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3, You must end with an "a"',
+ ])
+ expect(surnameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it("should set field errors with the form validator's onChange and transform them", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ foo: {
+ bar: '',
+ },
+ },
+ validatorAdapter: valibotValidator({
+ transformErrors: (errors: any[]) => errors[0]?.message,
+ }),
+ validators: {
+ onChange: v.object({
+ name: v.pipe(
+ v.string(),
+ v.minLength(3, 'You must have a length of at least 3'),
+ v.endsWith('a', 'You must end with an "a"'),
+ ),
+ foo: v.object({
+ bar: v.pipe(
+ v.string(),
+ v.minLength(4, 'You must have a length of at least 4'),
+ ),
+ }),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const fooBarField = new FieldApi({
+ form,
+ name: 'foo.bar',
+ })
+
+ nameField.mount()
+ fooBarField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ expect(fooBarField.getMeta().errors).toEqual([
+ 'You must have a length of at least 4',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it('should set field errors with the form validator for arrays', () => {
+ const form = new FormApi({
+ defaultValues: {
+ names: [''],
+ },
+ validatorAdapter: valibotValidator(),
+ validators: {
+ onChange: v.object({
+ names: v.array(
+ v.pipe(
+ v.string(),
+ v.minLength(3, 'You must have a length of at least 3'),
+ ),
+ ),
+ }),
+ },
+ })
+
+ const name0Field = new FieldApi({
+ form,
+ name: 'names[0]',
+ })
+
+ name0Field.mount()
+
+ expect(name0Field.getMeta().errors).toEqual([])
+ name0Field.setValue('q')
+
+ expect(name0Field.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ name0Field.setValue('qwer')
+ expect(name0Field.getMeta().errors).toEqual([])
+ })
})
diff --git a/packages/valibot-form-adapter/tests/createServerValidate.spec.ts b/packages/valibot-form-adapter/tests/createServerValidate.spec.ts
index bb60bf86b..22e7a4a70 100644
--- a/packages/valibot-form-adapter/tests/createServerValidate.spec.ts
+++ b/packages/valibot-form-adapter/tests/createServerValidate.spec.ts
@@ -49,15 +49,15 @@ describe('valibot createServerValidate api', () => {
it('should run v.string async validation', async () => {
const serverValidate = createServerValidate({
validatorAdapter: valibotValidator(),
- onServerValidate: v.pipeAsync(
- v.object({
- name: v.string(),
- }),
- v.checkAsync(async (val) => {
- await sleep(1)
- return val.name.length > 3
- }, 'Testing 123'),
- ),
+ onServerValidate: v.objectAsync({
+ name: v.pipeAsync(
+ v.string(),
+ v.checkAsync(async (name) => {
+ await sleep(1)
+ return name.length > 3
+ }, 'Testing 123'),
+ ),
+ }),
})
const formData1 = new FormData()
diff --git a/packages/yup-form-adapter/src/validator.ts b/packages/yup-form-adapter/src/validator.ts
index 59aa9cf3f..0962c2ad9 100644
--- a/packages/yup-form-adapter/src/validator.ts
+++ b/packages/yup-form-adapter/src/validator.ts
@@ -1,34 +1,78 @@
-import type { Validator, ValidatorAdapterParams } from '@tanstack/form-core'
+import { setBy } from '@tanstack/form-core'
+import type {
+ ValidationError,
+ Validator,
+ ValidatorAdapterParams,
+} from '@tanstack/form-core'
import type { AnySchema, ValidationError as YupError } from 'yup'
-type Params = ValidatorAdapterParams
+type Params = ValidatorAdapterParams
+type TransformFn = NonNullable
+
+export function prefixSchemaToErrors(
+ yupErrors: YupError[],
+ transformErrors: TransformFn,
+) {
+ const schema = new Map()
+
+ for (const yupError of yupErrors) {
+ if (!yupError.path) continue
+
+ const path = yupError.path
+ schema.set(path, (schema.get(path) ?? []).concat(yupError))
+ }
+
+ const transformedSchema = {} as Record
+
+ schema.forEach((value, key) => {
+ transformedSchema[key] = transformErrors(value)
+ })
+
+ return transformedSchema
+}
+
+export function defaultFormTransformer(transformErrors: TransformFn) {
+ return (zodErrors: YupError[]) => ({
+ form: transformErrors(zodErrors),
+ fields: prefixSchemaToErrors(zodErrors, transformErrors),
+ })
+}
export const yupValidator =
(params: Params = {}): Validator =>
() => {
+ const transformFieldErrors =
+ params.transformErrors ??
+ ((errors: YupError[]) => errors.map((error) => error.message).join(', '))
+
+ const getTransformStrategy = (validationSource: 'form' | 'field') =>
+ validationSource === 'form'
+ ? defaultFormTransformer(transformFieldErrors)
+ : transformFieldErrors
+
return {
- validate({ value }, fn) {
+ validate({ value, validationSource }, fn) {
try {
- fn.validateSync(value)
+ fn.validateSync(value, { abortEarly: false })
return
} catch (_e) {
const e = _e as YupError
- if (params.transformErrors) {
- return params.transformErrors(e.errors)
- }
- return e.errors.join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(e.inner)
}
},
- async validateAsync({ value }, fn) {
+ async validateAsync({ value, validationSource }, fn) {
try {
- await fn.validate(value)
+ await fn.validate(value, { abortEarly: false })
return
} catch (_e) {
const e = _e as YupError
- if (params.transformErrors) {
- return params.transformErrors(e.errors)
- }
- return e.errors.join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(e.inner)
}
},
}
diff --git a/packages/yup-form-adapter/tests/FieldApi.spec.ts b/packages/yup-form-adapter/tests/FieldApi.spec.ts
index 5224dbcf1..853ec62bc 100644
--- a/packages/yup-form-adapter/tests/FieldApi.spec.ts
+++ b/packages/yup-form-adapter/tests/FieldApi.spec.ts
@@ -133,7 +133,7 @@ describe('yup field api', () => {
const field = new FieldApi({
form,
validatorAdapter: yupValidator({
- transformErrors: (errors) => errors[0],
+ transformErrors: (errors) => errors[0]?.message,
}),
name: 'name',
validators: {
@@ -166,7 +166,7 @@ describe('yup field api', () => {
const field = new FieldApi({
form,
validatorAdapter: yupValidator({
- transformErrors: (errors) => errors[0],
+ transformErrors: (errors) => errors[0]?.message,
}),
name: 'name',
validators: {
diff --git a/packages/yup-form-adapter/tests/FormApi.spec.ts b/packages/yup-form-adapter/tests/FormApi.spec.ts
index 1bc230818..eb72ebe43 100644
--- a/packages/yup-form-adapter/tests/FormApi.spec.ts
+++ b/packages/yup-form-adapter/tests/FormApi.spec.ts
@@ -5,7 +5,7 @@ import yup from 'yup'
import { yupValidator } from '../src/index'
describe('yup form api', () => {
- it('should run an onChange with z.string validation', () => {
+ it('should run an onChange with yup.string validation', () => {
const form = new FormApi({
defaultValues: {
name: '',
@@ -56,4 +56,132 @@ describe('yup form api', () => {
field.setValue('asdf')
expect(field.getMeta().errors).toEqual([])
})
+
+ it("should set field errors with the form validator's onChange", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ surname: '',
+ },
+ validatorAdapter: yupValidator(),
+ validators: {
+ onChange: yup.object({
+ name: yup
+ .string()
+ .min(3, 'You must have a length of at least 3')
+ .matches(/a$/, 'You must end with an "a"'),
+ surname: yup.string().min(3, 'You must have a length of at least 3'),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const surnameField = new FieldApi({
+ form,
+ name: 'surname',
+ })
+
+ nameField.mount()
+ surnameField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3, You must end with an "a"',
+ ])
+ expect(surnameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it("should set field errors with the form validator's onChange and transform them", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ foo: {
+ bar: '',
+ },
+ },
+ validatorAdapter: yupValidator({
+ transformErrors: (errors) => errors[0]?.message,
+ }),
+ validators: {
+ onChange: yup.object({
+ name: yup
+ .string()
+ .min(3, 'You must have a length of at least 3')
+ .matches(/a$/, 'You must end with an "a"'),
+ foo: yup.object({
+ bar: yup.string().min(4, 'You must have a length of at least 4'),
+ }),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const fooBarField = new FieldApi({
+ form,
+ name: 'foo.bar',
+ })
+
+ nameField.mount()
+ fooBarField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ expect(fooBarField.getMeta().errors).toEqual([
+ 'You must have a length of at least 4',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it('should set field errors with the form validator for arrays', () => {
+ const form = new FormApi({
+ defaultValues: {
+ names: [''],
+ },
+ validatorAdapter: yupValidator(),
+ validators: {
+ onChange: yup.object({
+ names: yup.array(
+ yup.string().min(3, 'You must have a length of at least 3'),
+ ),
+ }),
+ },
+ })
+
+ const name0Field = new FieldApi({
+ form,
+ name: 'names[0]',
+ })
+
+ name0Field.mount()
+
+ expect(name0Field.getMeta().errors).toEqual([])
+ name0Field.setValue('q')
+
+ expect(name0Field.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ name0Field.setValue('qwer')
+ expect(name0Field.getMeta().errors).toEqual([])
+ })
})
diff --git a/packages/yup-form-adapter/tests/createServerValidate.spec.ts b/packages/yup-form-adapter/tests/createServerValidate.spec.ts
index 79fec11a0..801aacad1 100644
--- a/packages/yup-form-adapter/tests/createServerValidate.spec.ts
+++ b/packages/yup-form-adapter/tests/createServerValidate.spec.ts
@@ -100,7 +100,7 @@ describe('yup createServerValidate api', () => {
formData1.append('name', 'aa')
expect(
await serverValidate(formData1).catch((e) => e.formState.errors),
- ).toEqual(['You must have a length of at least 3'])
+ ).toEqual(['You must have a length of at least 3, UUID'])
const formData2 = new FormData()
formData2.append('name', 'aaa')
diff --git a/packages/zod-form-adapter/src/validator.ts b/packages/zod-form-adapter/src/validator.ts
index 077263699..9fbe50e3d 100644
--- a/packages/zod-form-adapter/src/validator.ts
+++ b/packages/zod-form-adapter/src/validator.ts
@@ -1,27 +1,73 @@
-import type { Validator, ValidatorAdapterParams } from '@tanstack/form-core'
+import type {
+ ValidationError,
+ Validator,
+ ValidatorAdapterParams,
+} from '@tanstack/form-core'
import type { ZodIssue, ZodType } from 'zod'
type Params = ValidatorAdapterParams
+type TransformFn = NonNullable
+
+export function prefixSchemaToErrors(
+ zodErrors: ZodIssue[],
+ transformErrors: TransformFn,
+) {
+ const schema = new Map()
+
+ for (const zodError of zodErrors) {
+ const path = zodError.path
+ .map((segment) =>
+ typeof segment === 'number' ? `[${segment}]` : segment,
+ )
+ .join('.')
+ .replace(/\.\[/g, '[')
+ schema.set(path, (schema.get(path) ?? []).concat(zodError))
+ }
+
+ const transformedSchema = {} as Record
+
+ schema.forEach((value, key) => {
+ transformedSchema[key] = transformErrors(value)
+ })
+
+ return transformedSchema
+}
+
+export function defaultFormTransformer(transformErrors: TransformFn) {
+ return (zodErrors: ZodIssue[]) => ({
+ form: transformErrors(zodErrors),
+ fields: prefixSchemaToErrors(zodErrors, transformErrors),
+ })
+}
export const zodValidator =
(params: Params = {}): Validator =>
() => {
+ const transformFieldErrors =
+ params.transformErrors ??
+ ((issues: ZodIssue[]) => issues.map((issue) => issue.message).join(', '))
+
+ const getTransformStrategy = (validationSource: 'form' | 'field') =>
+ validationSource === 'form'
+ ? defaultFormTransformer(transformFieldErrors)
+ : transformFieldErrors
+
return {
- validate({ value }, fn) {
+ validate({ value, validationSource }, fn) {
const result = fn.safeParse(value)
if (result.success) return
- if (params.transformErrors) {
- return params.transformErrors(result.error.issues)
- }
- return result.error.issues.map((issue) => issue.message).join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(result.error.issues)
},
- async validateAsync({ value }, fn) {
+ async validateAsync({ value, validationSource }, fn) {
const result = await fn.safeParseAsync(value)
if (result.success) return
- if (params.transformErrors) {
- return params.transformErrors(result.error.issues)
- }
- return result.error.issues.map((issue) => issue.message).join(', ')
+
+ const transformer = getTransformStrategy(validationSource)
+
+ return transformer(result.error.issues)
},
}
}
diff --git a/packages/zod-form-adapter/tests/FormApi.spec.ts b/packages/zod-form-adapter/tests/FormApi.spec.ts
index 51cff8f64..d2ff390ba 100644
--- a/packages/zod-form-adapter/tests/FormApi.spec.ts
+++ b/packages/zod-form-adapter/tests/FormApi.spec.ts
@@ -56,4 +56,132 @@ describe('zod form api', () => {
field.setValue('asdf')
expect(field.getMeta().errors).toEqual([])
})
+
+ it("should set field errors with the form validator's onChange", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ surname: '',
+ },
+ validatorAdapter: zodValidator(),
+ validators: {
+ onChange: z.object({
+ name: z
+ .string()
+ .min(3, 'You must have a length of at least 3')
+ .endsWith('a', 'You must end with an "a"'),
+ surname: z.string().min(3, 'You must have a length of at least 3'),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const surnameField = new FieldApi({
+ form,
+ name: 'surname',
+ })
+
+ nameField.mount()
+ surnameField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3, You must end with an "a"',
+ ])
+ expect(surnameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it("should set field errors with the form validator's onChange and transform them", () => {
+ const form = new FormApi({
+ defaultValues: {
+ name: '',
+ foo: {
+ bar: '',
+ },
+ },
+ validatorAdapter: zodValidator({
+ transformErrors: (errors) => errors[0]?.message,
+ }),
+ validators: {
+ onChange: z.object({
+ name: z
+ .string()
+ .min(3, 'You must have a length of at least 3')
+ .endsWith('a', 'You must end with an "a"'),
+ foo: z.object({
+ bar: z.string().min(4, 'You must have a length of at least 4'),
+ }),
+ }),
+ },
+ })
+
+ const nameField = new FieldApi({
+ form,
+ name: 'name',
+ })
+
+ const fooBarField = new FieldApi({
+ form,
+ name: 'foo.bar',
+ })
+
+ nameField.mount()
+ fooBarField.mount()
+
+ expect(nameField.getMeta().errors).toEqual([])
+ nameField.setValue('q')
+ expect(nameField.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ expect(fooBarField.getMeta().errors).toEqual([
+ 'You must have a length of at least 4',
+ ])
+ nameField.setValue('qwer')
+ expect(nameField.getMeta().errors).toEqual(['You must end with an "a"'])
+ nameField.setValue('qwera')
+ expect(nameField.getMeta().errors).toEqual([])
+ })
+
+ it('should set field errors with the form validator for arrays', () => {
+ const form = new FormApi({
+ defaultValues: {
+ names: [''],
+ },
+ validatorAdapter: zodValidator(),
+ validators: {
+ onChange: z.object({
+ names: z.array(
+ z.string().min(3, 'You must have a length of at least 3'),
+ ),
+ }),
+ },
+ })
+
+ const name0Field = new FieldApi({
+ form,
+ name: 'names[0]',
+ })
+
+ name0Field.mount()
+
+ expect(name0Field.getMeta().errors).toEqual([])
+ name0Field.setValue('q')
+
+ expect(name0Field.getMeta().errors).toEqual([
+ 'You must have a length of at least 3',
+ ])
+ name0Field.setValue('qwer')
+ expect(name0Field.getMeta().errors).toEqual([])
+ })
})