diff --git a/docs/framework/solid/guides/form-composition.md b/docs/framework/solid/guides/form-composition.md index 9d2c92e7c..af7c66de6 100644 --- a/docs/framework/solid/guides/form-composition.md +++ b/docs/framework/solid/guides/form-composition.md @@ -216,6 +216,238 @@ function App() { 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({ group, title }) { + // access reactive values using the group store + const password = useStore(group.store, (state) => state.values.password) + // or the form itself + const isSubmitting = useStore( + group.form.store, + (state) => state.isSubmitting, + ) + + return ( +
+

{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 !== group.getFieldValue('password')) { + return 'Passwords do not match' + } + return undefined + }, + }} + > + {(field) => ( +
+ + +
+ )} +
+
+ ) + }, +}) +``` + +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 While the above examples are great for getting started, they're not ideal for certain use-cases where you might have hundreds of form and field components. diff --git a/docs/framework/solid/reference/functions/createformhook.md b/docs/framework/solid/reference/functions/createformhook.md index a36b3ac77..60d00774a 100644 --- a/docs/framework/solid/reference/functions/createformhook.md +++ b/docs/framework/solid/reference/functions/createformhook.md @@ -124,3 +124,122 @@ withForm: (__namedParameters) => (params) => Element; +``` + +#### Type Parameters + +• **TFieldGroupData** + +• **TSubmitMeta** + +• **TRenderProps** *extends* `Record`\<`string`, `unknown`\> = \{\} + +#### Parameters + +##### \_\_namedParameters + +[`WithFieldGroupProps`](../../interfaces/withfieldgroupprops.md)\<`TFieldGroupData`, `TComponents`, `TFormComponents`, `TSubmitMeta`, `TRenderProps`\> + +#### Returns + +`Function` + +##### Type Parameters + +• **TFormData** + +• **TFields** *extends* + \| `string` + \| \{ \[K in string \| number \| symbol\]: DeepKeysOfType\ \} + +• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TFormSubmitMeta** + +##### Parameters + +###### params + +`PropsWithChildren`\<`NoInfer`\<`TRenderProps`\> & `object`\> + +##### Returns + +`Element` + +### withForm() + +```ts +withForm: (__namedParameters) => (props) => Element; +``` + +#### Type Parameters + +• **TFormData** + +• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TSubmitMeta** + +• **TRenderProps** *extends* `object` = \{\} + +#### Parameters + +##### \_\_namedParameters + +[`WithFormProps`](../../interfaces/withformprops.md)\<`TFormData`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`, `TRenderProps`\> + +#### Returns + +`Function` + +##### Parameters + +###### props + +`PropsWithChildren`\<`NoInfer`\<`UnwrapOrAny`\<`TRenderProps`\>\> & `object`\> + +##### Returns + +`Element` \ No newline at end of file diff --git a/docs/framework/solid/reference/functions/usefieldgroup.md b/docs/framework/solid/reference/functions/usefieldgroup.md new file mode 100644 index 000000000..1b5b3a8fa --- /dev/null +++ b/docs/framework/solid/reference/functions/usefieldgroup.md @@ -0,0 +1,79 @@ +--- +id: useFieldGroup +title: useFieldGroup +--- + + + +# Function: useFieldGroup() + +```ts +function useFieldGroup(opts): AppFieldExtendedReactFieldGroupApi +``` + +Defined in: [packages/react-form/src/useFieldGroup.tsx:89](https://github.com/TanStack/form/blob/main/packages/react-form/src/useFieldGroup.tsx#L89) + +## Type Parameters + +• **TFormData** + +• **TFieldGroupData** + +• **TFields** *extends* + \| `string` + \| \{ \[K in string \| number \| symbol\]: DeepKeysOfType\ \} + +• **TOnMount** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChange** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnChangeAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnBlur** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnBlurAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnSubmit** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnSubmitAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnDynamic** *extends* `undefined` \| `FormValidateOrFn`\<`TFormData`\> + +• **TOnDynamicAsync** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TOnServer** *extends* `undefined` \| `FormAsyncValidateOrFn`\<`TFormData`\> + +• **TComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\> + +• **TFormComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\> + +• **TSubmitMeta** = `never` + +## Parameters + +### opts + +#### defaultValues? + +`TFieldGroupData` + +#### fields + +`TFields` + +#### form + + \| `AppFieldExtendedReactFormApi`\<`TFormData`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`\> + \| `AppFieldExtendedReactFieldGroupApi`\<`unknown`, `TFormData`, `string` \| `FieldsMap`\<`unknown`, `TFormData`\>, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `any`, `TSubmitMeta`, `TComponents`, `TFormComponents`\> + +#### formComponents + +`TFormComponents` + +#### onSubmitMeta? + +`TSubmitMeta` + +## Returns + +`AppFieldExtendedReactFieldGroupApi`\<`TFormData`, `TFieldGroupData`, `TFields`, `TOnMount`, `TOnChange`, `TOnChangeAsync`, `TOnBlur`, `TOnBlurAsync`, `TOnSubmit`, `TOnSubmitAsync`, `TOnDynamic`, `TOnDynamicAsync`, `TOnServer`, `TSubmitMeta`, `TComponents`, `TFormComponents`\> diff --git a/docs/framework/solid/reference/interfaces/withfieldgroupprops.md b/docs/framework/solid/reference/interfaces/withfieldgroupprops.md new file mode 100644 index 000000000..46536312b --- /dev/null +++ b/docs/framework/solid/reference/interfaces/withfieldgroupprops.md @@ -0,0 +1,56 @@ +--- +id: WithFieldGroupProps +title: WithFieldGroupProps +--- + + + +# Interface: WithFieldGroupProps\ + +Defined in: [packages/react-form/src/createFormHook.tsx:247](https://github.com/TanStack/form/blob/main/packages/solid-form/src/createFormHook.tsx#L247) + +## Extends + +- `BaseFormOptions`\<`TFieldGroupData`, `TSubmitMeta`\> + +## Type Parameters + +• **TFieldGroupData** + +• **TFieldComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\> + +• **TFormComponents** *extends* `Record`\<`string`, `ComponentType`\<`any`\>\> + +• **TSubmitMeta** + +• **TRenderProps** *extends* `Record`\<`string`, `unknown`\> = `Record`\<`string`, `never`\> + +## Properties + +### props? + +```ts +optional props: TRenderProps; +``` + +Defined in: [packages/react-form/src/createFormHook.tsx:255](https://github.com/TanStack/form/blob/main/packages/react-form/src/createFormHook.tsx#L255) + +*** + +### render() + +```ts +render: (props) => Element; +``` + +Defined in: [packages/react-form/src/createFormHook.tsx:256](https://github.com/TanStack/form/blob/main/packages/react-form/src/createFormHook.tsx#L256) + +#### Parameters + +##### props + +`PropsWithChildren`\<`NoInfer`\<`TRenderProps`\> & `object`\> + +#### Returns + +`Element` diff --git a/packages/react-form/src/createFormHook.tsx b/packages/react-form/src/createFormHook.tsx index 548fc2322..79957cf95 100644 --- a/packages/react-form/src/createFormHook.tsx +++ b/packages/react-form/src/createFormHook.tsx @@ -179,7 +179,7 @@ export type AppFieldExtendedReactFormApi< TOnBlurAsync, TOnSubmit, TOnSubmitAsync, - TOnDynamic, + TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, diff --git a/packages/solid-form/src/createField.tsx b/packages/solid-form/src/createField.tsx index de263f00c..9242a287a 100644 --- a/packages/solid-form/src/createField.tsx +++ b/packages/solid-form/src/createField.tsx @@ -11,6 +11,7 @@ import type { DeepValue, FieldAsyncValidateOrFn, FieldValidateOrFn, + FieldValidators, FormAsyncValidateOrFn, FormValidateOrFn, Narrow, @@ -539,6 +540,94 @@ export type FieldComponent< ExtendedApi >) => JSX.Element + +/** + * 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[] + } +}) => JSXElement interface FieldComponentProps< 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..0ecff7236 --- /dev/null +++ b/packages/solid-form/src/createFieldGroup.tsx @@ -0,0 +1,250 @@ +import { + AnyFieldGroupApi, + DeepKeysOfType, + FieldGroupApi, + FieldGroupState, + FieldsMap, + FormAsyncValidateOrFn, + FormValidateOrFn, + functionalUpdate, +} from "@tanstack/form-core"; +import { Accessor, Component, createRenderEffect, createSignal, JSX, ParentComponent, ParentProps } from "solid-js"; +import { LensFieldComponent } from "./createField"; +import { AppFieldExtendedSolidFormApi } from "./createFormHook" +import { useStore } from "@tanstack/solid-store"; + +function LocalSubscribe({ + lens, + selector, + children, +}: ParentProps<{ + lens: AnyFieldGroupApi; + selector: (state: FieldGroupState) => FieldGroupState; +}>) { + const data = useStore(lens.store, selector); + + return functionalUpdate(children, data); +} + +/** + * @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: ParentComponent; + /** + * A React 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 react 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; +}): Accessor> { + const [formLensApi] = createSignal((() => { + const api = new FieldGroupApi(opts); + const form = + opts.form instanceof FieldGroupApi + ? (opts.form.form as AppFieldExtendedSolidFormApi< + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + >) + : opts.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 = function AppForm(appFormProps) { + return + } + + extendedApi.AppField = function AppField(props) { + return + } + + extendedApi.Field = function Field(props) { + return + } + + extendedApi.Subscribe = function Subscribe(props: any) { + return + } + return Object.assign(extendedApi, { + ...opts.formComponents, + }) as AppFieldExtendedSolidFieldGroupApi< + TFormData, + TFieldGroupData, + TFields, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta, + TComponents, + TFormComponents + >; + })()) + + createRenderEffect(() => { + formLensApi().mount() + }) + return formLensApi; +} \ No newline at end of file diff --git a/packages/solid-form/src/createFormHook.tsx b/packages/solid-form/src/createFormHook.tsx index 9b02050e1..1c02ff129 100644 --- a/packages/solid-form/src/createFormHook.tsx +++ b/packages/solid-form/src/createFormHook.tsx @@ -1,9 +1,12 @@ -import { createContext, splitProps, useContext } from 'solid-js' +import { createContext, createMemo, splitProps, useContext } from 'solid-js' 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 { AppFieldExtendedSolidFieldGroupApi, createFieldGroup } from './createFieldGroup' /** * TypeScript inferencing is weird. @@ -51,8 +55,8 @@ import type { SolidFormExtendedApi } from './createForm' type UnwrapOrAny = [unknown] extends [T] ? any : T type UnwrapDefaultOrAny = [DefaultT] extends [T] ? [T] extends [DefaultT] - ? any - : T + ? any + : T : T export function createFormHookContexts() { @@ -143,7 +147,10 @@ interface CreateFormHookProps< formContext: Context } -type AppFieldExtendedSolidFormApi< +/** + * @private + */ +export type AppFieldExtendedSolidFormApi< TFormData, TOnMount extends undefined | FormValidateOrFn, TOnChange extends undefined | FormValidateOrFn, @@ -208,19 +215,19 @@ export interface WithFormProps< TFormComponents extends Record>, TRenderProps extends Record = Record, > extends FormOptions< - TFormData, - TOnMount, - TOnChange, - TOnChangeAsync, - TOnBlur, - TOnBlurAsync, - TOnSubmit, - TOnSubmitAsync, - TOnDynamic, - TOnDynamicAsync, - TOnServer, - TSubmitMeta - > { + TFormData, + TOnMount, + TOnChange, + TOnChangeAsync, + TOnBlur, + TOnBlurAsync, + TOnSubmit, + TOnSubmitAsync, + TOnDynamic, + TOnDynamicAsync, + TOnServer, + TSubmitMeta +> { // Optional, but adds props to the `render` function outside of `form` props?: TRenderProps render: ( @@ -247,6 +254,43 @@ 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>, @@ -354,7 +398,7 @@ export function createFormHook< extendedForm.AppForm = AppForm for (const [key, value] of Object.entries(opts.formComponents)) { // Since it's a generic I need to cast it to an object - ;(extendedForm as Record)[key] = value + ; (extendedForm as Record)[key] = value } return extendedForm @@ -416,8 +460,91 @@ export function createFormHook< return (innerProps) => render({ ...props, ...innerProps }) } + function withFieldGroup< + TFieldGroupData, + TSubmitMeta, + TRenderProps extends Record = {}, + >({ + render, + defaultValues, + props, + }: 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 = createMemo(() => { + return { form: innerProps.form, fields: innerProps.fields, defaultValues, formComponents: opts.formComponents } + }) + const fieldGroupApi = createFieldGroup(fieldGroupProps() as any); + return render({ ...props, ...innerProps, group: fieldGroupApi as any }) + } + } return { useAppForm, withForm, + withFieldGroup } } diff --git a/packages/solid-form/tests/createFormHook.test-d.tsx b/packages/solid-form/tests/createFormHook.test-d.tsx index 067c2be1b..b7f43c002 100644 --- a/packages/solid-form/tests/createFormHook.test-d.tsx +++ b/packages/solid-form/tests/createFormHook.test-d.tsx @@ -2,6 +2,7 @@ import { describe, expectTypeOf, it } from 'vitest' import { formOptions } from '@tanstack/form-core' import { createFormHook, createFormHookContexts } from '../src' import type { JSX } from 'solid-js/jsx-runtime' +import { JSXElement } from 'solid-js' const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() @@ -10,7 +11,7 @@ function Test() { return null } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { Test, }, @@ -249,4 +250,555 @@ describe('createFormHook', () => { ) }) + + it('should infer subset values and props when calling withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroupComponent = withFieldGroup({ + defaultValues, + render: function Render({ group, children, ...props }) { + // Existing types may be inferred + expectTypeOf(group.state.values.firstName).toEqualTypeOf() + expectTypeOf(group.state.values.lastName).toEqualTypeOf() + + expectTypeOf(group.state.values).toEqualTypeOf() + expectTypeOf(children).toEqualTypeOf() + expectTypeOf(props).toEqualTypeOf<{}>() + return + }, + }) + + const FormGroupComponentWithProps = withFieldGroup({ + ...defaultValues, + props: {} as ComponentProps, + render: ({ group, children, ...props }) => { + expectTypeOf(props).toEqualTypeOf<{ + prop1: string + prop2: number + }>() + return + }, + }) + }) + + it('should allow spreading formOptions when calling withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + + const defaultValues: Person = { + firstName: '', + lastName: '', + } + const formOpts = formOptions({ + defaultValues, + validators: { + onChange: () => 'Error', + }, + listeners: { + onBlur: () => 'Something', + }, + asyncAlways: true, + asyncDebounceMs: 500, + }) + + // validators and listeners are ignored, only defaultValues is acknowledged + const FormGroupComponent = withFieldGroup({ + ...formOpts, + render: function Render({ group }) { + // Existing types may be inferred + expectTypeOf(group.state.values.firstName).toEqualTypeOf() + expectTypeOf(group.state.values.lastName).toEqualTypeOf() + return + }, + }) + + const noDefaultValuesFormOpts = formOptions({ + onSubmitMeta: { foo: '' }, + }) + + const UnknownFormGroupComponent = withFieldGroup({ + ...noDefaultValuesFormOpts, + render: function Render({ group }) { + // group.state.values can be anything. + // note that T extends unknown !== unknown extends T. + expectTypeOf().toExtend() + + // either no submit meta or of the type in formOptions + expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf< + [] | [{ foo: string }] + >() + return + }, + }) + }) + + it('should allow passing compatible forms to withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + type ComponentProps = { + prop1: string + prop2: number + } + + const defaultValues: Person = { + firstName: 'FirstName', + lastName: 'LastName', + } + + const FormGroup = withFieldGroup({ + defaultValues, + props: {} as ComponentProps, + render: () => { + return <> + }, + }) + + const equalAppForm = useAppForm(() => ({ + defaultValues, + })) + + // ----------------- + // Assert that an equal form is not compatible as you have no name to pass + const NoSubfield = ( + + ) + + // ----------------- + // Assert that a form extending Person in a property is allowed + + const extendedAppForm = useAppForm(() => ({ + defaultValues: { person: { ...defaultValues, address: '' }, address: '' }, + })) + // While it has other properties, it satisfies defaultValues + const CorrectComponent1 = ( + + ) + + const MissingProps = ( + // @ts-expect-error because prop1 and prop2 are not added + + ) + + // ----------------- + // Assert that a form not satisfying Person errors + const incompatibleAppForm = useAppForm(() => ({ + defaultValues: { person: { ...defaultValues, lastName: 0 } }, + })) + const IncompatibleComponent = ( + + ) + }) + + it('should require strict equal submitMeta if it is set in withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + type SubmitMeta = { + correct: string + } + + const defaultValues = { + person: { firstName: 'FirstName', lastName: 'LastName' } as Person, + } + const onSubmitMeta: SubmitMeta = { + correct: 'Prop', + } + + const FormLensNoMeta = withFieldGroup({ + defaultValues: {} as Person, + render: function Render({ group }) { + // Since handleSubmit always allows to submit without meta, this is okay + group.handleSubmit() + + // To prevent unwanted meta behaviour, handleSubmit's meta should be never if not set. + expectTypeOf(group.handleSubmit).parameters.toEqualTypeOf< + [] | [submitMeta: never] + >() + + return + }, + }) + + const FormGroupWithMeta = withFieldGroup({ + defaultValues: {} as Person, + onSubmitMeta, + render: function Render({ group }) { + // Since handleSubmit always allows to submit without meta, this is okay + group.handleSubmit() + + // This matches the value + group.handleSubmit({ correct: '' }) + + // This does not. + // @ts-expect-error + group.handleSubmit({ wrong: 'Meta' }) + + return + }, + }) + + const noMetaForm = useAppForm(() => ({ + defaultValues, + })) + + const CorrectComponent1 = ( + + ) + + const WrongComponent1 = ( + + ) + + const metaForm = useAppForm(() => ({ + defaultValues, + onSubmitMeta, + })) + + const CorrectComponent2 = + const CorrectComponent3 = ( + + ) + + const diffMetaForm = useAppForm(() => ({ + defaultValues, + onSubmitMeta: { ...onSubmitMeta, something: 'else' }, + })) + + const CorrectComponent4 = ( + + ) + const WrongComponent2 = ( + + ) + }) + + it('should accept any validators for withFieldGroup', () => { + type Person = { + firstName: string + lastName: string + } + + const defaultValues = { + person: { firstName: 'FirstName', lastName: 'LastName' } satisfies Person, + } + + const formA = useAppForm(() => ({ + defaultValues, + validators: { + onChange: () => 'A', + }, + listeners: { + onChange: () => 'A', + }, + })) + const formB = useAppForm(() => ({ + defaultValues, + validators: { + onChange: () => 'B', + }, + listeners: { + onChange: () => 'B', + }, + })) + + const FormGroup = withFieldGroup({ + defaultValues: defaultValues.person, + render: function Render({ group }) { + return + }, + }) + + const CorrectComponent1 = + const CorrectComponent2 = + }) + + 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 form = useAppForm(() => ({ + defaultValues, + })) + const LensNested = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ +
+ ) + }, + }) + + const Component = + }) + + it('should not allow withFieldGroups with different metas to be nested', () => { + 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 LensNestedNoMeta = withFieldGroup({ + defaultValues: defaultValues.form.field, + render: function Render() { + return <> + }, + }) + const LensNestedWithMeta = withFieldGroup({ + defaultValues: defaultValues.form.field, + onSubmitMeta: { meta: '' }, + render: function Render() { + return <> + }, + }) + const LensWrapper = withFieldGroup({ + defaultValues: defaultValues.form, + render: function Render({ group }) { + return ( +
+ + +
+ ) + }, + }) + + it('should allow mapping withFieldGroup to different fields', () => { + const defaultValues = { + firstName: '', + lastName: '', + age: 0, + relatives: [{ firstName: '', lastName: '', age: 0 }], + } + const defaultFields = { + first: '', + last: '', + } + + const form = useAppForm(() => ({ + defaultValues, + })) + + const FieldGroup = withFieldGroup({ + defaultValues: defaultFields, + render: function Render() { + return <> + }, + }) + + const Component1 = ( + + ) + + const Component2 = ( + + ) + }) + + it('should not allow fields mapping if the top level is an array', () => { + const defaultValues = { + firstName: '', + lastName: '', + age: 0, + relatives: [{ firstName: '', lastName: '', age: 0 }], + relativesRecord: { + something: { firstName: '', lastName: '', age: 0 }, + } as Record, + } + const defaultFields = { + firstName: '', + lastName: '', + } + + const form = useAppForm(() => ({ + defaultValues, + })) + + const FieldGroupRecord = withFieldGroup({ + defaultValues: { anything: defaultFields } as Record< + string, + typeof defaultFields + >, + render: function Render() { + return <> + }, + }) + const FieldGroupArray = withFieldGroup({ + defaultValues: [defaultFields], + render: function Render() { + return <> + }, + }) + + const CorrectComponent1 = ( + + ) + const WrongComponent1 = ( + + ) + const CorrectComponent3 = ( + + ) + const WrongComponent2 = ( + + ) + }) + }) + + it('should allow mapping field groups to optional fields', () => { + const groupFields = { + name: '', + } + + type WrapperValues = { + namespace: { name: string } | undefined + namespace2: { name: string } | null + namespace3: { name: string } | null | undefined + nope: null | undefined + nope2: { lastName: string } | null | undefined + } + + const defaultValues: WrapperValues = { + namespace: undefined, + namespace2: null, + namespace3: null, + nope: null, + nope2: null, + } + + const FieldGroup = withFieldGroup({ + defaultValues: groupFields, + render: function Render() { + return <> + }, + }) + + const form = useAppForm(() => ({ + defaultValues, + })) + + const Component = + const Component2 = + const Component3 = + // @ts-expect-error because it doesn't ever evaluate to the expected values + const Component4 = + // @ts-expect-error because the types don't match properly + const Component5 = + }) + }) diff --git a/packages/solid-form/tests/createFormHook.test.tsx b/packages/solid-form/tests/createFormHook.test.tsx index 60025aa9c..6e3c112e2 100644 --- a/packages/solid-form/tests/createFormHook.test.tsx +++ b/packages/solid-form/tests/createFormHook.test.tsx @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' -import { render } from '@solidjs/testing-library' +import { render, fireEvent } from '@solidjs/testing-library' import { formOptions } from '@tanstack/form-core' -import { createFormHook, createFormHookContexts } from '../src' +import { createFormHook, createFormHookContexts, useStore } from '../src' + + const { fieldContext, useFieldContext, formContext, useFormContext } = createFormHookContexts() @@ -28,7 +30,7 @@ function SubscribeButton({ label }: { label: string }) { ) } -const { useAppForm, withForm } = createFormHook({ +const { useAppForm, withForm, withFieldGroup } = createFormHook({ fieldComponents: { TextField, }, @@ -112,4 +114,470 @@ 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: (props) => { + return ( +
+

{props.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() + fireEvent.input(input, { target: { value: "" } }) + fireEvent.input(input, { target: { value: "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') + + fireEvent.click(target) + + expect(result).toHaveTextContent('1') + }) })