Skip to content

Commit 89c4f24

Browse files
Vijayabaskar56autofix-ci[bot]LeCarbonator
authored
feat(solid-form): add withFieldGroup API (#1783)
* feat(solid-form): add withFieldGroup API * ci: apply automated fixes and generate docs * chore(solid-form): add withFieldGroup API changeset added and formatting fic --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: LeCarbonator <[email protected]>
1 parent 5ae9c07 commit 89c4f24

File tree

10 files changed

+1205
-20
lines changed

10 files changed

+1205
-20
lines changed

.changeset/sixty-hands-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/solid-form': minor
3+
---
4+
5+
add withFieldGroup API Solid Form Composition

docs/framework/solid/guides/form-composition.md

Lines changed: 241 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ Sometimes forms get very large; it's just how it goes sometimes. While TanStack
159159
To solve this, we support breaking forms into smaller pieces using the `withForm` higher-order component.
160160

161161
```tsx
162-
const { useAppForm, withForm } = createFormHook({
162+
const { useAppForm, withForm, withFieldGroup } = createFormHook({
163163
fieldComponents: {
164164
TextField,
165165
},
@@ -213,8 +213,243 @@ function App() {
213213
### `withForm` FAQ
214214

215215
> Why a higher-order component instead of a hook?
216+
>
217+
> 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.
218+
219+
## Reusing groups of fields in multiple forms
220+
221+
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.
222+
223+
> Unlike `withForm`, validators cannot be specified and could be any value.
224+
> Ensure that your fields can accept unknown error types.
225+
226+
Rewriting the passwords example using `withFieldGroup` would look like this:
227+
228+
```tsx
229+
const { useAppForm, withForm, withFieldGroup } = createFormHook({
230+
fieldComponents: {
231+
TextField,
232+
ErrorInfo,
233+
},
234+
formComponents: {
235+
SubscribeButton,
236+
},
237+
fieldContext,
238+
formContext,
239+
})
240+
241+
type PasswordFields = {
242+
password: string
243+
confirm_password: string
244+
}
245+
246+
// These default values are not used at runtime, but the keys are needed for mapping purposes.
247+
// This allows you to spread `formOptions` without needing to redeclare it.
248+
const defaultValues: PasswordFields = {
249+
password: '',
250+
confirm_password: '',
251+
}
252+
253+
const FieldGroupPasswordFields = withFieldGroup({
254+
defaultValues,
255+
// You may also restrict the group to only use forms that implement this submit meta.
256+
// If none is provided, any form with the right defaultValues may use it.
257+
// onSubmitMeta: { action: '' }
258+
259+
// Optional, but adds props to the `render` function in addition to `form`
260+
props: {
261+
// These default values are also for type-checking and are not used at runtime
262+
title: 'Password',
263+
},
264+
// Internally, you will have access to a `group` instead of a `form`
265+
render: function Render(props) {
266+
// access reactive values using the group store
267+
const password = useStore(
268+
props.group.store,
269+
(state) => state.values.password,
270+
)
271+
// or the form itself
272+
const isSubmitting = useStore(
273+
props.group.form.store,
274+
(state) => state.isSubmitting,
275+
)
276+
277+
return (
278+
<div>
279+
<h2>{props.title}</h2>
280+
{/* Groups also have access to Field, Subscribe, Field, AppField and AppForm */}
281+
<props.group.AppField name="password">
282+
{(field) => <field.TextField label="Password" />}
283+
</props.group.AppField>
284+
<props.group.AppField
285+
name="confirm_password"
286+
validators={{
287+
onChangeListenTo: ['password'],
288+
onChange: ({ value, fieldApi }) => {
289+
// The form could be any values, so it is typed as 'unknown'
290+
const values: unknown = fieldApi.form.state.values
291+
// use the group methods instead
292+
if (value !== props.group.getFieldValue('password')) {
293+
return 'Passwords do not match'
294+
}
295+
return undefined
296+
},
297+
}}
298+
>
299+
{(field) => (
300+
<div>
301+
<field.TextField label="Confirm Password" />
302+
<field.ErrorInfo />
303+
</div>
304+
)}
305+
</props.group.AppField>
306+
</div>
307+
)
308+
},
309+
})
310+
```
216311

217-
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.
312+
We can now use these grouped fields in any form that implements the default values:
313+
314+
```tsx
315+
// You are allowed to extend the group fields as long as the
316+
// existing properties remain unchanged
317+
type Account = PasswordFields & {
318+
provider: string
319+
username: string
320+
}
321+
322+
// You may nest the group fields wherever you want
323+
type FormValues = {
324+
name: string
325+
age: number
326+
account_data: PasswordFields
327+
linked_accounts: Account[]
328+
}
329+
330+
const defaultValues: FormValues = {
331+
name: '',
332+
age: 0,
333+
account_data: {
334+
password: '',
335+
confirm_password: '',
336+
},
337+
linked_accounts: [
338+
{
339+
provider: 'TanStack',
340+
username: '',
341+
password: '',
342+
confirm_password: '',
343+
},
344+
],
345+
}
346+
347+
function App() {
348+
const form = useAppForm(() => ({
349+
defaultValues,
350+
// If the group didn't specify an `onSubmitMeta` property,
351+
// the form may implement any meta it wants.
352+
// Otherwise, the meta must be defined and match.
353+
onSubmitMeta: { action: '' },
354+
}))
355+
356+
return (
357+
<form.AppForm>
358+
<FieldGroupPasswordFields
359+
form={form}
360+
// You must specify where the fields can be found
361+
fields="account_data"
362+
title="Passwords"
363+
/>
364+
<form.Field name="linked_accounts" mode="array">
365+
{(field) =>
366+
field().state.value.map((account, i) => (
367+
<FieldGroupPasswordFields
368+
key={account.provider}
369+
form={form}
370+
// The fields may be in nested fields
371+
fields={`linked_accounts[${i}]`}
372+
title={account.provider}
373+
/>
374+
))
375+
}
376+
</form.Field>
377+
</form.AppForm>
378+
)
379+
}
380+
```
381+
382+
### Mapping field group values to a different field
383+
384+
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
385+
to their true location by changing the `field` property:
386+
387+
> [!IMPORTANT]
388+
> 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.
389+
390+
```tsx
391+
// To have an easier form, you can keep the fields on the top level
392+
type FormValues = {
393+
name: string
394+
age: number
395+
password: string
396+
confirm_password: string
397+
}
398+
399+
const defaultValues: FormValues = {
400+
name: '',
401+
age: 0,
402+
password: '',
403+
confirm_password: '',
404+
}
405+
406+
function App() {
407+
const form = useAppForm(() => ({
408+
defaultValues,
409+
}))
410+
411+
return (
412+
<form.AppForm>
413+
<FieldGroupPasswordFields
414+
form={form}
415+
// You can map the fields to their equivalent deep key
416+
fields={{
417+
password: 'password',
418+
confirm_password: 'confirm_password',
419+
// or map them to differently named keys entirely
420+
// 'password': 'name'
421+
}}
422+
title="Passwords"
423+
/>
424+
</form.AppForm>
425+
)
426+
}
427+
```
428+
429+
If you expect your fields to always be at the top level of your form, you can create a quick map
430+
of your field groups using a helper function:
431+
432+
```tsx
433+
const defaultValues: PasswordFields = {
434+
password: '',
435+
confirm_password: '',
436+
}
437+
438+
const passwordFields = createFieldMap(defaultValues)
439+
/* This generates the following map:
440+
{
441+
'password': 'password',
442+
'confirm_password': 'confirm_password'
443+
}
444+
*/
445+
446+
// Usage:
447+
<FieldGroupPasswordFields
448+
form={form}
449+
fields={passwordFields}
450+
title="Passwords"
451+
/>
452+
```
218453

219454
## Tree-shaking form and field components
220455

@@ -316,7 +551,7 @@ function SubscribeButton(props: { label: string }) {
316551
)
317552
}
318553

319-
const { useAppForm, withForm } = createFormHook({
554+
const { useAppForm, withForm, withFieldGroup } = createFormHook({
320555
fieldComponents: {
321556
TextField,
322557
},
@@ -345,7 +580,7 @@ const ChildForm = withForm({
345580
render: (props) => {
346581
return (
347582
<div>
348-
<p>{title}</p>
583+
<p>{props.title}</p>
349584
<props.form.AppField
350585
name="firstName"
351586
children={(field) => <field.TextField label="First Name" />}
@@ -360,9 +595,9 @@ const ChildForm = withForm({
360595

361596
// /src/features/people/page.ts
362597
const Parent = () => {
363-
const form = useAppForm({
598+
const form = useAppForm(() => ({
364599
...formOpts,
365-
})
600+
}))
366601

367602
return <ChildForm form={form} title={'Testing'} />
368603
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { withFieldGroup } from '../../hooks/form'
2+
3+
export const FieldGroupEmergencyContact = withFieldGroup({
4+
defaultValues: {
5+
phone: '',
6+
fullName: '',
7+
},
8+
render: function Render({ group }) {
9+
return (
10+
<>
11+
<group.AppField
12+
name="fullName"
13+
children={(field) => <field.TextField label="Full Name" />}
14+
/>
15+
<group.AppField
16+
name="phone"
17+
children={(field) => <field.TextField label="Phone" />}
18+
/>
19+
</>
20+
)
21+
},
22+
})

examples/solid/large-form/src/features/people/page.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useAppForm } from '../../hooks/form.tsx'
22
import { AddressFields } from './address-fields.tsx'
3+
import { FieldGroupEmergencyContact } from './emergency-contact.tsx'
34
import { peopleFormOpts } from './shared-form.tsx'
45

56
export const PeoplePage = () => {
@@ -57,14 +58,7 @@ export const PeoplePage = () => {
5758
/>
5859
<AddressFields form={form} />
5960
<h2>Emergency Contact</h2>
60-
<form.AppField
61-
name="emergencyContact.fullName"
62-
children={(field) => <field.TextField label="Full Name" />}
63-
/>
64-
<form.AppField
65-
name="emergencyContact.phone"
66-
children={(field) => <field.TextField label="Phone" />}
67-
/>
61+
<FieldGroupEmergencyContact form={form} fields="emergencyContact" />
6862
<form.AppForm>
6963
<form.SubscribeButton label="Submit" />
7064
</form.AppForm>

examples/solid/large-form/src/hooks/form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function SubscribeButton(props: { label: string }) {
1515
)
1616
}
1717

18-
export const { useAppForm, withForm } = createFormHook({
18+
export const { useAppForm, withForm, withFieldGroup } = createFormHook({
1919
fieldComponents: {
2020
TextField,
2121
},

0 commit comments

Comments
 (0)