diff --git a/packages/form-core/src/FieldGroupApi.ts b/packages/form-core/src/FieldGroupApi.ts index 32dcfe7a0..0bc575d67 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, @@ -175,6 +175,57 @@ 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, + any, + any + >, + >( + props: TOptions, + ): TOptions => { + const newProps = { ...props } + const validators = newProps.validators + + newProps.name = this.getFormFieldName(props.name) + + 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() { diff --git a/packages/form-core/tests/FieldGroupApi.spec.ts b/packages/form-core/tests/FieldGroupApi.spec.ts index a58fd7846..2ff20790d 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 = { @@ -919,4 +918,143 @@ describe('field group api', () => { 'complexValue.prop1', ) }) + + it('should remap the name of field options correctly', () => { + const form = new FormApi({ + defaultValues: { + user: { + profile: { + personal: { + firstName: '', + lastName: '', + email: '', + }, + preferences: { + theme: 'light', + notifications: true, + }, + }, + settings: { + privacy: { + shareData: false, + allowMarketing: true, + }, + }, + }, + alternateProfile: { + firstName: '', + lastName: '', + email: '', + }, + }, + }) + form.mount() + + const fieldGroupString = new FieldGroupApi({ + form, + fields: 'user.profile.personal', + defaultValues: { firstName: '' }, + }) + fieldGroupString.mount() + + const props1 = { + name: 'firstName', + } + const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) + expect(remappedProps1.name).toBe('user.profile.personal.firstName') + + const fieldGroupObject = new FieldGroupApi({ + form, + fields: { + firstName: 'alternateProfile.firstName', + lastName: 'alternateProfile.lastName', + email: 'alternateProfile.email', + }, + defaultValues: { firstName: '' }, + }) + fieldGroupObject.mount() + + const props2 = { + name: 'firstName', + } + const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) + expect(remappedProps2.name).toBe('alternateProfile.firstName') + }) + + it('should remap listener paths with its remapFieldProps method', () => { + const form = new FormApi({ + defaultValues: { + user: { + profile: { + personal: { + firstName: '', + lastName: '', + email: '', + }, + preferences: { + theme: 'light', + notifications: true, + }, + }, + settings: { + privacy: { + shareData: false, + allowMarketing: true, + }, + }, + }, + alternateProfile: { + firstName: '', + lastName: '', + email: '', + }, + }, + }) + form.mount() + + const fieldGroupString = new FieldGroupApi({ + form, + fields: 'user.profile.personal', + defaultValues: { firstName: '', lastName: '', email: '' }, + }) + fieldGroupString.mount() + + const props1 = { + name: 'email', + validators: { + onChangeListenTo: ['firstName'], + onBlurListenTo: ['lastName'], + }, + } + const remappedProps1 = fieldGroupString.getFormFieldOptions(props1) + expect(remappedProps1.validators.onChangeListenTo).toEqual([ + 'user.profile.personal.firstName', + ]) + expect(remappedProps1.validators.onBlurListenTo).toEqual([ + 'user.profile.personal.lastName', + ]) + + const fieldGroupObject = new FieldGroupApi({ + form, + fields: { + firstName: 'alternateProfile.firstName', + lastName: 'alternateProfile.lastName', + email: 'alternateProfile.email', + }, + defaultValues: { firstName: '', lastName: '', email: '' }, + }) + fieldGroupObject.mount() + + const props2 = { + name: 'email', + validators: { + onChangeListenTo: ['firstName', 'lastName'], + }, + } + const remappedProps2 = fieldGroupObject.getFormFieldOptions(props2) + expect(remappedProps2.validators.onChangeListenTo).toEqual([ + 'alternateProfile.firstName', + 'alternateProfile.lastName', + ]) + }) }) diff --git a/packages/react-form/src/useField.tsx b/packages/react-form/src/useField.tsx index 8f3409432..61d12c730 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' @@ -516,32 +517,63 @@ export type LensFieldComponent< >({ children, ...fieldOptions -}: FieldComponentBoundProps< - unknown, - string, - TData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - undefined | FormValidateOrFn, - undefined | FormValidateOrFn, - undefined | FormAsyncValidateOrFn, - 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, + TOnDynamic, + TOnDynamicAsync, + undefined | FormValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + 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, + TOnDynamic, + TOnDynamicAsync + >, + '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/src/useFieldGroup.tsx b/packages/react-form/src/useFieldGroup.tsx index 09017775a..4324502cb 100644 --- a/packages/react-form/src/useFieldGroup.tsx +++ b/packages/react-form/src/useFieldGroup.tsx @@ -210,21 +210,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 } diff --git a/packages/react-form/tests/createFormHook.test.tsx b/packages/react-form/tests/createFormHook.test.tsx index d6cd338a8..e0e1feb4f 100644 --- a/packages/react-form/tests/createFormHook.test.tsx +++ b/packages/react-form/tests/createFormHook.test.tsx @@ -451,6 +451,90 @@ describe('createFormHook', () => { 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() + }) + it('should accept formId and return it', async () => { function Submit() { const form = useFormContext()