From 5ba8ae2ce67721d40f10eaf8119e63d78538f2c2 Mon Sep 17 00:00:00 2001 From: vijayabaskar Date: Tue, 30 Sep 2025 22:41:55 +0530 Subject: [PATCH 1/3] feat(solid-form): add withFieldGroup API --- .../solid/guides/form-composition.md | 247 ++++++++- .../src/features/people/emergency-contact.tsx | 22 + .../large-form/src/features/people/page.tsx | 10 +- examples/solid/large-form/src/hooks/form.tsx | 2 +- packages/solid-form/src/createField.tsx | 92 +++- packages/solid-form/src/createFieldGroup.tsx | 237 +++++++++ packages/solid-form/src/createFormHook.tsx | 137 ++++- packages/solid-form/src/index.tsx | 1 + .../solid-form/tests/createFormHook.test.tsx | 472 +++++++++++++++++- 9 files changed, 1200 insertions(+), 20 deletions(-) create mode 100644 examples/solid/large-form/src/features/people/emergency-contact.tsx create mode 100644 packages/solid-form/src/createFieldGroup.tsx diff --git a/docs/framework/solid/guides/form-composition.md b/docs/framework/solid/guides/form-composition.md index 9d2c92e7c..fe36134e8 100644 --- a/docs/framework/solid/guides/form-composition.md +++ b/docs/framework/solid/guides/form-composition.md @@ -159,7 +159,7 @@ Sometimes forms get very large; it's just how it goes sometimes. While TanStack To solve this, we support breaking forms into smaller pieces using the `withForm` higher-order component. ```tsx -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, @@ -213,8 +213,243 @@ function App() { ### `withForm` FAQ > Why a higher-order component instead of a hook? +> +> While hooks are the future of Solid, higher-order components are still a powerful tool for composition. In particular, the API of `withForm` enables us to have strong type-safety without requiring users to pass generics. + +## Reusing groups of fields in multiple forms + +Sometimes, a pair of fields are so closely related that it makes sense to group and reuse them — like the password example listed in the [linked fields guide](../linked-fields.md). Instead of repeating this logic across multiple forms, you can utilize the `withFieldGroup` higher-order component. + +> Unlike `withForm`, validators cannot be specified and could be any value. +> Ensure that your fields can accept unknown error types. + +Rewriting the passwords example using `withFieldGroup` would look like this: + +```tsx +const { useAppForm, withForm, withFieldGroup } = createFormHook({ + fieldComponents: { + TextField, + ErrorInfo, + }, + formComponents: { + SubscribeButton, + }, + fieldContext, + formContext, +}) + +type PasswordFields = { + password: string + confirm_password: string +} + +// These default values are not used at runtime, but the keys are needed for mapping purposes. +// This allows you to spread `formOptions` without needing to redeclare it. +const defaultValues: PasswordFields = { + password: '', + confirm_password: '', +} + +const FieldGroupPasswordFields = withFieldGroup({ + defaultValues, + // You may also restrict the group to only use forms that implement this submit meta. + // If none is provided, any form with the right defaultValues may use it. + // onSubmitMeta: { action: '' } + + // Optional, but adds props to the `render` function in addition to `form` + props: { + // These default values are also for type-checking and are not used at runtime + title: 'Password', + }, + // Internally, you will have access to a `group` instead of a `form` + render: function Render(props) { + // access reactive values using the group store + const password = useStore( + props.group.store, + (state) => state.values.password, + ) + // or the form itself + const isSubmitting = useStore( + props.group.form.store, + (state) => state.isSubmitting, + ) + + return ( +
+

{props.title}

