diff --git a/docs/framework/react/guides/disable-field-mapping.md b/docs/framework/react/guides/disable-field-mapping.md new file mode 100644 index 000000000..02d450f5d --- /dev/null +++ b/docs/framework/react/guides/disable-field-mapping.md @@ -0,0 +1,349 @@ +# disableFieldMapping + +The `disableFieldMapping` option allows you to control whether schema validation errors are applied to specific fields. This is useful when implementing conditional validation, multi-step forms, or custom validation logic. + +## Basic Usage + +### Global Disable + +To disable schema validation errors for all fields, set `disableFieldMapping` to `true`: + +```tsx +import { useForm } from '@tanstack/react-form' +import { z } from 'zod' + +const schema = z.object({ + username: z.string().min(1, 'Username is required'), + email: z.string().email('Valid email is required'), +}) + +function MyForm() { + const form = useForm({ + defaultValues: { + username: '', + email: '', + }, + validators: { + onChange: schema, + }, + disableFieldMapping: true, + }) + + return ( +
{ + e.preventDefault() + form.handleSubmit() + }} + > + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + +
+ ) +} +``` + +### Selective Disable + +You can selectively disable schema validation for specific fields: + +```tsx +function MyForm() { + const form = useForm({ + defaultValues: { + username: '', + email: '', + password: '', + }, + validators: { + onChange: schema, + }, + disableFieldMapping: { + fields: { + username: true, + email: false, + }, + }, + }) + + return ( +
+ + {(field) => ( +
+ field.handleChange(e.target.value)} + /> +
+ )} +
+ + + {(field) => ( +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +
{error}
+ ))} +
+ )} +
+
+ ) +} +``` + +## Use Cases + +### 1. Conditional Validation + +You can dynamically control validation based on user input state or form state: + +```tsx +function ConditionalValidationForm() { + const [showAdvanced, setShowAdvanced] = useState(false) + + const form = useForm({ + defaultValues: { + basicField: '', + advancedField: '', + }, + validators: { + onChange: schema, + }, + disableFieldMapping: { + fields: { + advancedField: !showAdvanced, + }, + }, + }) + + return ( +
+ + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + + + + {showAdvanced && ( + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + )} +
+ ) +} +``` + +### 2. Multi-step Forms + +When you want to validate only the current step's fields in a multi-step form: + +```tsx +function MultiStepForm() { + const [currentStep, setCurrentStep] = useState(1) + + const form = useForm({ + defaultValues: { + step1Field: '', + step2Field: '', + step3Field: '', + }, + validators: { + onChange: schema, + }, + disableFieldMapping: { + fields: { + step1Field: currentStep !== 1, + step2Field: currentStep !== 2, + step3Field: currentStep !== 3, + }, + }, + }) + + return ( +
+ {currentStep === 1 && ( + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + )} + + {currentStep === 2 && ( + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + + )} + + +
+ ) +} +``` + +### 3. Using with Custom Validation + +When you want to use field-specific custom validation instead of schema validation: + +```tsx +function CustomValidationForm() { + const form = useForm({ + defaultValues: { + username: '', + email: '', + }, + validators: { + onChange: schema, + }, + disableFieldMapping: { + fields: { + username: true, + }, + }, + }) + + return ( +
+ { + if (value.length < 3) { + return 'Username must be at least 3 characters' + } + if (!/^[a-zA-Z0-9_]+$/.test(value)) { + return 'Username can only contain letters, numbers, and underscores' + } + return undefined + }, + }} + > + {(field) => ( +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +
{error}
+ ))} +
+ )} +
+ + + {(field) => ( +
+ field.handleChange(e.target.value)} + /> + {field.state.meta.errors.map((error) => ( +
{error}
+ ))} +
+ )} +
+
+ ) +} +``` + +## Runtime Configuration Changes + +You can dynamically change the `disableFieldMapping` configuration using the `form.update()` method even after the form is created: + +```tsx +function DynamicConfigForm() { + const form = useForm({ + defaultValues: { username: '', email: '' }, + validators: { onChange: schema }, + disableFieldMapping: false, + }) + + const toggleValidation = () => { + form.update({ + disableFieldMapping: !form.options.disableFieldMapping, + }) + } + + return ( +
+ + + + {(field) => ( + field.handleChange(e.target.value)} + /> + )} + +
+ ) +} +``` + +## Important Notes + +1. **Field-level validation is unaffected**: `disableFieldMapping` only controls schema validation; field-level `validators` options still work. + +2. **Form-level validation**: Validation during form submission runs regardless of `disableFieldMapping` settings. + +3. **Type safety**: When using TypeScript, field names must be keys defined in the form data type. + +## API Reference + +```typescript +interface FieldMappingConfig { + fields?: Partial, boolean>> +} + +interface FormOptions { + disableFieldMapping?: boolean | FieldMappingConfig +} +``` + +- `true`: Disable schema validation for all fields +- `false` or `undefined`: Enable schema validation for all fields (default) +- `{ fields: { fieldName: boolean } }`: Fine-grained field control + - `true`: Disable schema validation for that field + - `false`: Enable schema validation for that field + - Fields not specified: Use default (enabled) diff --git a/examples/react/disable-field-mapping/README.md b/examples/react/disable-field-mapping/README.md new file mode 100644 index 000000000..b8216a1c3 --- /dev/null +++ b/examples/react/disable-field-mapping/README.md @@ -0,0 +1,60 @@ +# disableFieldMapping Example + +This example demonstrates the `disableFieldMapping` feature of TanStack Form. + +## Feature Description + +`disableFieldMapping` is a feature that allows you to control whether schema validation errors are applied to specific fields. + +### Usage + +#### 1. Global Disable +```typescript +const form = useForm({ + // ... other options + disableFieldMapping: true, // Disable schema errors for all fields +}) +``` + +#### 2. Selective Disable +```typescript +const form = useForm({ + // ... other options + disableFieldMapping: { + fields: { + username: true, // Disable schema errors for username field only + email: false, // Enable schema errors for email field + // Fields not specified use default (enabled) + }, + }, +}) +``` + +### Use Cases + +1. **Conditional Validation**: Disable schema validation for some fields only under specific conditions +2. **Multi-step Forms**: Validate only current step fields in multi-step forms +3. **Custom Validation**: Use field-specific custom validation instead of schema validation +4. **UX Improvement**: Hide errors for fields the user hasn't interacted with yet + +## How to Run + +```bash +# Install dependencies +pnpm install + +# Start development server +pnpm dev +``` + +Open `http://localhost:3000` in your browser to see the example. + +## Example Structure + +This example shows three forms: + +1. **Default Form**: Schema validation applied to all fields +2. **Global Disable Form**: Schema validation disabled for all fields +3. **Selective Disable Form**: username disabled, email enabled + +Try the same inputs in each form to see the differences based on `disableFieldMapping` configuration. diff --git a/examples/react/disable-field-mapping/index.html b/examples/react/disable-field-mapping/index.html new file mode 100644 index 000000000..5dc9260d8 --- /dev/null +++ b/examples/react/disable-field-mapping/index.html @@ -0,0 +1,12 @@ + + + + + + TanStack Form - disableFieldMapping Example + + +
+ + + diff --git a/examples/react/disable-field-mapping/package.json b/examples/react/disable-field-mapping/package.json new file mode 100644 index 000000000..53c7e4650 --- /dev/null +++ b/examples/react/disable-field-mapping/package.json @@ -0,0 +1,24 @@ +{ + "name": "disable-field-mapping-example", + "version": "1.0.0", + "description": "TanStack Form disableFieldMapping feature example", + "main": "src/index.tsx", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-form": "workspace:*", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "zod": "^3.22.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^4.0.0" + } +} diff --git a/examples/react/disable-field-mapping/src/index.tsx b/examples/react/disable-field-mapping/src/index.tsx new file mode 100644 index 000000000..d173bb0ec --- /dev/null +++ b/examples/react/disable-field-mapping/src/index.tsx @@ -0,0 +1,234 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useForm } from '@tanstack/react-form' +import { z } from 'zod' + +// Form data type definition +interface FormData { + username: string + email: string + password: string + confirmPassword: string +} + +// Zod schema definition +const formSchema = z.object({ + username: z.string().min(1, 'Username is required'), + email: z.string().email('Valid email is required'), + password: z.string().min(8, 'Password must be at least 8 characters'), + confirmPassword: z.string().min(1, 'Confirm password is required'), +}).refine((data) => data.password === data.confirmPassword, { + message: 'Passwords must match', + path: ['confirmPassword'], +}) + +function App() { + // Default form (schema applied to all fields) + const defaultForm = useForm({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + } as FormData, + validators: { + onChange: formSchema, + }, + }) + + // Global disable form + const disabledForm = useForm({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + } as FormData, + validators: { + onChange: formSchema, + }, + disableFieldMapping: true, // Disable schema errors for all fields + }) + + // Selective disable form + const selectiveForm = useForm({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + } as FormData, + validators: { + onChange: formSchema, + }, + disableFieldMapping: { + fields: { + username: true, // Disable schema errors for username field only + email: false, // Enable schema errors for email field + // password, confirmPassword use default (enabled) + }, + }, + }) + + return ( +
+

disableFieldMapping Example

+ +
+ {/* Default Form */} +
+

Default Form (All Schema Errors Enabled)

+
{ + e.preventDefault() + defaultForm.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + +
+
+ + {/* Global Disable Form */} +
+

Global Disable Form

+
{ + e.preventDefault() + disabledForm.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + +
+
+ + {/* Selective Disable Form */} +
+

Selective Disable Form

+

+ username: Schema errors disabled
+ email: Schema errors enabled +

+
{ + e.preventDefault() + selectiveForm.handleSubmit() + }} + > + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + + {(field) => ( +
+ + field.handleChange(e.target.value)} + style={{ width: '100%', padding: '5px' }} + /> + {field.state.meta.errors.length > 0 && ( +
+ {field.state.meta.errors[0]} +
+ )} +
+ )} +
+ + +
+
+
+
+ ) +} + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement) +root.render() diff --git a/examples/react/disable-field-mapping/tsconfig.json b/examples/react/disable-field-mapping/tsconfig.json new file mode 100644 index 000000000..3934b8f6d --- /dev/null +++ b/examples/react/disable-field-mapping/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/react/disable-field-mapping/vite.config.ts b/examples/react/disable-field-mapping/vite.config.ts new file mode 100644 index 000000000..40707c4fe --- /dev/null +++ b/examples/react/disable-field-mapping/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + }, +}) diff --git a/packages/form-core/src/FormApi.ts b/packages/form-core/src/FormApi.ts index 93f9b605f..4557d67c6 100644 --- a/packages/form-core/src/FormApi.ts +++ b/packages/form-core/src/FormApi.ts @@ -34,6 +34,7 @@ import type { import type { ExtractGlobalFormError, FieldManipulator, + FieldMappingConfig, FormValidationError, FormValidationErrorMap, ListenerCause, @@ -434,6 +435,11 @@ export interface FormOptions< validationLogic?: ValidationLogicFn + /** + * Controls how schema validation errors are mapped to form fields. + */ + disableFieldMapping?: FieldMappingConfig + /** * form level listeners */ @@ -1267,6 +1273,30 @@ export class FormApi< return this.options.formId } + shouldApplySchemaToField = >( + field: TField, + ): boolean => { + const config = this.options.disableFieldMapping + + if (config === undefined) { + return true + } + + if (typeof config === 'boolean') { + return !config + } + + if (config.fields) { + const fieldConfig = config.fields[field] + if (fieldConfig === undefined) { + return true + } + return !fieldConfig + } + + return true + } + /** * @private */ @@ -1553,20 +1583,70 @@ export class FormApi< type: 'validate', }) - const { formError, fieldErrors } = normalizeError(rawError) + const { formError, fieldErrors: rawFieldErrors } = normalizeError(rawError) + + let fieldErrors = rawFieldErrors + let filteredFormError = formError + + if (this.options.disableFieldMapping) { + if (this.options.disableFieldMapping === true) { + fieldErrors = undefined + filteredFormError = undefined + } else if (rawFieldErrors) { + fieldErrors = {} as Record, ValidationError> + for (const field in rawFieldErrors) { + if (this.shouldApplySchemaToField(field as DeepKeys)) { + fieldErrors[field as DeepKeys] = rawFieldErrors[field as DeepKeys] + } + } + if (Object.keys(fieldErrors).length === 0) { + fieldErrors = undefined + } + + if (formError && typeof formError === 'object' && !Array.isArray(formError)) { + const filteredError = {} as Record + for (const field in formError as Record) { + if (this.shouldApplySchemaToField(field as DeepKeys)) { + filteredError[field] = (formError as Record)[field] + } + } + if (Object.keys(filteredError).length === 0) { + filteredFormError = undefined + } else { + filteredFormError = filteredError + } + } + } + } const errorMapKey = getErrorMapKey(validateObj.cause) - for (const field of Object.keys( - this.state.fieldMeta, - ) as DeepKeys[]) { + + const fieldsToProcess = new Set([ + ...Object.keys(this.state.fieldMeta), + ...(fieldErrors ? Object.keys(fieldErrors) : []), + ] as DeepKeys[]) + + for (const field of fieldsToProcess) { + if (!this.shouldApplySchemaToField(field)) { + continue + } + const fieldMeta = this.getFieldMeta(field) - if (!fieldMeta) continue + + if (!fieldMeta && fieldErrors?.[field]) { + this.getFieldInfo(field) + const newFieldMeta = this.getFieldMeta(field) + if (!newFieldMeta) continue + } + + const currentFieldMeta = this.getFieldMeta(field) + if (!currentFieldMeta) continue const { errorMap: currentErrorMap, errorSourceMap: currentErrorMapSource, - } = fieldMeta + } = currentFieldMeta const newFormValidatorError = fieldErrors?.[field] @@ -1606,17 +1686,17 @@ export class FormApi< } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (this.state.errorMap?.[errorMapKey] !== formError) { + if (this.state.errorMap?.[errorMapKey] !== filteredFormError) { this.baseStore.setState((prev) => ({ ...prev, errorMap: { ...prev.errorMap, - [errorMapKey]: formError, + [errorMapKey]: filteredFormError, }, })) } - if (formError || fieldErrors) { + if (filteredFormError || fieldErrors) { hasErrored = true } } diff --git a/packages/form-core/src/types.ts b/packages/form-core/src/types.ts index ff5824283..f73f53ac1 100644 --- a/packages/form-core/src/types.ts +++ b/packages/form-core/src/types.ts @@ -4,6 +4,12 @@ import type { Updater } from './utils' export type ValidationError = unknown +export type FieldMappingConfig = + | boolean + | { + fields?: Partial, boolean>> + } + export type ValidationSource = 'form' | 'field' /** diff --git a/packages/form-core/tests/disableFieldMapping.spec.ts b/packages/form-core/tests/disableFieldMapping.spec.ts new file mode 100644 index 000000000..8da33a0d3 --- /dev/null +++ b/packages/form-core/tests/disableFieldMapping.spec.ts @@ -0,0 +1,248 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { FieldApi, FormApi } from '../src/index' +import type { FieldMappingConfig } from '../src/index' + +interface TestFormData { + username: string + email: string + password: string + confirmPassword: string +} + +const testSchema = z.object({ + username: z.string().min(1, 'Username is required'), + email: z.string().email('Valid email is required'), + password: z.string().min(8, 'Password must be at least 8 characters'), + confirmPassword: z.string().min(1, 'Confirm password is required'), +}).refine((data) => data.password === data.confirmPassword, { + message: 'Passwords must match', + path: ['confirmPassword'], +}) + +describe('disableFieldMapping', () => { + describe('shouldApplySchemaToField method', () => { + it('should return true when no configuration is provided (default behavior)', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(true) + expect(form.shouldApplySchemaToField('email')).toBe(true) + expect(form.shouldApplySchemaToField('password')).toBe(true) + }) + + it('should return false for all fields when disableFieldMapping is true', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: true, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(false) + expect(form.shouldApplySchemaToField('email')).toBe(false) + expect(form.shouldApplySchemaToField('password')).toBe(false) + }) + + it('should return true for all fields when disableFieldMapping is false', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: false, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(true) + expect(form.shouldApplySchemaToField('email')).toBe(true) + expect(form.shouldApplySchemaToField('password')).toBe(true) + }) + + it('should respect field-specific configuration', () => { + const config: FieldMappingConfig = { + fields: { + }, + } + + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: config, + }) + + }) + + it('should handle empty fields configuration', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: { fields: {} }, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(true) + expect(form.shouldApplySchemaToField('email')).toBe(true) + expect(form.shouldApplySchemaToField('password')).toBe(true) + }) + }) + + describe('schema validation integration', () => { + it('should apply schema errors to all fields by default', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + validators: { + onChange: testSchema, + }, + }) + form.mount() + + const usernameField = new FieldApi({ form, name: 'username' }) + const emailField = new FieldApi({ form, name: 'email' }) + const passwordField = new FieldApi({ form, name: 'password' }) + const confirmPasswordField = new FieldApi({ form, name: 'confirmPassword' }) + + usernameField.mount() + emailField.mount() + passwordField.mount() + confirmPasswordField.mount() + + usernameField.setValue('') + emailField.setValue('invalid-email') + passwordField.setValue('123') + confirmPasswordField.setValue('456') + + expect(form.state.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + username: expect.any(Array), + email: expect.any(Array), + password: expect.any(Array), + confirmPassword: expect.any(Array), + }), + ]) + ) + }) + + it('should not apply schema errors when disableFieldMapping is true', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + validators: { + onChange: testSchema, + }, + disableFieldMapping: true, + }) + form.mount() + + const usernameField = new FieldApi({ form, name: 'username' }) + const emailField = new FieldApi({ form, name: 'email' }) + const passwordField = new FieldApi({ form, name: 'password' }) + const confirmPasswordField = new FieldApi({ form, name: 'confirmPassword' }) + + usernameField.mount() + emailField.mount() + passwordField.mount() + confirmPasswordField.mount() + + usernameField.setValue('') + emailField.setValue('invalid-email') + passwordField.setValue('123') + confirmPasswordField.setValue('456') + + expect(form.state.errors).toEqual([]) + }) + + it('should selectively apply schema errors based on field configuration', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + validators: { + onChange: testSchema, + }, + disableFieldMapping: { + fields: { + username: true, + email: false, + }, + }, + }) + form.mount() + + const usernameField = new FieldApi({ form, name: 'username' }) + const emailField = new FieldApi({ form, name: 'email' }) + const passwordField = new FieldApi({ form, name: 'password' }) + const confirmPasswordField = new FieldApi({ form, name: 'confirmPassword' }) + + usernameField.mount() + emailField.mount() + passwordField.mount() + confirmPasswordField.mount() + + usernameField.setValue('') + emailField.setValue('invalid-email') + passwordField.setValue('123') + confirmPasswordField.setValue('456') + + expect(form.state.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + }), + ]) + ) + + const errorObj = form.state.errors[0] as Record + expect(errorObj).not.toHaveProperty('username') + }) + + it('should handle configuration changes at runtime', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: false, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(true) + + form.update({ + disableFieldMapping: true, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(false) + }) + }) +})