Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/angular/field-groups/src/bound-field.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ export class BoundFieldComponent {
watchFields: this.fields().lowerBound
? [this.fields().lowerBound!]
: undefined,
run: ({ value, parseIssues }: any) => {
run: ({ value }: any) => {
const valueResult = boundsSchema.safeParse(value)
if (!valueResult.success) return parseIssues(valueResult.error.issues)
if (!valueResult.success) return valueResult.error.issues
const lowerName = this.fields().lowerBound
if (!lowerName) return
const lowerResult = boundsSchema.safeParse(
Expand All @@ -61,6 +61,7 @@ export class BoundFieldComponent {
if (lowerResult.success && valueResult.data < lowerResult.data) {
return 'Upper bound must be greater than lower bound'
}
return undefined
},
},
])
Expand Down
4 changes: 2 additions & 2 deletions examples/lit/field-groups/src/field-bounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ function upperBoundField(props: {
{
triggers: ['change'],
watchFields: ['lowerBound'],
run: ({ value, parseIssues }) => {
run: ({ value }) => {
const upper = boundsSchema.safeParse(value)
if (!upper.success) return parseIssues(upper.error.issues)
if (!upper.success) return upper.error.issues
const lower = boundsSchema.safeParse(
props.fields.getFieldValue('lowerBound'),
)
Expand Down
4 changes: 2 additions & 2 deletions examples/preact/field-groups/src/fieldGroups/fieldBounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) {
// `watchFields` also uses virtual names. The wrapper rewrites
// `lowerBound` to the caller's concrete path, such as `minPrice`.
watchFields: ['lowerBound'],
run: ({ value, parseIssues }) => {
run: ({ value }) => {
const upperBoundResult = boundsSchema.safeParse(value)

if (!upperBoundResult.success) {
return parseIssues(upperBoundResult.error.issues)
return upperBoundResult.error.issues
}

const lowerBoundResult = boundsSchema.safeParse(
Expand Down
4 changes: 2 additions & 2 deletions examples/react/field-groups/src/fieldGroups/fieldBounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) {
// `watchFields` also uses virtual names. The wrapper rewrites
// `lowerBound` to the caller's concrete path, such as `minPrice`.
watchFields: ['lowerBound'],
run: ({ value, parseIssues }) => {
run: ({ value }) => {
const upperBoundResult = boundsSchema.safeParse(value)

if (!upperBoundResult.success) {
return parseIssues(upperBoundResult.error.issues)
return upperBoundResult.error.issues
}

const lowerBoundResult = boundsSchema.safeParse(
Expand Down
4 changes: 2 additions & 2 deletions examples/solid/field-groups/src/fieldGroups/fieldBounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ function UpperBoundFieldComponent(props: UpperBoundFieldProps) {
{
triggers: ['change'],
watchFields: ['lowerBound'],
run: ({ value, parseIssues }) => {
run: ({ value }) => {
const upperBoundResult = boundsSchema.safeParse(value)

if (!upperBoundResult.success) {
return parseIssues(upperBoundResult.error.issues)
return upperBoundResult.error.issues
}

const lowerBoundResult = boundsSchema.safeParse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
{
triggers: ['change'],
watchFields: ['lowerBound'],
run: ({ value, parseIssues }) => {
run: ({ value }) => {
const upperBoundResult = boundsSchema.safeParse(value)
if (!upperBoundResult.success) {
return parseIssues(upperBoundResult.error.issues)
return upperBoundResult.error.issues
}
const lowerBoundResult = boundsSchema.safeParse(
fields.getFieldValue('lowerBound'),
Expand Down
57 changes: 36 additions & 21 deletions packages/form-core/src/FormApi/FormApi.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ParsedStandardSchemaIssues,
ToFormErrorTypes,
ToFormOutputs,
ValidationErrorMap,
} from '../validation.public'
import type { StandardSchemaV1Issue } from '../standardSchema.public'
import type { FormListeners } from '../listeners.public'
Expand All @@ -18,9 +19,9 @@ import type { ServerFormState } from '../ssr.public'
import type { SUBMIT_ERROR } from './handleSubmit.lib'

/**
* A validation error wrapped for processing as an `onSubmit` failure.
* A validation error recognized as an `onSubmit` failure.
*
* Create this value with the `createValidationError` or `parseIssues` helper
* Create this value with the `createValidationError` or `createErrorMap` helper
* provided to `onSubmit`, then return it from the callback so its form- and
* field-level errors are added to validation state.
*
Expand Down Expand Up @@ -63,17 +64,29 @@ export type CreateValidationErrorFn<in out TFormData> = <
) => OnSubmitError<TError>

/**
* Converts Standard Schema issues into an `onSubmit` validation error.
* Creates an error map that can be returned directly from `onSubmit`.
*
* Issue paths are mapped to fields in the submitted value. Return the result
* from `onSubmit` to add the parsed errors to validation state.
* Build a mutable map or pass Standard Schema issues to route them using the
* submitted values. Narrow possibly undefined issue lists before calling this
* helper. Empty maps indicate success.
*
* @param issues - The Standard Schema issues to convert.
* @typeParam TFormData - Library-managed. Do not specify explicitly.
*/
export type ParseSubmitIssuesFn<in out TFormData> = (
issues: ReadonlyArray<StandardSchemaV1Issue>,
) => OnSubmitError<ParsedStandardSchemaIssues<TFormData>>
export interface CreateSubmitErrorMapFn<in out TFormData> {
/** Creates a mutable submission error map with an empty `fields` object. */
(): ValidationErrorMap<TFormData> &
OnSubmitError<ValidationErrorMap<TFormData>>
/** Reuses the supplied map, adding an empty `fields` object if it is missing. */
(
initial: Partial<ValidationErrorMap<TFormData>>,
): ValidationErrorMap<TFormData> &
OnSubmitError<ValidationErrorMap<TFormData>>
/** Routes issues while preserving Standard Schema issue arrays in the result. */
(
issues: ReadonlyArray<StandardSchemaV1Issue>,
): ParsedStandardSchemaIssues<TFormData> &
OnSubmitError<ParsedStandardSchemaIssues<TFormData>>
}

/**
* Context passed to `onSubmit` after submission validation succeeds.
Expand Down Expand Up @@ -147,7 +160,7 @@ export interface FormSubmitContext<
*/
createValidationError: CreateValidationErrorFn<TFormData>
/**
* Parses Standard Schema issues into an error returnable from `onSubmit`.
* Builds an error map or routes Standard Schema issues for `onSubmit`.
*
* Issue paths are mapped to fields in the submitted value. Return the parsed
* error from `onSubmit` to add those errors to validation state.
Expand All @@ -156,24 +169,24 @@ export interface FormSubmitContext<
* ```ts
* {
* // ...
* onSubmit: async ({ value, parseIssues }) => {
* onSubmit: async ({ value, createErrorMap }) => {
* const result = zodSchema.safeParse(value)
*
* if (!result.success) {
* return parseIssues(result.error.issues)
* return createErrorMap(result.error.issues)
* }
* },
* }
* ```
*/
parseIssues: ParseSubmitIssuesFn<TFormData>
createErrorMap: CreateSubmitErrorMapFn<TFormData>
}

/**
* Handles a form submission after submission validation succeeds.
*
* A returned promise is awaited before submission finishes. Return an error
* created by the submission context's `createValidationError` or `parseIssues`
* created by the submission context's `createValidationError` or `createErrorMap`
* helper to mark the submission as invalid.
*
* @typeParam TFormData - Library-managed. Do not specify explicitly.
Expand Down Expand Up @@ -365,7 +378,7 @@ export interface FormOptions<
/**
* Called after submission validation succeeds.
*
* Return an error created with `createValidationError` or `parseIssues` to
* Return an error created with `createValidationError` or `createErrorMap` to
* mark the submission as invalid. A returned promise is awaited before
* submission finishes. If the callback throws, `onSubmitInvalid` is called.
*
Expand Down Expand Up @@ -584,15 +597,16 @@ export interface FormResetOptions {
* Registered fields are marked touched, field validators run before form
* validators, and `onSubmit` is awaited only when validation succeeds.
* Validation error results and errors returned by `onSubmit` through
* `createValidationError` are stored as error state. `onSubmitInvalid` is
* awaited after a failed attempt.
* `createValidationError` or `createErrorMap` are stored as error state.
* `onSubmitInvalid` is awaited after a failed attempt.
*
* Calls made while an attempt is in progress return the same promise instead
* of starting another attempt.
*
* @returns A promise resolving to the error results produced by field and
* form validation, plus any validation error returned by `onSubmit` through
* `createValidationError`. The array is empty if none are produced.
* `createValidationError` or `createErrorMap`. The array is empty if none are
* produced.
* @typeParam TFormData - Library-managed. Do not specify explicitly.
*/
export type HandleSubmitFn<in out TFormData> = () => Promise<
Expand Down Expand Up @@ -678,15 +692,16 @@ export interface FormApi<
* Registered fields are marked touched, field validators run before form
* validators, and `onSubmit` is awaited only when validation succeeds.
* Validation error results and errors returned by `onSubmit` through
* `createValidationError` are stored as error state. `onSubmitInvalid` is
* awaited after a failed attempt.
* `createValidationError` or `createErrorMap` are stored as error state.
* `onSubmitInvalid` is awaited after a failed attempt.
*
* Calls made while an attempt is in progress return the same promise instead
* of starting another attempt.
*
* The returned promise resolves to the error results produced by field and
* form validation, plus any validation error returned by `onSubmit` through
* `createValidationError`. The array is empty if none are produced.
* `createValidationError` or `createErrorMap`. The array is empty if none are
* produced.
*/
handleSubmit: HandleSubmitFn<TFormData>
/**
Expand Down
23 changes: 15 additions & 8 deletions packages/form-core/src/FormApi/handleSubmit.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,19 @@ import {
isValidationErrorMap,
parseValidationResult,
} from '../validation'
import { parseStandardSchemaIssues } from '../standardSchema.lib'
import { createErrorMapFactory } from '../createErrorMap.lib'
import type { StandardSchemaV1Issue } from '../standardSchema.public'
import type { InternalFormApi } from './FormApi.lib'
import type {
FormValidateResult,
FormValidationError,
ValidationErrorInput,
ValidationErrorMap,
} from '../validation.public'
import type {
CreateSubmitErrorMapFn,
CreateValidationErrorFn,
OnSubmitError,
ParseSubmitIssuesFn,
} from './FormApi.public'

export const SUBMIT_ERROR = Symbol('SUBMIT_ERROR')
Expand All @@ -39,12 +41,17 @@ function createSubmitError<TError extends FormValidationError<any>>(
return { [SUBMIT_ERROR]: error }
}

function createParseIssues<TFormData>(
function createSubmitErrorMapFactory<TFormData>(
value: TFormData,
): ParseSubmitIssuesFn<TFormData> {
return (issues) => {
return createSubmitError(parseStandardSchemaIssues(issues, value, 'form'))
}
): CreateSubmitErrorMapFn<TFormData> {
const createErrorMap = createErrorMapFactory(value)

return ((initial) => {
const map = createErrorMap(initial as never)
// Read the map itself so edits made after creation are included.
Object.defineProperty(map, SUBMIT_ERROR, { value: map })
return map
}) as CreateSubmitErrorMapFn<TFormData>
}

export async function runSubmissionProcess<TFormData>(
Expand Down Expand Up @@ -168,7 +175,7 @@ export async function runSubmissionProcess<TFormData>(
validatorOutputs,
value,
createValidationError,
parseIssues: createParseIssues(value),
createErrorMap: createSubmitErrorMapFactory(value),
})

if (hasResettedFormDuringSubmit()) {
Expand Down
7 changes: 2 additions & 5 deletions packages/form-core/src/FormGroupApi/FormGroupApi.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ import {
deriveFromBaseFieldMeta,
hasFieldMetaErrors,
} from '../FieldApi/fieldState.lib'
import { parseStandardSchemaIssues } from '../standardSchema.lib'
import { createErrorMap } from '../validation.public'
import { createErrorMapFactory } from '../createErrorMap.lib'
import { reconcileValidatorInstances } from '../ValidatorInstance.lib'
import { resolveDefaultOptions } from '../defaultOptions.lib'
import type { FormApi } from '../FormApi/FormApi.public'
Expand Down Expand Up @@ -685,10 +684,8 @@ export class InternalFormGroupApi<
triggerFieldApi: opts?.triggerFieldApi,
signal: ctx.signal,
value: this.value,
createErrorMap,
createErrorMap: createErrorMapFactory(this.value),
createOutput,
parseIssues: (issues) =>
parseStandardSchemaIssues(issues, this.value, 'form'),
}),
onResult: (result) => {
this._processValidationResult(result, signal)
Expand Down
23 changes: 23 additions & 0 deletions packages/form-core/src/createErrorMap.lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { parseStandardSchemaIssues } from './standardSchema.lib'
import { isReadonlyArray } from './utils.lib'
import type { CreateErrorMapFn } from './validation.public'

type TupleNarrower = (arg: any) => arg is ReadonlyArray<any>

/**
* Standard schema needs path parsing, so the error map function needs context of form values.
*/
export function createErrorMapFactory<TFormData>(
value: TFormData,
): CreateErrorMapFn<TFormData> {
const createErrorMap = ((initial) => {
if (isReadonlyArray(initial)) {
return parseStandardSchemaIssues(initial, value, 'form')
}
const map = initial ?? {}
map.fields ??= {}
return map
}) as CreateErrorMapFn<TFormData>

return createErrorMap
}
7 changes: 2 additions & 5 deletions packages/form-core/src/ssr.lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { batch } from '@tanstack/store'
import { createOutput } from './validationOutput.lib'
import { defaultInternalBaseFieldMeta } from './FieldApi/fieldState.lib'
import { visitAllFormFields } from './FieldApi/fieldTraversal.lib'
import { parseStandardSchemaIssues } from './standardSchema.lib'
import { evaluate } from './utils.lib'
import { runValidatorPipeline } from './validation'
import { createErrorMap } from './validation.public'
import { createErrorMapFactory } from './createErrorMap.lib'
import { devtools } from './devtoolsBridge.lib'
import { reconcileValidatorInstances } from './ValidatorInstance.lib'
import type { FormOptions } from './FormApi/FormApi.public'
Expand Down Expand Up @@ -155,10 +154,8 @@ export async function validateServerValues<
signal: ctx.signal,
formApi: undefined,
value: values,
createErrorMap,
createErrorMap: createErrorMapFactory(values),
createOutput,
parseIssues: (issues) =>
parseStandardSchemaIssues(issues, values, 'form'),
}),
scope: 'form',
})
Expand Down
1 change: 1 addition & 0 deletions packages/form-core/src/types.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface FieldUpdateOptions {
}

export type OneOrMany<TValue> = TValue | Array<TValue>
export type ReadonlyOneOrMany<TValue> = TValue | ReadonlyArray<TValue>

/**
* A field API that preserves its value type while erasing its name, error, and
Expand Down
Loading
Loading