+ {/* Groups also have access to Field, Subscribe, Field, AppField and AppForm */} + + {(field) => } + + { + // The form could be any values, so it is typed as 'unknown' + const values: unknown = fieldApi.form.state.values + // use the group methods instead + if (value !== props.group.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }} + > + {(field) => ( +
+ + +
+ )} +
+
+ ) + }, +}) +``` -While hooks are the future of Solid, higher-order components are still a powerful tool for composition. In particular, the API of `withForm` enables us to have strong type-safety without requiring users to pass generics. +We can now use these grouped fields in any form that implements the default values: + +```tsx +// You are allowed to extend the group fields as long as the +// existing properties remain unchanged +type Account = PasswordFields & { + provider: string + username: string +} + +// You may nest the group fields wherever you want +type FormValues = { + name: string + age: number + account_data: PasswordFields + linked_accounts: Account[] +} + +const defaultValues: FormValues = { + name: '', + age: 0, + account_data: { + password: '', + confirm_password: '', + }, + linked_accounts: [ + { + provider: 'TanStack', + username: '', + password: '', + confirm_password: '', + }, + ], +} + +function App() { + const form = useAppForm(() => ({ + defaultValues, + // If the group didn't specify an `onSubmitMeta` property, + // the form may implement any meta it wants. + // Otherwise, the meta must be defined and match. + onSubmitMeta: { action: '' }, + })) + + return ( + + + + {(field) => + field().state.value.map((account, i) => ( + + )) + } + + + ) +} +``` + +### Mapping field group values to a different field + +You may want to keep the password fields on the top level of your form, or rename the properties for clarity. You can map field group values +to their true location by changing the `field` property: + +> [!IMPORTANT] +> Due to TypeScript limitations, field mapping is only allowed for objects. You can use records or arrays at the top level of a field group, but you will not be able to map the fields. + +```tsx +// To have an easier form, you can keep the fields on the top level +type FormValues = { + name: string + age: number + password: string + confirm_password: string +} + +const defaultValues: FormValues = { + name: '', + age: 0, + password: '', + confirm_password: '', +} + +function App() { + const form = useAppForm(() => ({ + defaultValues, + })) + + return ( + + + + ) +} +``` + +If you expect your fields to always be at the top level of your form, you can create a quick map +of your field groups using a helper function: + +```tsx +const defaultValues: PasswordFields = { + password: '', + confirm_password: '', +} + +const passwordFields = createFieldMap(defaultValues) +/* This generates the following map: + { + 'password': 'password', + 'confirm_password': 'confirm_password' + } +*/ + +// Usage: + +``` ## Tree-shaking form and field components @@ -316,7 +551,7 @@ function SubscribeButton(props: { label: string }) { ) } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, @@ -345,7 +580,7 @@ const ChildForm = withForm({ render: (props) => { return (
-

{title}

+

