Skip to content

Commit

Permalink
Merge pull request #7 from atmina/fieldarray
Browse files Browse the repository at this point in the history
feat: Improve `useFieldArray` ergonomics
  • Loading branch information
mvarendorff2 authored Feb 14, 2024
2 parents 972a23d + 557ab66 commit f2a7c1f
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 9 deletions.
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ const AddressSubform: FC<{field: FormBuilder<Address>}> = ({field}) => {
}
```

## Field arrays

Fields which are typed as arrays provide a `$useFieldArray()` hook which can be used to map over the contents, as well
as mutate them using operations such as `append`, `insert`, `move` and `remove`.

The `fields` returned by `$useFieldArray` are themselves `FormBuilder`s that can be registered on inputs or passed to
other Subform components.

```tsx
import { FC } from "react";

const AddressesSubform: FC<{field: FormBuilder<Person[]>}> = ({field}) => {
const {fields, append} = field.$useFieldArray();
const add = () => {
append({state: '', city: '', /* etc. */});
}
return <div>
{fields.map(f => <AddressSubForm key={f.$key} field={f} />)}
<button onClick={add}>Add new address</button>
<div>
}
```

The `$key` contains a unique id for the array item and must be passed as the `key` when [rendering the list](https://react.dev/learn/rendering-lists).

Note: Field arrays are intended for use with arrays of objects. When dealing with arrays of primitives, you can either
wrap the primitive in an object, or use a controller (`$useController`) to implement your own array logic.

For more information, see the React Hook Form docs on [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray).

## Compatibility with `useForm`

Currently, `useFormBuilder` is almost compatible with `useForm`. This means you get the entire bag of tools provided by
Expand All @@ -107,4 +137,4 @@ streamline this API and increase its type-safety.

## License

MIT
MIT
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@atmina/formbuilder",
"version": "0.0.7",
"version": "1.0.0",
"description": "A strongly-typed alternative API for React Hook Form.",
"source": "src/index.ts",
"main": "lib/index.js",
Expand Down
38 changes: 37 additions & 1 deletion src/formbuilder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ describe("useFormBuilder", () => {
firstName: string;
lastName: string;
};
list: {
id: string;
action: string;
}[];
}

const createHarness = (
Expand Down Expand Up @@ -59,6 +63,10 @@ describe("useFormBuilder", () => {
firstName: "John",
lastName: "Smith",
},
list: [
{ id: "0", action: "frobnicate" },
{ id: "1", action: "skedaddle" },
],
};

beforeAll(() => {
Expand Down Expand Up @@ -151,7 +159,10 @@ describe("useFormBuilder", () => {

await waitFor(() => {
expect(watchedRoot).toHaveTextContent(
JSON.stringify({ person: { firstName: "Joe", lastName: "Smith" } })
JSON.stringify({
...defaultValues,
person: { ...defaultValues.person, firstName: "Joe" },
})
);
expect(watchedRoot).toHaveTextContent("Smith");
expect(watchedFirstName).toHaveTextContent("Joe");
Expand Down Expand Up @@ -218,4 +229,29 @@ describe("useFormBuilder", () => {
expect(errorType).toHaveTextContent("required");
});
});

test("$useFieldArray", async () => {
const harness = createHarness({ defaultValues }, (builder) => {
const { fields } = builder.fields.list.$useFieldArray();

return (
<div>
{fields.map((field, i) => (
<input
key={field.$key}
{...field.action()}
aria-label={`action-${i}`}
/>
))}
</div>
);
});

render(<harness.Form />);

await waitFor(() => {
expect(screen.getByLabelText("action-0")).toHaveValue("frobnicate");
expect(screen.getByLabelText("action-1")).toHaveValue("skedaddle");
});
});
});
25 changes: 19 additions & 6 deletions src/formbuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ type FormBuilderRegisterFn<T> = {
*/
export function createFormBuilder<TFieldValues extends FieldValues>(
methods: UseFormReturn<TFieldValues>,
path: string[]
path: string[],
// Set if created in $useFieldArray()
key?: string,
): FormBuilder<TFieldValues> {
const currentPath = path.join(".") as FieldPath<TFieldValues>;
// Cache generated functions to stabilize references across re-renders.
Expand All @@ -160,14 +162,23 @@ export function createFormBuilder<TFieldValues extends FieldValues>(
// Called when used with `String(...)`.
useCached = () => currentPath;
break;
case "$key":
return key ?? currentPath;
case "$useFieldArray":
useCached = (props?: $UseFieldArrayProps<never>) =>
useFieldArray({
useCached = (props?: $UseFieldArrayProps<never>) => {
const { fields, ...rest } = useFieldArray({
name: currentPath as FieldArrayPath<TFieldValues>,
keyName: "key" as const,
keyName: "$key" as const,
control,
...props,
});
return {
fields: fields.map(
({ $key }, i) => createFormBuilder(methods, [...path, i.toString()], $key)
),
...rest
};
}
break;
case "$useController":
useCached = (
Expand Down Expand Up @@ -331,10 +342,12 @@ interface $UseFieldArrayProps<T> {
shouldUnregister?: boolean;
}

type $UseFieldArrayReturn<T> = UseFieldArrayReturn<
type $UseFieldArrayReturn<T> = Omit<UseFieldArrayReturn<
{ __: T[] },
T extends Primitive | BrowserNativeObject ? never : "__"
>;
>, "fields"> & {
fields: (FormBuilder<T> & {$key: string})[];
};

export type UseFormBuilderProps<
TFieldValues extends FieldValues = FieldValues,
Expand Down

0 comments on commit f2a7c1f

Please sign in to comment.