Skip to content

Commit 1089876

Browse files
feat(react-form): Add withFieldGroup (#1469)
* feat: add createFormGroup * refactor: move createFormGroup to AppForm While the previous separate implementation was compatible with AppForm, users wouldn't have been able to use any field/form components in the render itself. This commit allows them to do that, at the expense of not being compatible with useForm. * chore: add unit tests for createFormGroup types * chore: add unit test for createFormGroup * chore: export CreateFormGroupProps * add DeepKeysOfType util type This type will help with a lens API for forms so that only names leading to the subset data are allowed. * feat: add initial FormLensApi draft * chore: add FormLensApi tests batch * fix(form-core): fix form.resetField() ignoring nested fields * chore: complete form-core unit test for FormLensApi * feat: add react adapter to form lens api * fix: fix names for lens.Field and add test * chore: export WithFormLensProps * feat: add Subscribe and store to form lens * feat: add mount method to FormLensApi * fix: memoize innerProps to avoid focus loss in withFormLens * refactor: use single useState instead of multiple useMemos this is because useMemo is not intended for stable objects see https://react.dev/reference/react/useMemo for more info * feat: allow nesting withFormLenses * remove createFormGroup for redundancy * fix: widen typing of lens.Field/AppField to correct level * docs: add withFormLens section * fix: fix TName for lens component * docs: fix typo in withFormLens * feat: add lensErrors to FormLensApi store * chore: adjust memo dependency in useFormLens * chore: call userEvent.setup() in createFormHook tests * refactor: move path concatenation to utils * chore: move useLens to own file and rename * feat: add FieldsMap and createFieldMap utils * chore: migrate (most) lens references to field group * chore: finalize migration from lens to field group * ci: apply automated fixes and generate docs * chore: remove accidental test file * chore: add some unit tests for field mapping * chore: add unit tests * docs: update docs to use group * docs: add caveat with field mapping * docs: fix weird line break in alert text * revert: remove FieldGroupApi#resetFieldMeta the method appears to be a helper function for form.reset, which is accessible from the field group API. There does not seem to be a good reason to port this method from FormApi. * refactor: allow null or undefined for field group keys users are able to do conditional rendering, but TS generally doesn't pick up on it. Therefore, while it is less type safe, it allows users to use field groups in more locations than previously possible. * chore: add FieldGroupApi.test-d.ts * docs(react-form): amend large form example with withFieldGroup * chore(form-core): remove FieldGroupApi.reset the reset is already accessible through group.form.reset. * chore: fix broken unit test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 72f9df5 commit 1089876

22 files changed

+3577
-33
lines changed

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

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,238 @@ const ChildForm = withForm({
248248
})
249249
```
250250

251+
## Reusing groups of fields in multiple forms
252+
253+
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.
254+
255+
> Unlike `withForm`, validators cannot be specified and could be any value.
256+
> Ensure that your fields can accept unknown error types.
257+
258+
Rewriting the passwords example using `withFieldGroup` would look like this:
259+
260+
```tsx
261+
const { useAppForm, withForm, withFieldGroup } = createFormHook({
262+
fieldComponents: {
263+
TextField,
264+
ErrorInfo,
265+
},
266+
formComponents: {
267+
SubscribeButton,
268+
},
269+
fieldContext,
270+
formContext,
271+
})
272+
273+
type PasswordFields = {
274+
password: string
275+
confirm_password: string
276+
}
277+
278+
// These default values are not used at runtime, but the keys are needed for mapping purposes.
279+
// This allows you to spread `formOptions` without needing to redeclare it.
280+
const defaultValues: PasswordFields = {
281+
password: '',
282+
confirm_password: '',
283+
}
284+
285+
const FieldGroupPasswordField = withFieldGroup({
286+
defaultValues,
287+
// You may also restrict the group to only use forms that implement this submit meta.
288+
// If none is provided, any form with the right defaultValues may use it.
289+
// onSubmitMeta: { action: '' }
290+
291+
// Optional, but adds props to the `render` function in addition to `form`
292+
props: {
293+
// These default values are also for type-checking and are not used at runtime
294+
title: 'Password',
295+
},
296+
// Internally, you will have access to a `group` instead of a `form`
297+
render: function Render({ group, title }) {
298+
// access reactive values using the group store
299+
const password = useStore(group.store, (state) => state.values.password)
300+
// or the form itself
301+
const isSubmitting = useStore(
302+
group.form.store,
303+
(state) => state.isSubmitting,
304+
)
305+
306+
return (
307+
<div>
308+
<h2>{title}</h2>
309+
{/* Groups also have access to Field, Subscribe, Field, AppField and AppForm */}
310+
<group.AppField name="password">
311+
{(field) => <field.TextField label="Password" />}
312+
</group.AppField>
313+
<group.AppField
314+
name="confirm_password"
315+
validators={{
316+
onChangeListenTo: ['password'],
317+
onChange: ({ value, fieldApi }) => {
318+
// The form could be any values, so it is typed as 'unknown'
319+
const values: unknown = fieldApi.form.state.values
320+
// use the group methods instead
321+
if (value !== group.getFieldValue('password')) {
322+
return 'Passwords do not match'
323+
}
324+
return undefined
325+
},
326+
}}
327+
>
328+
{(field) => (
329+
<div>
330+
<field.TextField label="Confirm Password" />
331+
<field.ErrorInfo />
332+
</div>
333+
)}
334+
</group.AppField>
335+
</div>
336+
)
337+
},
338+
})
339+
```
340+
341+
We can now use these grouped fields in any form that implements the default values:
342+
343+
```tsx
344+
// You are allowed to extend the group fields as long as the
345+
// existing properties remain unchanged
346+
type Account = PasswordFields & {
347+
provider: string
348+
username: string
349+
}
350+
351+
// You may nest the group fields wherever you want
352+
type FormValues = {
353+
name: string
354+
age: number
355+
account_data: PasswordFields
356+
linked_accounts: Account[]
357+
}
358+
359+
const defaultValues: FormValues = {
360+
name: '',
361+
age: 0,
362+
account_data: {
363+
password: '',
364+
confirm_password: '',
365+
},
366+
linked_accounts: [
367+
{
368+
provider: 'TanStack',
369+
username: '',
370+
password: '',
371+
confirm_password: '',
372+
},
373+
],
374+
}
375+
376+
function App() {
377+
const form = useAppForm({
378+
defaultValues,
379+
// If the group didn't specify an `onSubmitMeta` property,
380+
// the form may implement any meta it wants.
381+
// Otherwise, the meta must be defined and match.
382+
onSubmitMeta: { action: '' },
383+
})
384+
385+
return (
386+
<form.AppForm>
387+
<PasswordFields
388+
form={form}
389+
// You must specify where the fields can be found
390+
fields="account_data"
391+
title="Passwords"
392+
/>
393+
<form.Field name="linked_accounts" mode="array">
394+
{(field) =>
395+
field.state.value.map((account, i) => (
396+
<PasswordFields
397+
key={account.provider}
398+
form={form}
399+
// The fields may be in nested fields
400+
fields={`linked_accounts[${i}]`}
401+
title={account.provider}
402+
/>
403+
))
404+
}
405+
</form.Field>
406+
</form.AppForm>
407+
)
408+
}
409+
```
410+
411+
### Mapping field group values to a different field
412+
413+
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
414+
to their true location by changing the `field` property:
415+
416+
> [!IMPORTANT]
417+
> 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.
418+
419+
```tsx
420+
// To have an easier form, you can keep the fields on the top level
421+
type FormValues = {
422+
name: string
423+
age: number
424+
password: string
425+
confirm_password: string
426+
}
427+
428+
const defaultValues: FormValues = {
429+
name: '',
430+
age: 0,
431+
password: '',
432+
confirm_password: '',
433+
}
434+
435+
function App() {
436+
const form = useAppForm({
437+
defaultValues,
438+
})
439+
440+
return (
441+
<form.AppForm>
442+
<PasswordFields
443+
form={form}
444+
// You can map the fields to their equivalent deep key
445+
fields={{
446+
password: 'password',
447+
confirm_password: 'confirm_password',
448+
// or map them to differently named keys entirely
449+
// 'password': 'name'
450+
}}
451+
title="Passwords"
452+
/>
453+
</form.AppForm>
454+
)
455+
}
456+
```
457+
458+
If you expect your fields to always be at the top level of your form, you can create a quick map
459+
of your field groups using a helper function:
460+
461+
```tsx
462+
const defaultValues: PasswordFields = {
463+
password: '',
464+
confirm_password: '',
465+
}
466+
467+
const passwordFields = createFieldMap(defaultValues)
468+
/* This generates the following map:
469+
{
470+
'password': 'password',
471+
'confirm_password': 'confirm_password'
472+
}
473+
*/
474+
475+
// Usage:
476+
<PasswordFields
477+
form={form}
478+
fields={passwordFields}
479+
title="Passwords"
480+
/>
481+
```
482+
251483
## Tree-shaking form and field components
252484

253485
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.
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/react/large-form/src/features/people/page.tsx

Lines changed: 3 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,8 @@ 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" />
62+
6863
<form.AppForm>
6964
<form.SubscribeButton label="Submit" />
7065
</form.AppForm>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ function SubscribeButton({ label }: { label: string }) {
1313
)
1414
}
1515

16-
export const { useAppForm, withForm } = createFormHook({
16+
export const { useAppForm, withForm, withFieldGroup } = createFormHook({
1717
fieldComponents: {
1818
TextField,
1919
},

0 commit comments

Comments
 (0)