{props.title}

} @@ -360,9 +595,9 @@ const ChildForm = withForm({ // /src/features/people/page.ts const Parent = () => { - const form = useAppForm({ + const form = useAppForm(() => ({ ...formOpts, - }) + })) return } diff --git a/examples/solid/large-form/src/features/people/emergency-contact.tsx b/examples/solid/large-form/src/features/people/emergency-contact.tsx new file mode 100644 index 000000000..69cc88160 --- /dev/null +++ b/examples/solid/large-form/src/features/people/emergency-contact.tsx @@ -0,0 +1,22 @@ +import { withFieldGroup } from '../../hooks/form' + +export const FieldGroupEmergencyContact = withFieldGroup({ + defaultValues: { + phone: '', + fullName: '', + }, + render: function Render({ group }) { + return ( + <> + } + /> + } + /> + + ) + }, +}) diff --git a/examples/solid/large-form/src/features/people/page.tsx b/examples/solid/large-form/src/features/people/page.tsx index b7b6426ff..3323da564 100644 --- a/examples/solid/large-form/src/features/people/page.tsx +++ b/examples/solid/large-form/src/features/people/page.tsx @@ -1,5 +1,6 @@ import { useAppForm } from '../../hooks/form.tsx' import { AddressFields } from './address-fields.tsx' +import { FieldGroupEmergencyContact } from './emergency-contact.tsx' import { peopleFormOpts } from './shared-form.tsx' export const PeoplePage = () => { @@ -57,14 +58,7 @@ export const PeoplePage = () => { />

Emergency Contact

- } - /> - } - /> + diff --git a/examples/solid/large-form/src/hooks/form.tsx b/examples/solid/large-form/src/hooks/form.tsx index e26451d86..8ff2360d2 100644 --- a/examples/solid/large-form/src/hooks/form.tsx +++ b/examples/solid/large-form/src/hooks/form.tsx @@ -15,7 +15,7 @@ function SubscribeButton(props: { label: string }) { ) } -export const { useAppForm, withForm } = createFormHook({ +export const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index de263f00c..30ef3a87a 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -11,12 +11,13 @@ import type { DeepValue, FieldAsyncValidateOrFn, FieldValidateOrFn, + FieldValidators, FormAsyncValidateOrFn, FormValidateOrFn, Narrow, } from '@tanstack/form-core' -import type { Accessor, Component, JSX, JSXElement } from 'solid-js' +import type { Accessor, JSX, JSXElement } from 'solid-js' import type { CreateFieldOptions, CreateFieldOptionsBound } from './types' interface SolidFieldApi< @@ -625,6 +626,95 @@ interface FieldComponentProps< ) => JSXElement } + +/** + * A type alias representing a field component for a form lens data type. + */ +export type LensFieldComponent< + in out TLensData, + in out TParentSubmitMeta, + in out ExtendedApi = {}, +> = < + const TName extends DeepKeys, + TData extends DeepValue, + TOnMount extends undefined | FieldValidateOrFn, + TOnChange extends undefined | FieldValidateOrFn, + TOnChangeAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnBlur extends undefined | FieldValidateOrFn, + TOnBlurAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnSubmit extends undefined | FieldValidateOrFn, + TOnSubmitAsync extends + | undefined + | FieldAsyncValidateOrFn, + TOnDynamic extends undefined | FieldValidateOrFn, + TOnDynamicAsync extends + | undefined + | FieldAsyncValidateOrFn, +>({ + children, + ...fieldOptions +}: 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[] + } +}) => JSX.Element + export function Field< TParentData, TName extends DeepKeys, diff --git a/packages/solid-form/src/createFieldGroup.tsx b/packages/solid-form/src/createFieldGroup.tsx new file mode 100644 index 000000000..5c66c315b --- /dev/null +++ b/packages/solid-form/src/createFieldGroup.tsx @@ -0,0 +1,237 @@ +import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core' +import { useStore } from '@tanstack/solid-store' +import { onCleanup, onMount } from 'solid-js' +import type { Component, JSX, ParentProps } from 'solid-js'; +import type { + DeepKeysOfType, + FieldGroupState, + FieldsMap, + FormAsyncValidateOrFn, + FormValidateOrFn +} from '@tanstack/form-core' +import type { LensFieldComponent } from './createField' +import type { AppFieldExtendedSolidFormApi } from './createFormHook' + + +/** + * @private + */ +export type AppFieldExtendedSolidFieldGroupApi< + TFormData, + TFieldGroupData, + TFields extends + | DeepKeysOfType + | FieldsMap, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TSubmitMeta, + TFieldComponents extends Record>, + TFormComponents extends Record>, +> = FieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta +> & + NoInfer & { + AppField: LensFieldComponent< + TFieldGroupData, + TSubmitMeta, + NoInfer + > + AppForm: Component + /** + * A solid component to render form fields. With this, you can render and manage individual form fields. + */ + Field: LensFieldComponent + + /** + * A `Subscribe` function that allows you to listen and solid to changes in the form's state. It's especially useful when you need to execute side effects or render specific components in response to state updates. + */ + Subscribe: >>(props: { + selector?: (state: NoInfer>) => TSelected + children: ((state: NoInfer) => JSX.Element) | JSX.Element + }) => JSX.Element + } + +export function createFieldGroup< + TFormData, + TFieldGroupData, + TFields extends + | DeepKeysOfType + | FieldsMap, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TComponents extends Record>, + TFormComponents extends Record>, + TSubmitMeta = never, +>(opts: () => { + form: + | AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + | AppFieldExtendedSolidFieldGroupApi< + // Since this only occurs if you nest it within other form lenses, it can be more + // lenient with the types. + unknown, + TFormData, + string | FieldsMap, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + TSubmitMeta, + TComponents, + TFormComponents + > + fields: TFields + defaultValues?: TFieldGroupData + onSubmitMeta?: TSubmitMeta + formComponents: TFormComponents +}): AppFieldExtendedSolidFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents +> { + const options = opts() + const api = new FieldGroupApi(options) + const form = options.form instanceof FieldGroupApi + ? (options.form.form as AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + >) + : options.form + + const extendedApi: AppFieldExtendedSolidFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > = api as never + + extendedApi.AppForm = (appFormProps) => + extendedApi.AppField = (props) => ( + + ) + extendedApi.Field = (props) => ( + + ) + extendedApi.Subscribe = (props) => { + const data = useStore(api.store, props.selector) + + return functionalUpdate(props.children, data()) as Element + } + + let mounted = false + onMount(() => { + const cleanupFn = api.mount() + mounted = true + onCleanup(() => { + cleanupFn() + mounted = false + }) + }) + + return Object.assign(extendedApi, { + ...options.formComponents, + }) as AppFieldExtendedSolidFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > +} \ No newline at end of file diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index 9b02050e1..1af0a5072 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -3,7 +3,10 @@ import { createForm } from './createForm' import type { AnyFieldApi, AnyFormApi, + BaseFormOptions, + DeepKeysOfType, FieldApi, + FieldsMap, FormAsyncValidateOrFn, FormOptions, FormValidateOrFn, @@ -17,6 +20,7 @@ import type { } from 'solid-js' import type { FieldComponent } from './createField' import type { SolidFormExtendedApi } from './createForm' +import { createFieldGroup, type AppFieldExtendedSolidFieldGroupApi } from './createFieldGroup' /** * TypeScript inferencing is weird. @@ -142,8 +146,10 @@ interface CreateFormHookProps< formComponents: TFormComponents formContext: Context } - -type AppFieldExtendedSolidFormApi< +/** + * @private + */ +export type AppFieldExtendedSolidFormApi< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -247,6 +253,45 @@ export interface WithFormProps< ) => JSXElement } + +export interface WithFieldGroupProps< + TFieldGroupData, + TFieldComponents extends Record>, + TFormComponents extends Record>, + TSubmitMeta, + TRenderProps extends Record = Record, +> extends BaseFormOptions { + // Optional, but adds props to the `render` function outside of `form` + props?: TRenderProps + render: ( + props: ParentProps< + NoInfer & { + group: AppFieldExtendedSolidFieldGroupApi< + unknown, + TFieldGroupData, + string | FieldsMap, + undefined | FormValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormValidateOrFn, + undefined | FormAsyncValidateOrFn, + undefined | FormAsyncValidateOrFn, + // this types it as 'never' in the render prop. It should prevent any + // untyped meta passed to the handleSubmit by accident. + unknown extends TSubmitMeta ? never : TSubmitMeta, + TFieldComponents, + TFormComponents + > + } + >, + ) => JSXElement +} + + export function createFormHook< const TComponents extends Record>, const TFormComponents extends Record>, @@ -416,8 +461,96 @@ export function createFormHook< return (innerProps) => render({ ...props, ...innerProps }) } + function withFieldGroup< + TFieldGroupData, + TSubmitMeta, + TRenderProps extends Record = {}, + >({ + render, + props, + defaultValues, + }: WithFieldGroupProps< + TFieldGroupData, + TComponents, + TFormComponents, + TSubmitMeta, + TRenderProps + >): < + TFormData, + TFields extends + | DeepKeysOfType + | FieldsMap, + TOnMount extends undefined | FormValidateOrFn, + TOnChange extends undefined | FormValidateOrFn, + TOnChangeAsync extends undefined | FormAsyncValidateOrFn, + TOnBlur extends undefined | FormValidateOrFn, + TOnBlurAsync extends undefined | FormAsyncValidateOrFn, + TOnSubmit extends undefined | FormValidateOrFn, + TOnSubmitAsync extends undefined | FormAsyncValidateOrFn, + TOnDynamic extends undefined | FormValidateOrFn, + TOnDynamicAsync extends undefined | FormAsyncValidateOrFn, + TOnServer extends undefined | FormAsyncValidateOrFn, + TFormSubmitMeta, + >( + params: ParentProps< + NoInfer & { + form: + | AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, + TComponents, + TFormComponents + > + | AppFieldExtendedSolidFieldGroupApi< + // Since this only occurs if you nest it within other field groups, it can be more + // lenient with the types. + unknown, + TFormData, + string | FieldsMap, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + unknown extends TSubmitMeta ? TFormSubmitMeta : TSubmitMeta, + TComponents, + TFormComponents + > + fields: TFields + } + >, + ) => JSXElement { + return function Render(innerProps) { + const fieldGroupProps = { + form: innerProps.form, + fields: innerProps.fields, + defaultValues, + formComponents: opts.formComponents, + } + const fieldGroupApi = createFieldGroup(() => fieldGroupProps) + + return render({ ...props, ...innerProps, group: fieldGroupApi as any }) + } + } + return { useAppForm, withForm, + withFieldGroup, } } diff --git a/packages/solid-form/src/index.tsx b/packages/solid-form/src/index.tsx index 311e83a27..22acfe2fd 100644 --- a/packages/solid-form/src/index.tsx +++ b/packages/solid-form/src/index.tsx @@ -4,5 +4,6 @@ export { useStore } from '@tanstack/solid-store' export * from './createField' export * from './createForm' +export * from './createFieldGroup' export * from './createFormHook' export * from './types' diff --git a/packages/solid-form/tests/createFormHook.test.tsx b/packages/solid-form/tests/createFormHook.test.tsx index 60025aa9c..d6eea7b98 100644 --- a/packages/solid-form/tests/createFormHook.test.tsx +++ b/packages/solid-form/tests/createFormHook.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { render } from '@solidjs/testing-library' import { formOptions } from '@tanstack/form-core' -import { createFormHook, createFormHookContexts } from '../src' +import userEvent from '@testing-library/user-event' +import { createFormHook, createFormHookContexts, useStore } from '../src' + +const user = userEvent.setup() const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() @@ -28,7 +31,7 @@ function SubscribeButton({ label }: { label: string }) { ) } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, @@ -112,4 +115,469 @@ describe('createFormHook', () => { expect(input).toHaveValue('John') expect(getByText('Testing')).toBeInTheDocument() }) + + it('should handle withFieldGroup types properly', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + // Optional, but adds props to the `render` function outside of `form` + props: { + title: 'Child Form', + }, + render: ({ group, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + + return + } + + const { getByLabelText, getByText } = render(() => ) + const input = getByLabelText('First Name') + expect(input).toHaveValue('John') + expect(getByText('Testing')).toBeInTheDocument() + }) + + it('should use the correct field name in Field with withFieldGroup', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + people: [ + { + firstName: 'Jane', + lastName: 'Doe', + }, + { + firstName: 'Robert', + lastName: 'Doe', + }, + ], + }, + }) + + const ChildFormAsField = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: ({ group }) => { + return ( +
+ } + /> + + + +
+ ) + }, + }) + const ChildFormAsArray = withFieldGroup({ + defaultValues: [formOpts.defaultValues.person], + props: { + title: '', + }, + render: ({ group, title }) => { + return ( +
+

{title}

+ } + /> + + + +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + + return ( + <> + + + + + ) + } + + const { getByLabelText, getByText } = render(() => ) + const inputField1 = getByLabelText('person.firstName') + const inputArray = getByLabelText('people[0].firstName') + const inputField2 = getByLabelText('people[1].firstName') + expect(inputField1).toHaveValue('John') + expect(inputArray).toHaveValue('Jane') + expect(inputField2).toHaveValue('Robert') + expect(getByText('Testing')).toBeInTheDocument() + }) + + it('should forward Field and Subscribe to the form', () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildFormAsField = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: ({ group }) => { + return ( +
+ ( + + )} + /> + state.values.lastName}> + {(lastName) =>

{lastName}

} +
+
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + return + } + + const { getByLabelText, getByText } = render(() => ) + const input = getByLabelText('person.firstName') + expect(input).toHaveValue('John') + expect(getByText('Doe')).toBeInTheDocument() + }) + + it('should not lose focus on update with withFieldGroup', async () => { + const formOpts = formOptions({ + defaultValues: { + person: { + firstName: 'John', + lastName: 'Doe', + }, + }, + }) + + const ChildForm = withFieldGroup({ + defaultValues: formOpts.defaultValues.person, + render: function Render({ group }) { + const firstName = useStore( + group.store, + (state) => state.values.firstName, + ) + return ( +
+

{firstName()}

+ ( + + )} + /> + state.values.lastName}> + {(lastName) =>

{lastName}

} +
+
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + return + } + + const { getByLabelText } = render(() => ) + + const input = getByLabelText('person.firstName') + input.focus() + expect(input).toHaveFocus() + + await user.clear(input) + await user.type(input, 'Something') + + expect(input).toHaveFocus() + }) + + it('should allow nesting withFieldGroup in other withFieldGroups', () => { + type Nested = { + firstName: string + } + type Wrapper = { + field: Nested + } + type FormValues = { + form: Wrapper + unrelated: { something: { lastName: string } } + } + + const defaultValues: FormValues = { + form: { + field: { + firstName: 'Test', + }, + }, + unrelated: { + something: { + lastName: '', + }, + }, + } + + const LensNested = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render({ group }) { + return ( + + {(field) =>

{field().name}

} +
+ ) + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + defaultValues, + })) + return + } + + const { getByText } = render(() => ) + + expect(getByText('form.field.firstName')).toBeInTheDocument() + }) + + it('should allow mapping withFieldGroup to different values', () => { + const formOpts = formOptions({ + defaultValues: { + unrelated: 'John', + values: '', + }, + }) + + const ChildFormAsField = withFieldGroup({ + defaultValues: { firstName: '', lastName: '' }, + render: ({ group }) => { + return ( +
+ } + /> +
+ ) + }, + }) + + const Parent = () => { + const form = useAppForm(() => ({ + ...formOpts, + })) + + return ( + + ) + } + + const { getByLabelText } = render(() => ) + const inputField1 = getByLabelText('unrelated') + expect(inputField1).toHaveValue('John') + }) + + it('should remap FieldGroupApi.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() + + return ( + + ) + } + + function Comp() { + const form = useAppForm(() => ({ + formId: 'test', + })) + + return ( + +
{ + e.preventDefault() + form.handleSubmit() + }} + >
+ + state.submissionAttempts} + children={(submissionAttempts) => ( + {submissionAttempts()} + )} + /> + + +
+ ) + } + + const { getByTestId } = render(() => ) + const target = getByTestId('formId-target') + const result = getByTestId('formId-result') + + await user.click(target) + expect(result).toHaveTextContent('1') + }) }) From 64f0143b1180d71588a2da94318cb9484f2b5b49 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:34:08 +0000 Subject: [PATCH 2/3] ci: apply automated fixes and generate docs --- packages/solid-form/src/createField.tsx | 1 - packages/solid-form/src/createFieldGroup.tsx | 134 ++++++++++--------- packages/solid-form/src/createFormHook.tsx | 17 +-- 3 files changed, 77 insertions(+), 75 deletions(-) diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index 30ef3a87a..a48bc8f96 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -626,7 +626,6 @@ interface FieldComponentProps< ) => JSXElement } - /** * A type alias representing a field component for a form lens data type. */ diff --git a/packages/solid-form/src/createFieldGroup.tsx b/packages/solid-form/src/createFieldGroup.tsx index 5c66c315b..0310dd9d9 100644 --- a/packages/solid-form/src/createFieldGroup.tsx +++ b/packages/solid-form/src/createFieldGroup.tsx @@ -1,18 +1,17 @@ import { FieldGroupApi, functionalUpdate } from '@tanstack/form-core' import { useStore } from '@tanstack/solid-store' import { onCleanup, onMount } from 'solid-js' -import type { Component, JSX, ParentProps } from 'solid-js'; +import type { Component, JSX, ParentProps } from 'solid-js' import type { DeepKeysOfType, FieldGroupState, FieldsMap, FormAsyncValidateOrFn, - FormValidateOrFn + FormValidateOrFn, } from '@tanstack/form-core' import type { LensFieldComponent } from './createField' import type { AppFieldExtendedSolidFormApi } from './createFormHook' - /** * @private */ @@ -91,49 +90,51 @@ export function createFieldGroup< TComponents extends Record>, TFormComponents extends Record>, TSubmitMeta = never, ->(opts: () => { - form: - | AppFieldExtendedSolidFormApi< - TFormData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TOnServer, - TSubmitMeta, - TComponents, - TFormComponents - > - | AppFieldExtendedSolidFieldGroupApi< - // Since this only occurs if you nest it within other form lenses, it can be more - // lenient with the types. - unknown, - TFormData, - string | FieldsMap, - any, - any, - any, - any, - any, - any, - any, - any, - any, - any, - TSubmitMeta, - TComponents, - TFormComponents - > - fields: TFields - defaultValues?: TFieldGroupData - onSubmitMeta?: TSubmitMeta - formComponents: TFormComponents -}): AppFieldExtendedSolidFieldGroupApi< +>( + opts: () => { + form: + | AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + > + | AppFieldExtendedSolidFieldGroupApi< + // Since this only occurs if you nest it within other form lenses, it can be more + // lenient with the types. + unknown, + TFormData, + string | FieldsMap, + any, + any, + any, + any, + any, + any, + any, + any, + any, + any, + TSubmitMeta, + TComponents, + TFormComponents + > + fields: TFields + defaultValues?: TFieldGroupData + onSubmitMeta?: TSubmitMeta + formComponents: TFormComponents + }, +): AppFieldExtendedSolidFieldGroupApi< TFormData, TFieldGroupData, TFields, @@ -153,25 +154,26 @@ export function createFieldGroup< > { const options = opts() const api = new FieldGroupApi(options) - const form = options.form instanceof FieldGroupApi - ? (options.form.form as AppFieldExtendedSolidFormApi< - TFormData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TOnServer, - TSubmitMeta, - TComponents, - TFormComponents - >) - : options.form - + const form = + options.form instanceof FieldGroupApi + ? (options.form.form as AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + >) + : options.form + const extendedApi: AppFieldExtendedSolidFieldGroupApi< TFormData, TFieldGroupData, @@ -234,4 +236,4 @@ export function createFieldGroup< TComponents, TFormComponents > -} \ No newline at end of file +} diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index 1af0a5072..71d91653b 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -20,7 +20,10 @@ import type { } from 'solid-js' import type { FieldComponent } from './createField' import type { SolidFormExtendedApi } from './createForm' -import { createFieldGroup, type AppFieldExtendedSolidFieldGroupApi } from './createFieldGroup' +import { + createFieldGroup, + type AppFieldExtendedSolidFieldGroupApi, +} from './createFieldGroup' /** * TypeScript inferencing is weird. @@ -253,7 +256,6 @@ export interface WithFormProps< ) => JSXElement } - export interface WithFieldGroupProps< TFieldGroupData, TFieldComponents extends Record>, @@ -291,7 +293,6 @@ export interface WithFieldGroupProps< ) => JSXElement } - export function createFormHook< const TComponents extends Record>, const TFormComponents extends Record>, @@ -537,11 +538,11 @@ export function createFormHook< ) => JSXElement { return function Render(innerProps) { const fieldGroupProps = { - form: innerProps.form, - fields: innerProps.fields, - defaultValues, - formComponents: opts.formComponents, - } + form: innerProps.form, + fields: innerProps.fields, + defaultValues, + formComponents: opts.formComponents, + } const fieldGroupApi = createFieldGroup(() => fieldGroupProps) return render({ ...props, ...innerProps, group: fieldGroupApi as any }) From 306cea384130514c7d45dc79e3c34c3592344ffc Mon Sep 17 00:00:00 2001 From: vijayabaskar Date: Wed, 1 Oct 2025 20:49:52 +0530 Subject: [PATCH 3/3] chore(solid-form): add withFieldGroup API changeset added and formatting fic --- .changeset/sixty-hands-peel.md | 5 +++++ packages/solid-form/src/createFormHook.tsx | 6 ++---- 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 .changeset/sixty-hands-peel.md diff --git a/.changeset/sixty-hands-peel.md b/.changeset/sixty-hands-peel.md new file mode 100644 index 000000000..4bbe31e88 --- /dev/null +++ b/.changeset/sixty-hands-peel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/solid-form': minor +--- + +add withFieldGroup API Solid Form Composition diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index 71d91653b..cf2a389ab 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -1,5 +1,7 @@ import { createContext, splitProps, useContext } from 'solid-js' import { createForm } from './createForm' +import { createFieldGroup } from './createFieldGroup' +import type { AppFieldExtendedSolidFieldGroupApi } from './createFieldGroup' import type { AnyFieldApi, AnyFormApi, @@ -20,10 +22,6 @@ import type { } from 'solid-js' import type { FieldComponent } from './createField' import type { SolidFormExtendedApi } from './createForm' -import { - createFieldGroup, - type AppFieldExtendedSolidFieldGroupApi, -} from './createFieldGroup' /** * TypeScript inferencing is weird.