From 4725028533e37bc0d32b83b77f7b11550e7a4f18 Mon Sep 17 00:00:00 2001 From: Kamil Kusy Date: Fri, 1 Aug 2025 23:18:27 +0200 Subject: [PATCH 1/5] fix(react-form): remap listener paths in withFieldGroup --- packages/form-core/src/FieldGroupApi.ts | 39 ++++++++++++++ .../form-core/tests/FieldGroupApi.spec.ts | 53 +++++++++++++++++++ packages/react-form/src/useFieldGroup.tsx | 4 +- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 981587a60..139aa9a53 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -458,4 +458,43 @@ export class FieldGroupApi< validateAllFields = (cause: ValidationCause) => this.form.validateAllFields(cause) + + /** + * Remaps field validator listener paths to their full form paths. + */ + remapFieldProps = ( + props: TProps, + ): TProps => { + const newProps = { ...props } + const validators = newProps.validators + + if ( + validators && + (validators.onChangeListenTo || validators.onBlurListenTo) + ) { + const newValidators = { ...validators } + + const remapListenTo = (listenTo: DeepKeys[] | undefined) => { + if (!listenTo) return undefined + return listenTo.map((localFieldName) => + this.getFormFieldName(localFieldName), + ) + } + + if (newValidators.onChangeListenTo) { + newValidators.onChangeListenTo = remapListenTo( + newValidators.onChangeListenTo, + ) + } + if (newValidators.onBlurListenTo) { + newValidators.onBlurListenTo = remapListenTo( + newValidators.onBlurListenTo, + ) + } + + newProps.validators = newValidators + } + + return newProps + } } diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index a58fd7846..5da0cf773 100644 --- a/packages/form-core/tests/FieldGroupApi.spec.ts +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -919,4 +919,57 @@ describe('field group api', () => { 'complexValue.prop1', ) }) + + it('should remap listener paths with its remapFieldProps method', () => { + const form = new FormApi({ + defaultValues: { + account: { + password: '', + confirmPassword: '', + }, + userPassword: '', + userConfirmPassword: '', + }, + }) + form.mount() + + const fieldGroupString = new FieldGroupApi({ + form, + fields: 'account', + defaultValues: { password: '', confirmPassword: '' }, + }) + fieldGroupString.mount() + + const props1 = { + validators: { + onChangeListenTo: ['password'], + onBlurListenTo: ['confirmPassword'], + }, + } + const remappedProps1 = fieldGroupString.remapFieldProps(props1) + expect(remappedProps1.validators.onChangeListenTo).toEqual([ + 'account.password', + ]) + expect(remappedProps1.validators.onBlurListenTo).toEqual([ + 'account.confirmPassword', + ]) + + const fieldGroupObject = new FieldGroupApi({ + form, + fields: { + password: 'userPassword', + confirmPassword: 'userConfirmPassword', + }, + defaultValues: { password: '', confirmPassword: '' }, + }) + fieldGroupObject.mount() + + const props2 = { + validators: { + onChangeListenTo: ['password'], + }, + } + const remappedProps2 = fieldGroupObject.remapFieldProps(props2) + expect(remappedProps2.validators.onChangeListenTo).toEqual(['userPassword']) + }) }) diff --git a/packages/react-form/src/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx index 2966947a4..5ac5db1d1 100644 --- a/packages/react-form/src/useFieldGroup.tsx +++ b/packages/react-form/src/useFieldGroup.tsx @@ -198,7 +198,7 @@ export function useFieldGroup< return ( ) as never } @@ -207,7 +207,7 @@ export function useFieldGroup< return ( ) as never } From 56f87cc13fe537ed81316d5875fd1658a90801e7 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 2 Aug 2025 15:43:28 +0200 Subject: [PATCH 2/5] refactor: adjust types for getFormFieldOptions --- packages/form-core/src/FieldGroupApi.ts | 88 ++++++++++--------- .../form-core/tests/FieldGroupApi.spec.ts | 7 +- packages/react-form/src/useFieldGroup.tsx | 14 +-- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 139aa9a53..7d4d044cf 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -6,7 +6,7 @@ import type { FormAsyncValidateOrFn, FormValidateOrFn, } from './FormApi' -import type { AnyFieldMeta, AnyFieldMetaBase } from './FieldApi' +import type { AnyFieldMetaBase, FieldOptions } from './FieldApi' import type { DeepKeys, DeepKeysOfType, @@ -163,6 +163,53 @@ export class FieldGroupApi< return concatenatePaths(formMappedPath, restOfPath) } + /** + * Get the field options with the true form DeepKeys for validators + * @private + */ + getFormFieldOptions = < + TOptions extends FieldOptions< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any + >, + >( + props: TOptions, + ): TOptions => { + const newProps = { ...props } + const validators = newProps.validators + + if ( + validators && + (validators.onChangeListenTo || validators.onBlurListenTo) + ) { + const newValidators = { ...validators } + + const remapListenTo = (listenTo: DeepKeys[] | undefined) => { + if (!listenTo) return undefined + return listenTo.map((localFieldName) => + this.getFormFieldName(localFieldName), + ) + } + + newValidators.onChangeListenTo = remapListenTo( + validators.onChangeListenTo, + ) + newValidators.onBlurListenTo = remapListenTo(validators.onBlurListenTo) + + newProps.validators = newValidators + } + + return newProps + } + store: Derived> get state() { @@ -458,43 +505,4 @@ export class FieldGroupApi< validateAllFields = (cause: ValidationCause) => this.form.validateAllFields(cause) - - /** - * Remaps field validator listener paths to their full form paths. - */ - remapFieldProps = ( - props: TProps, - ): TProps => { - const newProps = { ...props } - const validators = newProps.validators - - if ( - validators && - (validators.onChangeListenTo || validators.onBlurListenTo) - ) { - const newValidators = { ...validators } - - const remapListenTo = (listenTo: DeepKeys[] | undefined) => { - if (!listenTo) return undefined - return listenTo.map((localFieldName) => - this.getFormFieldName(localFieldName), - ) - } - - if (newValidators.onChangeListenTo) { - newValidators.onChangeListenTo = remapListenTo( - newValidators.onChangeListenTo, - ) - } - if (newValidators.onBlurListenTo) { - newValidators.onBlurListenTo = remapListenTo( - newValidators.onBlurListenTo, - ) - } - - newProps.validators = newValidators - } - - return newProps - } } diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index 5da0cf773..e69305d77 100644 --- a/packages/form-core/tests/FieldGroupApi.spec.ts +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { FieldApi, FieldGroupApi, FormApi } from '../src/index' -import { defaultFieldMeta } from '../src/metaHelper' describe('field group api', () => { type Person = { @@ -941,12 +940,13 @@ describe('field group api', () => { fieldGroupString.mount() const props1 = { + name: 'confirmPassword', validators: { onChangeListenTo: ['password'], onBlurListenTo: ['confirmPassword'], }, } - const remappedProps1 = fieldGroupString.remapFieldProps(props1) + const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) expect(remappedProps1.validators.onChangeListenTo).toEqual([ 'account.password', ]) @@ -965,11 +965,12 @@ describe('field group api', () => { fieldGroupObject.mount() const props2 = { + name: 'confirmPassword', validators: { onChangeListenTo: ['password'], }, } - const remappedProps2 = fieldGroupObject.remapFieldProps(props2) + const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) expect(remappedProps2.validators.onChangeListenTo).toEqual(['userPassword']) }) }) diff --git a/packages/react-form/src/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx index 5ac5db1d1..bb94c18d0 100644 --- a/packages/react-form/src/useFieldGroup.tsx +++ b/packages/react-form/src/useFieldGroup.tsx @@ -194,21 +194,15 @@ export function useFieldGroup< return } - extendedApi.AppField = function AppField({ name, ...appFieldProps }) { + extendedApi.AppField = function AppField(props) { return ( - + ) as never } - extendedApi.Field = function Field({ name, ...fieldProps }) { + extendedApi.Field = function Field(props) { return ( - + ) as never } From c1d8eb17f0faebc6d7373ea7fcb450833616a0b9 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Sat, 2 Aug 2025 17:03:02 +0200 Subject: [PATCH 3/5] refactor(react-form): fix typing for using group.Field --- packages/form-core/src/FieldGroupApi.ts | 2 + .../form-core/tests/FieldGroupApi.spec.ts | 43 ++++++++++ packages/react-form/src/useField.tsx | 74 +++++++++++----- .../react-form/tests/createFormHook.test.tsx | 84 +++++++++++++++++++ 4 files changed, 181 insertions(+), 22 deletions(-) diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 7d4d044cf..817cf7d54 100644 --- a/packages/form-core/src/FieldGroupApi.ts +++ b/packages/form-core/src/FieldGroupApi.ts @@ -186,6 +186,8 @@ export class FieldGroupApi< const newProps = { ...props } const validators = newProps.validators + newProps.name = this.getFormFieldName(props.name) + if ( validators && (validators.onChangeListenTo || validators.onBlurListenTo) diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index e69305d77..800d1d5d2 100644 --- a/packages/form-core/tests/FieldGroupApi.spec.ts +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -919,6 +919,49 @@ describe('field group api', () => { ) }) + it('should remap the name of field options correctly', () => { + const form = new FormApi({ + defaultValues: { + account: { + password: '', + confirmPassword: '', + }, + userPassword: '', + userConfirmPassword: '', + }, + }) + form.mount() + + const fieldGroupString = new FieldGroupApi({ + form, + fields: 'account', + defaultValues: { password: '' }, + }) + fieldGroupString.mount() + + const props1 = { + name: 'password', + } + const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) + expect(remappedProps1.name).toBe('account.password') + + const fieldGroupObject = new FieldGroupApi({ + form, + fields: { + password: 'userPassword', + confirmPassword: 'userConfirmPassword', + }, + defaultValues: { password: '' }, + }) + fieldGroupObject.mount() + + const props2 = { + name: 'password', + } + const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) + expect(remappedProps2.name).toBe('userPassword') + }) + it('should remap listener paths with its remapFieldProps method', () => { const form = new FormApi({ defaultValues: { diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 40733d12a..076efe3e7 100644 --- a/packages/react-form/src/useField.tsx +++ b/packages/react-form/src/useField.tsx @@ -7,6 +7,7 @@ import type { DeepValue, FieldAsyncValidateOrFn, FieldValidateOrFn, + FieldValidators, FormAsyncValidateOrFn, FormValidateOrFn, } from '@tanstack/form-core' @@ -446,28 +447,57 @@ export type LensFieldComponent< >({ children, ...fieldOptions -}: FieldComponentBoundProps< - unknown, - string, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - undefined | FormValidateOrFn, - undefined | FormValidateOrFn, - undefined | FormAsyncValidateOrFn, - undefined | FormValidateOrFn, - undefined | FormAsyncValidateOrFn, - undefined | FormValidateOrFn, - undefined | FormAsyncValidateOrFn, - undefined | FormAsyncValidateOrFn, - TParentSubmitMeta, - ExtendedApi -> & { name: TName }) => ReactNode +}: Omit< + FieldComponentBoundProps< + unknown, + string, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + undefined | FormValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormAsyncValidateOrFn, + TParentSubmitMeta, + ExtendedApi + >, + 'name' | 'validators' +> & { + name: TName + validators?: Omit< + FieldValidators< + unknown, + string, + TData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync + >, + 'onChangeListenTo' | 'onBlurListenTo' + > & { + /** + * An optional list of field names that should trigger this field's `onChange` and `onChangeAsync` events when its value changes + */ + onChangeListenTo?: DeepKeys[] + /** + * An optional list of field names that should trigger this field's `onBlur` and `onBlurAsync` events when its value changes + */ + onBlurListenTo?: DeepKeys[] + } +}) => ReactNode /** * A function component that takes field options and a render function as children and returns a React component. diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index 083ba211e..6424edbb5 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -450,4 +450,88 @@ describe('createFormHook', () => { const inputField1 = getByLabelText('unrelated') expect(inputField1).toHaveValue('John') }) + + it('should remap GroupFieldApi.Field validators to the correct names', () => { + const FieldGroupString = withFieldGroup({ + defaultValues: { password: '', confirmPassword: '' }, + render: function Render({ group }) { + return ( + null, + onChangeListenTo: ['password'], + onBlur: () => null, + onBlurListenTo: ['confirmPassword'], + }} + > + {(field) => { + expect(field.options.validators?.onChangeListenTo).toStrictEqual([ + 'account.password', + ]) + expect(field.options.validators?.onBlurListenTo).toStrictEqual([ + 'account.confirmPassword', + ]) + return <> + }} + + ) + }, + }) + + const FieldGroupObject = withFieldGroup({ + defaultValues: { password: '', confirmPassword: '' }, + render: function Render({ group }) { + return ( + null, + onChangeListenTo: ['password'], + onBlur: () => null, + onBlurListenTo: ['confirmPassword'], + }} + > + {(field) => { + expect(field.options.validators?.onChangeListenTo).toStrictEqual([ + 'userPassword', + ]) + expect(field.options.validators?.onBlurListenTo).toStrictEqual([ + 'userConfirmPassword', + ]) + return <> + }} + + ) + }, + }) + + const Parent = () => { + const form = useAppForm({ + defaultValues: { + account: { + password: '', + confirmPassword: '', + }, + userPassword: '', + userConfirmPassword: '', + }, + }) + + return ( + <> + + + + ) + } + + render() + }) }) From c7c1d84d6a212bf2a4d28e30fd99d2201ffb726c Mon Sep 17 00:00:00 2001 From: Kamil Kusy Date: Thu, 14 Aug 2025 17:24:18 +0200 Subject: [PATCH 4/5] modify tests --- .../form-core/tests/FieldGroupApi.spec.ts | 105 ++++++++++++------ 1 file changed, 73 insertions(+), 32 deletions(-) diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index 800d1d5d2..2ff20790d 100644 --- a/packages/form-core/tests/FieldGroupApi.spec.ts +++ b/packages/form-core/tests/FieldGroupApi.spec.ts @@ -922,98 +922,139 @@ describe('field group api', () => { it('should remap the name of field options correctly', () => { const form = new FormApi({ defaultValues: { - account: { - password: '', - confirmPassword: '', + user: { + profile: { + personal: { + firstName: '', + lastName: '', + email: '', + }, + preferences: { + theme: 'light', + notifications: true, + }, + }, + settings: { + privacy: { + shareData: false, + allowMarketing: true, + }, + }, + }, + alternateProfile: { + firstName: '', + lastName: '', + email: '', }, - userPassword: '', - userConfirmPassword: '', }, }) form.mount() const fieldGroupString = new FieldGroupApi({ form, - fields: 'account', - defaultValues: { password: '' }, + fields: 'user.profile.personal', + defaultValues: { firstName: '' }, }) fieldGroupString.mount() const props1 = { - name: 'password', + name: 'firstName', } const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) - expect(remappedProps1.name).toBe('account.password') + expect(remappedProps1.name).toBe('user.profile.personal.firstName') const fieldGroupObject = new FieldGroupApi({ form, fields: { - password: 'userPassword', - confirmPassword: 'userConfirmPassword', + firstName: 'alternateProfile.firstName', + lastName: 'alternateProfile.lastName', + email: 'alternateProfile.email', }, - defaultValues: { password: '' }, + defaultValues: { firstName: '' }, }) fieldGroupObject.mount() const props2 = { - name: 'password', + name: 'firstName', } const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) - expect(remappedProps2.name).toBe('userPassword') + expect(remappedProps2.name).toBe('alternateProfile.firstName') }) it('should remap listener paths with its remapFieldProps method', () => { const form = new FormApi({ defaultValues: { - account: { - password: '', - confirmPassword: '', + user: { + profile: { + personal: { + firstName: '', + lastName: '', + email: '', + }, + preferences: { + theme: 'light', + notifications: true, + }, + }, + settings: { + privacy: { + shareData: false, + allowMarketing: true, + }, + }, + }, + alternateProfile: { + firstName: '', + lastName: '', + email: '', }, - userPassword: '', - userConfirmPassword: '', }, }) form.mount() const fieldGroupString = new FieldGroupApi({ form, - fields: 'account', - defaultValues: { password: '', confirmPassword: '' }, + fields: 'user.profile.personal', + defaultValues: { firstName: '', lastName: '', email: '' }, }) fieldGroupString.mount() const props1 = { - name: 'confirmPassword', + name: 'email', validators: { - onChangeListenTo: ['password'], - onBlurListenTo: ['confirmPassword'], + onChangeListenTo: ['firstName'], + onBlurListenTo: ['lastName'], }, } const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) expect(remappedProps1.validators.onChangeListenTo).toEqual([ - 'account.password', + 'user.profile.personal.firstName', ]) expect(remappedProps1.validators.onBlurListenTo).toEqual([ - 'account.confirmPassword', + 'user.profile.personal.lastName', ]) const fieldGroupObject = new FieldGroupApi({ form, fields: { - password: 'userPassword', - confirmPassword: 'userConfirmPassword', + firstName: 'alternateProfile.firstName', + lastName: 'alternateProfile.lastName', + email: 'alternateProfile.email', }, - defaultValues: { password: '', confirmPassword: '' }, + defaultValues: { firstName: '', lastName: '', email: '' }, }) fieldGroupObject.mount() const props2 = { - name: 'confirmPassword', + name: 'email', validators: { - onChangeListenTo: ['password'], + onChangeListenTo: ['firstName', 'lastName'], }, } const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) - expect(remappedProps2.validators.onChangeListenTo).toEqual(['userPassword']) + expect(remappedProps2.validators.onChangeListenTo).toEqual([ + 'alternateProfile.firstName', + 'alternateProfile.lastName', + ]) }) }) From e16ec4e1452af8a151be316778371045b7340a41 Mon Sep 17 00:00:00 2001 From: LeCarbonator <18158911+LeCarbonator@users.noreply.github.com> Date: Fri, 15 Aug 2025 13:55:37 +0200 Subject: [PATCH 5/5] chore: resolve suggestions and discussions --- packages/react-form/src/useFieldGroup.tsx | 6 ++---- packages/react-form/tests/createFormHook.test.tsx | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-form/src/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx index 4324502cb..d0a00352d 100644 --- a/packages/react-form/src/useFieldGroup.tsx +++ b/packages/react-form/src/useFieldGroup.tsx @@ -213,13 +213,11 @@ export function useFieldGroup< extendedApi.AppField = function AppField(props) { return ( - ) as never + ) } extendedApi.Field = function Field(props) { - return ( - - ) as never + return } extendedApi.Subscribe = function Subscribe(props: any) { diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index e0e1feb4f..7f57c3e2b 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -451,7 +451,7 @@ describe('createFormHook', () => { expect(inputField1).toHaveValue('John') }) - it('should remap GroupFieldApi.Field validators to the correct names', () => { + it('should remap FieldGroupApi.Field validators to the correct names', () => { const FieldGroupString = withFieldGroup({ defaultValues: { password: '', confirmPassword: '' }, render: function Render({ group }) {