From 14a2b2e08e7d45ffe940c1e0e6d0d0b97f59ac86 Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Tue, 16 Sep 2025 15:10:08 +0900 Subject: [PATCH 1/3] feat: add field mapping configuration to control schema validation error mapping --- packages/form-core/src/FormApi.ts | 98 ++++- .../__tests__/disable-field-mapping.test.ts | 371 ++++++++++++++++++ packages/form-core/src/types.ts | 6 + .../tests/disableFieldMapping.spec.ts | 246 ++++++++++++ 4 files changed, 712 insertions(+), 9 deletions(-) create mode 100644 packages/form-core/src/__tests__/disable-field-mapping.test.ts create mode 100644 packages/form-core/tests/disableFieldMapping.spec.ts 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/__tests__/disable-field-mapping.test.ts b/packages/form-core/src/__tests__/disable-field-mapping.test.ts new file mode 100644 index 000000000..b5d25415e --- /dev/null +++ b/packages/form-core/src/__tests__/disable-field-mapping.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from 'vitest' +import { FormApi } from '../FormApi' +import type { FieldMappingConfig } from '../types' + +interface TestFormData { + username: string + email: string + password: string + confirmPassword: string +} + +const mockSchemaValidator = { + validate: (value: TestFormData) => { + const errors: Record = {} + + if (!value.username) { + errors.username = 'Username is required' + } + if (!value.email || !value.email.includes('@')) { + errors.email = 'Valid email is required' + } + if (!value.password || value.password.length < 8) { + errors.password = 'Password must be at least 8 characters' + } + if (value.password !== value.confirmPassword) { + errors.confirmPassword = 'Passwords must match' + } + + return Object.keys(errors).length > 0 ? { fields: errors } : null + } +} + +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: { + username: true, + email: false, + + }, + } + + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: config, + }) + + expect(form.shouldApplySchemaToField('username')).toBe(false) + expect(form.shouldApplySchemaToField('email')).toBe(true) + expect(form.shouldApplySchemaToField('password')).toBe(true) + expect(form.shouldApplySchemaToField('confirmPassword')).toBe(true) + }) + + 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', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onChange: mockSchemaValidator, + }, + }) + + + const usernameField = form.getFieldInfo('username') + const emailField = form.getFieldInfo('email') + const passwordField = form.getFieldInfo('password') + const confirmPasswordField = form.getFieldInfo('confirmPassword') + + + await form.validateAllFields('change') + + + expect(form.getFieldMeta('username')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('email')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() + }) + + it('should not apply schema errors when disableFieldMapping is true', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onChange: mockSchemaValidator, + }, + disableFieldMapping: true, + }) + + + form.getFieldInfo('username') + form.getFieldInfo('email') + form.getFieldInfo('password') + form.getFieldInfo('confirmPassword') + + + await form.validateAllFields('change') + + + expect(form.getFieldMeta('username')?.errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('email')?.errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('password')?.errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeUndefined() + }) + + it('should selectively apply schema errors based on field configuration', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onChange: mockSchemaValidator, + }, + disableFieldMapping: { + fields: { + username: true, + email: false, + + }, + }, + }) + + + form.getFieldInfo('username') + form.getFieldInfo('email') + form.getFieldInfo('password') + form.getFieldInfo('confirmPassword') + + + await form.validateAllFields('change') + + + expect(form.getFieldMeta('username')?.errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('email')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() + }) + + it('should handle delayed field mounting with schema errors', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onChange: mockSchemaValidator, + }, + disableFieldMapping: { + fields: { + username: false, + email: true, + }, + }, + }) + + + await form.validateAllFields('change') + + + form.getFieldInfo('username') + form.getFieldInfo('email') + form.getFieldInfo('password') + form.getFieldInfo('confirmPassword') + + + await form.validateAllFields('change') + + + expect(form.getFieldMeta('username')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('email')?.errorMap.onChange).toBeUndefined() + expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() + expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() + }) + + it('should preserve field-level validators when schema mapping is disabled', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onChange: mockSchemaValidator, + }, + disableFieldMapping: true, + }) + + + const usernameField = form.getFieldInfo('username') + if (usernameField.instance) { + usernameField.instance.options.validators = { + onChange: ({ value }) => value ? undefined : 'Field-level error', + } + } + + + await form.validateAllFields('change') + + + const usernameMeta = form.getFieldMeta('username') + expect(usernameMeta?.errorMap.onChange).toBeUndefined() + + + form.setFieldValue('username', '') + await form.validateField('username', 'change') + + + const updatedMeta = form.getFieldMeta('username') + expect(updatedMeta?.errorMap.onChange).toBe('Field-level error') + }) + }) + + describe('performance and edge cases', () => { + 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) + }) + + it('should handle undefined field names gracefully', () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: '', + password: '', + confirmPassword: '', + }, + disableFieldMapping: { + fields: { + username: true, + }, + }, + }) + + + expect(form.shouldApplySchemaToField('nonExistentField' as any)).toBe(true) + }) + + it('should not impact form submission validation', async () => { + const form = new FormApi({ + defaultValues: { + username: '', + email: 'invalid-email', + password: '123', + confirmPassword: '456', + }, + validators: { + onSubmit: mockSchemaValidator, + }, + disableFieldMapping: true, + }) + + + form.getFieldInfo('username') + form.getFieldInfo('email') + + + let submitCalled = false + form.options.onSubmit = () => { + submitCalled = true + } + + await form.handleSubmit() + + + expect(submitCalled).toBe(false) + expect(form.state.isFieldsValid).toBe(false) + }) + }) +}) 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..d02e819fa --- /dev/null +++ b/packages/form-core/tests/disableFieldMapping.spec.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { FieldApi, FormApi } from '../src/index' +import type { FieldMappingConfig } from '../src/types' + +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: { + }, + }, + }) + 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) + }) + }) +}) From 960bbebe3d2f2006d97ca06bd8c508509a8a9fce Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Tue, 16 Sep 2025 15:23:34 +0900 Subject: [PATCH 2/3] refactor: move and update disable field mapping tests to new location --- .../__tests__/disable-field-mapping.test.ts | 371 ------------------ .../tests/disableFieldMapping.spec.ts | 16 +- 2 files changed, 9 insertions(+), 378 deletions(-) delete mode 100644 packages/form-core/src/__tests__/disable-field-mapping.test.ts diff --git a/packages/form-core/src/__tests__/disable-field-mapping.test.ts b/packages/form-core/src/__tests__/disable-field-mapping.test.ts deleted file mode 100644 index b5d25415e..000000000 --- a/packages/form-core/src/__tests__/disable-field-mapping.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { FormApi } from '../FormApi' -import type { FieldMappingConfig } from '../types' - -interface TestFormData { - username: string - email: string - password: string - confirmPassword: string -} - -const mockSchemaValidator = { - validate: (value: TestFormData) => { - const errors: Record = {} - - if (!value.username) { - errors.username = 'Username is required' - } - if (!value.email || !value.email.includes('@')) { - errors.email = 'Valid email is required' - } - if (!value.password || value.password.length < 8) { - errors.password = 'Password must be at least 8 characters' - } - if (value.password !== value.confirmPassword) { - errors.confirmPassword = 'Passwords must match' - } - - return Object.keys(errors).length > 0 ? { fields: errors } : null - } -} - -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: { - username: true, - email: false, - - }, - } - - const form = new FormApi({ - defaultValues: { - username: '', - email: '', - password: '', - confirmPassword: '', - }, - disableFieldMapping: config, - }) - - expect(form.shouldApplySchemaToField('username')).toBe(false) - expect(form.shouldApplySchemaToField('email')).toBe(true) - expect(form.shouldApplySchemaToField('password')).toBe(true) - expect(form.shouldApplySchemaToField('confirmPassword')).toBe(true) - }) - - 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', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onChange: mockSchemaValidator, - }, - }) - - - const usernameField = form.getFieldInfo('username') - const emailField = form.getFieldInfo('email') - const passwordField = form.getFieldInfo('password') - const confirmPasswordField = form.getFieldInfo('confirmPassword') - - - await form.validateAllFields('change') - - - expect(form.getFieldMeta('username')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('email')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() - }) - - it('should not apply schema errors when disableFieldMapping is true', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onChange: mockSchemaValidator, - }, - disableFieldMapping: true, - }) - - - form.getFieldInfo('username') - form.getFieldInfo('email') - form.getFieldInfo('password') - form.getFieldInfo('confirmPassword') - - - await form.validateAllFields('change') - - - expect(form.getFieldMeta('username')?.errorMap.onChange).toBeUndefined() - expect(form.getFieldMeta('email')?.errorMap.onChange).toBeUndefined() - expect(form.getFieldMeta('password')?.errorMap.onChange).toBeUndefined() - expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeUndefined() - }) - - it('should selectively apply schema errors based on field configuration', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onChange: mockSchemaValidator, - }, - disableFieldMapping: { - fields: { - username: true, - email: false, - - }, - }, - }) - - - form.getFieldInfo('username') - form.getFieldInfo('email') - form.getFieldInfo('password') - form.getFieldInfo('confirmPassword') - - - await form.validateAllFields('change') - - - expect(form.getFieldMeta('username')?.errorMap.onChange).toBeUndefined() - expect(form.getFieldMeta('email')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() - }) - - it('should handle delayed field mounting with schema errors', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onChange: mockSchemaValidator, - }, - disableFieldMapping: { - fields: { - username: false, - email: true, - }, - }, - }) - - - await form.validateAllFields('change') - - - form.getFieldInfo('username') - form.getFieldInfo('email') - form.getFieldInfo('password') - form.getFieldInfo('confirmPassword') - - - await form.validateAllFields('change') - - - expect(form.getFieldMeta('username')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('email')?.errorMap.onChange).toBeUndefined() - expect(form.getFieldMeta('password')?.errorMap.onChange).toBeDefined() - expect(form.getFieldMeta('confirmPassword')?.errorMap.onChange).toBeDefined() - }) - - it('should preserve field-level validators when schema mapping is disabled', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onChange: mockSchemaValidator, - }, - disableFieldMapping: true, - }) - - - const usernameField = form.getFieldInfo('username') - if (usernameField.instance) { - usernameField.instance.options.validators = { - onChange: ({ value }) => value ? undefined : 'Field-level error', - } - } - - - await form.validateAllFields('change') - - - const usernameMeta = form.getFieldMeta('username') - expect(usernameMeta?.errorMap.onChange).toBeUndefined() - - - form.setFieldValue('username', '') - await form.validateField('username', 'change') - - - const updatedMeta = form.getFieldMeta('username') - expect(updatedMeta?.errorMap.onChange).toBe('Field-level error') - }) - }) - - describe('performance and edge cases', () => { - 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) - }) - - it('should handle undefined field names gracefully', () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: '', - password: '', - confirmPassword: '', - }, - disableFieldMapping: { - fields: { - username: true, - }, - }, - }) - - - expect(form.shouldApplySchemaToField('nonExistentField' as any)).toBe(true) - }) - - it('should not impact form submission validation', async () => { - const form = new FormApi({ - defaultValues: { - username: '', - email: 'invalid-email', - password: '123', - confirmPassword: '456', - }, - validators: { - onSubmit: mockSchemaValidator, - }, - disableFieldMapping: true, - }) - - - form.getFieldInfo('username') - form.getFieldInfo('email') - - - let submitCalled = false - form.options.onSubmit = () => { - submitCalled = true - } - - await form.handleSubmit() - - - expect(submitCalled).toBe(false) - expect(form.state.isFieldsValid).toBe(false) - }) - }) -}) diff --git a/packages/form-core/tests/disableFieldMapping.spec.ts b/packages/form-core/tests/disableFieldMapping.spec.ts index d02e819fa..8da33a0d3 100644 --- a/packages/form-core/tests/disableFieldMapping.spec.ts +++ b/packages/form-core/tests/disableFieldMapping.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { FieldApi, FormApi } from '../src/index' -import type { FieldMappingConfig } from '../src/types' +import type { FieldMappingConfig } from '../src/index' interface TestFormData { username: string @@ -23,7 +23,7 @@ const testSchema = z.object({ describe('disableFieldMapping', () => { describe('shouldApplySchemaToField method', () => { it('should return true when no configuration is provided (default behavior)', () => { - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', @@ -38,7 +38,7 @@ describe('disableFieldMapping', () => { }) it('should return false for all fields when disableFieldMapping is true', () => { - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', @@ -54,7 +54,7 @@ describe('disableFieldMapping', () => { }) it('should return true for all fields when disableFieldMapping is false', () => { - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', @@ -75,7 +75,7 @@ describe('disableFieldMapping', () => { }, } - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', @@ -88,7 +88,7 @@ describe('disableFieldMapping', () => { }) it('should handle empty fields configuration', () => { - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', @@ -192,6 +192,8 @@ describe('disableFieldMapping', () => { }, disableFieldMapping: { fields: { + username: true, + email: false, }, }, }) @@ -224,7 +226,7 @@ describe('disableFieldMapping', () => { }) it('should handle configuration changes at runtime', () => { - const form = new FormApi({ + const form = new FormApi({ defaultValues: { username: '', email: '', From 007504b98c403267762e1dea4dac3e4e701a4cbc Mon Sep 17 00:00:00 2001 From: JIHOON LEE Date: Wed, 17 Sep 2025 09:11:03 +0900 Subject: [PATCH 3/3] docs: document disabled field mapping check in markdown --- .../react/guides/disable-field-mapping.md | 349 ++++++++++++++++++ .../react/disable-field-mapping/README.md | 60 +++ .../react/disable-field-mapping/index.html | 12 + .../react/disable-field-mapping/package.json | 24 ++ .../react/disable-field-mapping/src/index.tsx | 234 ++++++++++++ .../react/disable-field-mapping/tsconfig.json | 21 ++ .../disable-field-mapping/vite.config.ts | 9 + 7 files changed, 709 insertions(+) create mode 100644 docs/framework/react/guides/disable-field-mapping.md create mode 100644 examples/react/disable-field-mapping/README.md create mode 100644 examples/react/disable-field-mapping/index.html create mode 100644 examples/react/disable-field-mapping/package.json create mode 100644 examples/react/disable-field-mapping/src/index.tsx create mode 100644 examples/react/disable-field-mapping/tsconfig.json create mode 100644 examples/react/disable-field-mapping/vite.config.ts 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, + }, +})