From 13f5745446ad4326ceaf9f031f65b6a6ab0ad34c Mon Sep 17 00:00:00 2001 From: MiroslavPetrik Date: Wed, 6 Mar 2024 13:34:19 +0100 Subject: [PATCH] feat(listField): Refactor list field (#117) BREAKING CHANGE: listAtom extracted to `@form-atoms/list-atom` which is now a required peer Dependency for `listField()` BREAKING CHANGE: The `builder` config property from `listField` was renamed to `fields`. BREAKING CHANGE: The `builder` config property no longer accepts `FieldAtom` as return type. `FormFields` must be returned. --- CHANGELOG.md | 7 + README.md | 66 +-- package.json | 2 + src/Intro.mdx | 23 +- src/atoms/_useFieldInitialValue.ts | 29 - src/atoms/extendAtom.ts | 35 ++ src/atoms/extendFieldAtom.ts | 34 -- src/atoms/index.ts | 2 - src/atoms/list-atom/index.ts | 2 - src/atoms/list-atom/listAtom.test.ts | 518 ------------------ src/atoms/list-atom/listAtom.ts | 368 ------------- src/atoms/list-atom/listBuilder.test-d.ts | 55 -- src/atoms/list-atom/listBuilder.test.ts | 65 --- src/atoms/list-atom/listBuilder.ts | 58 -- src/atoms/list-atom/listItemForm.ts | 198 ------- src/atoms/types.ts | 12 + src/atoms/upload-atom/uploadAtom.ts | 6 +- src/components/index.ts | 1 - src/components/list/Docs.mdx | 442 --------------- src/components/list/List.stories.tsx | 412 -------------- src/components/list/List.test.tsx | 224 -------- src/components/list/List.tsx | 116 ---- .../list/List.withRadioControl.stories.tsx | 103 ---- src/components/list/ListField.mock.tsx | 23 - src/components/list/index.ts | 1 - src/components/list/item/Item.stories.tsx | 89 --- src/fields/list-field/Docs.mdx | 56 +- src/fields/list-field/ListField.mock.tsx | 22 + src/fields/list-field/listField.stories.tsx | 219 ++++++++ src/fields/list-field/listField.test-d.ts | 39 +- src/fields/list-field/listField.test.tsx | 250 +++------ src/fields/list-field/listField.ts | 50 +- src/fields/zod-field/zodField.ts | 15 +- src/hooks/index.ts | 3 - src/hooks/use-hydrate-field/index.ts | 1 - .../use-hydrate-field/useHydrateField.ts | 20 - src/hooks/use-list-actions/index.ts | 1 - src/hooks/use-list-actions/useListActions.ts | 57 -- .../use-list-field-initial-value/index.ts | 1 - .../useListFieldInitialValue.test.ts | 42 -- .../useListFieldInitialValue.ts | 13 - src/hooks/use-list-field/Docs.mdx | 110 ---- src/hooks/use-list-field/index.ts | 1 - src/hooks/use-list-field/useListField.test.ts | 182 ------ src/hooks/use-list-field/useListField.ts | 34 -- src/scenarios/PicoFieldErrors.tsx | 2 +- src/scenarios/PicoFieldName.tsx | 15 - yarn.lock | 16 + 48 files changed, 502 insertions(+), 3538 deletions(-) delete mode 100644 src/atoms/_useFieldInitialValue.ts create mode 100644 src/atoms/extendAtom.ts delete mode 100644 src/atoms/extendFieldAtom.ts delete mode 100644 src/atoms/list-atom/index.ts delete mode 100644 src/atoms/list-atom/listAtom.test.ts delete mode 100644 src/atoms/list-atom/listAtom.ts delete mode 100644 src/atoms/list-atom/listBuilder.test-d.ts delete mode 100644 src/atoms/list-atom/listBuilder.test.ts delete mode 100644 src/atoms/list-atom/listBuilder.ts delete mode 100644 src/atoms/list-atom/listItemForm.ts create mode 100644 src/atoms/types.ts delete mode 100644 src/components/list/Docs.mdx delete mode 100644 src/components/list/List.stories.tsx delete mode 100644 src/components/list/List.test.tsx delete mode 100644 src/components/list/List.tsx delete mode 100644 src/components/list/List.withRadioControl.stories.tsx delete mode 100644 src/components/list/ListField.mock.tsx delete mode 100644 src/components/list/index.ts delete mode 100644 src/components/list/item/Item.stories.tsx create mode 100644 src/fields/list-field/ListField.mock.tsx create mode 100644 src/fields/list-field/listField.stories.tsx delete mode 100644 src/hooks/use-hydrate-field/index.ts delete mode 100644 src/hooks/use-hydrate-field/useHydrateField.ts delete mode 100644 src/hooks/use-list-actions/index.ts delete mode 100644 src/hooks/use-list-actions/useListActions.ts delete mode 100644 src/hooks/use-list-field-initial-value/index.ts delete mode 100644 src/hooks/use-list-field-initial-value/useListFieldInitialValue.test.ts delete mode 100644 src/hooks/use-list-field-initial-value/useListFieldInitialValue.ts delete mode 100644 src/hooks/use-list-field/Docs.mdx delete mode 100644 src/hooks/use-list-field/index.ts delete mode 100644 src/hooks/use-list-field/useListField.test.ts delete mode 100644 src/hooks/use-list-field/useListField.ts delete mode 100644 src/scenarios/PicoFieldName.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f7f8b4..f22f121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [4.1.0-next.1](https://github.com/form-atoms/field/compare/v4.0.16...v4.1.0-next.1) (2024-03-06) + + +### Features + +* **listField:** Refactor list field ([#117](https://github.com/form-atoms/field/issues/117)) ([8bc738f](https://github.com/form-atoms/field/commit/8bc738fbd4048c581dd7f1bc8ec845e6dab2ce22)), closes [#116](https://github.com/form-atoms/field/issues/116) + ## [4.0.16](https://github.com/form-atoms/field/compare/v4.0.15...v4.0.16) (2024-03-06) diff --git a/README.md b/README.md index 6c80d68..405eb2e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@
-

Atomic Form Fields for React

+

@form-atoms/field

-Declarative & headless form fields build on top of [`jotai & form-atoms`](https://github.com/form-atom/form-atoms). +A `zod`-powered [`fieldAtoms`](https://github.com/form-atoms/form-atoms?tab=readme-ov-file#fieldatom) with pre-configured schemas for type & runtime safety. ``` -yarn add jotai form-atoms @form-atoms/field +npm install jotai jotai-effect form-atoms @form-atoms/field zod ``` @@ -16,27 +16,14 @@ yarn add jotai form-atoms @form-atoms/field Code coverage -## Motivation +## Features -`form-atoms` is the 'last-mile' of your app's form stack. It has layered, bottom-up architecture with clear separation of concerns. -We provide you with stable pre-fabricated UI fields, while you still can go one level down and take the advantage of form primitives to develop anything you need. +- [x] **Well-typed fields** required & validated by default +- [x] **Initialized field values**, commonly with `undefined` empty value +- [x] **Optional fields** with schema defaulting to `z.optional()` +- [x] **Conditionally required fields** - the required state can depend on other jotai atoms. -To contrast it with formik or react-hook-form, our form state thanks to `jotai` lives outside of the react tree, so you never lose it when the component unmounts. -Moreover, jotai's external state unlike redux-form has compact API with 'atom-local reducer' and automatic dependency tracking leading to unmatched rendering performance. - -![architecture](./architecture.png) - -### Key differences to other form libraries - -#### No 'dotted keypath' access - -Some libraries use `path.to.field` approach with field-dependent validation or when reading field at other place. We don't need such paths, as fields can be moved arround in regular JavaScript variables, as they are jotai atoms in reality. - -#### Persistent form state by default - -With others libraries you often lose form state when your component or page unmounts. Thats because the rendered form hook maintains the store. If the library provides a contextual API, you can opt-in into the persistence, so form state lives even when you unmount the form. - -`form-atoms` on the other hand keeps the form state until you clear it, because it lives in jotai atoms. This way, you don't have to warn users about data loss if they navigate out of filled & unsubmitted form. Instead you can display 'continue where you left off' message when they return to the form. +The fields are integrated with the following components: #### Atomic Components @@ -45,7 +32,7 @@ a clickable label which focuses the respective input, or a custom indicator whet We take care of these details in atomic 'low-level' components like `PlaceholderOption`, `FieldLabel` and `RequirementIndicator` respectively. -#### Generic Native Components +#### Generic Components With other form libraries you might find yourself repeatedly wiring them into recurring scenarios like checkbox multi select or radio group. We've created highly reusable generic components which integrate the native components. @@ -57,37 +44,30 @@ Lastly to capture a list of objects, you will find the [ListField](https://form- ## Docs -Checkout [our Storybook docs](https://form-atoms.github.io/field/) and for additional primitives see the [`form-atoms` docs](https://github.com/form-atoms/form-atoms). - -## Fields +See [Storybook docs](https://form-atoms.github.io/field/) -For well-known field types we export data type specific `fieldAtom` constructors. These come with -pre-defined empty value of `undefined` and a specific zod validation schema. -Similarly to `zod` schema fields, by default all the fieldAtoms are required. - -### Usage +### Quick start ```tsx -import { numberField, stringField, Select } from "@form-atoms/field"; -import { fromAtom, useForm } from "form-atoms"; +import { textField, numberField, stringField, Select } from "@form-atoms/field"; +import { fromAtom, useForm, Input } from "form-atoms"; import { z } from "zod"; -import { NumberField } from "@form-atoms/flowbite"; // or /chakra-ui - -const height = numberField(); -const age = numberField({ schema: z.number().min(18) }); // override default schema -const character = stringField().optional(); // make field optional -const personForm = formAtom({ height, age, character }); +const personForm = formAtom({ + name: textField(), + age: numberField({ schema: z.number().min(18) }); // override default schema + character: stringField().optional(); // make field optional +}); export const Form = () => { - const { submit } = useForm(personForm); + const { fieldAtoms, submit } = useForm(personForm); return (
- - + + `. | | [RequirementIndicator](?path=/docs/components-requirementindicator--docs) | Displays an indicator whether a field is required or optional. | #### Native Generic Components -| | | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| [CheckboxGroup](?path=/docs/components-checkboxgroup--docs) | Select multiple values from a list of generic options via ``. | -| [List](?path=/docs/components-list--docs) | An advanced headless component to control the [listField()](?path=/docs/fields-listField--docs). | -| [MultiSelect](?path=/docs/components-multiselect--docs) | Select multiple values via ``. | -| [Select](?path=/docs/components-select--docs) | Select a generic option via ``. | +| [MultiSelect](?path=/docs/components-multiselect--docs) | Select multiple values via ``. | +| [Select](?path=/docs/components-select--docs) | Select a generic option via `} - /> - } - /> - - - )} - -); -``` - -### List of primitive values - - - - - -```tsx -import { InputField } from "form-atoms"; -import { List, listField, textField } from "@form-atoms/field"; - -const productPros = listField({ - value: ["quality materials used", "not so heavy"], - builder: (value) => textField({ value }), -}); - -const Example = () => ( - <> - - - {({ fields, RemoveButton }) => ( -
- {/* IMPORTANT NOTE: when the list item contains primitive field, the `fields` prop is the primitive field atom: */} - - -
- )} -
- -); -``` - -### Custom `AddButton` - - - - - -```tsx -import { InputField } from "form-atoms"; -import { - type AddButtonProps, - type ListField, - List, - listField, - textField, -} from "@form-atoms/field"; - -const productPros = listField({ - value: ["quality materials used", "not so heavy"], - builder: (value) => textField({ value }), -}); - -// helper to infer the fields type returned from the "builder" -type ListFields = T extends ListField ? Fields : never; - -// Having static component requires us to type the props manually. -// To avoid explicit props, you can inline the component as `` prop. -const AddButton = ({ add }: AddButtonProps>) => ( - -); - -const Example = () => ( - <> - - - {({ fields, RemoveButton }) => ( -
- - -
- )} -
- -); -``` - -### `Empty` render prop - - - - - -```tsx -import { InputField } from "form-atoms"; -import { List, listField, textField } from "@form-atoms/field"; - -const productPros = listField({ - value: ["quality materials used", "not so heavy"], - builder: (value) => textField({ value }), -}); - -// BEST PRACTICE: define your component statically. Avoid inline definitions when specifying props. -// NOTE: The `Empty` prop does not accept any props -const NoHobbiesCard = () => ( -
-

- You don't have any hobbies in your list. Start by adding your first one. -

-
-); - -const Example = () => ( - - {({ fields, RemoveButton }) => ( -
- - -
- )} -
-); -``` - -### Ordering items - - - - - -```tsx -import { InputField } from "form-atoms"; -import { List, listField, textField } from "@form-atoms/field"; - -const hobbies = listField({ - value: [], - name: "hobbies", - builder: (value) => textField({ value }), -}); - -const Example = () => ( - - {({ fields, moveUp, moveDown, RemoveButton }) => ( -
- - - - -
- )} -
-); -``` - -## Advanced - -### Nested List - - - - - -```tsx -import { InputField } from "form-atoms"; -import { FieldLabel, listField, List, textField } from "@form-atoms/field"; - -const users = listField({ - name: "users", - value: [ - { - name: "Jerry", - lastName: "Park", - accounts: [{ iban: "SK89 7500 0000 0000 1234 5671" }], - }, - ], - builder: ({ name, lastName, accounts = [] }) => ({ - name: textField({ value: name }), - lastName: textField({ value: lastName }), - accounts: listField({ - name: "accounts", - value: accounts, - builder: ({ iban }) => ({ iban: textField({ value: iban }) }), - }), - }), -}); - -const NestedListExample = () => { - return ( - ( - - )} - > - {({ fields, index, remove }) => ( -
-
- -
-
-
- - } - /> -
-
- - } - /> -
-
- ( - - )} - RemoveButton={RemoveButton} - > - {({ fields, index, RemoveButton: RemoveIban }) => ( - <> - -
- } - /> - -
- - )} -
-
- )} -
- ); -}; - -const RemoveButton = ({ remove }: RemoveButtonProps) => ( - -); -``` - -### Composed List Field - - - - - -```tsx -import { ReactNode } from "react"; - -import { - List, - ListProps, - FieldLabel, - FieldErrors, - ListAtomItems, - ListAtomValue, -} from "@form-atoms/field"; - -export const ListField = ({ - field, - label, - ...listProps -}: { - label: ReactNode; -} & ListProps>) => { - return ( - <> - - -
- -
- - ); -}; -``` diff --git a/src/components/list/List.stories.tsx b/src/components/list/List.stories.tsx deleted file mode 100644 index 68b9195..0000000 --- a/src/components/list/List.stories.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import { StoryObj } from "@storybook/react"; -import { InputField } from "form-atoms"; - -import { AddButtonProps, List, ListProps, RemoveButtonProps } from "./List"; -import { ListField } from "./ListField.mock"; -import { ListAtomItems, ListAtomValue } from "../../atoms/list-atom"; -import { - type ListField as TListField, - listField, - textField, -} from "../../fields"; -import { PicoFieldName } from "../../scenarios/PicoFieldName"; -import { StoryForm } from "../../scenarios/StoryForm"; -import { FieldLabel } from "../field-label"; - -const RemoveButton = ({ remove }: RemoveButtonProps) => ( - -); - -const AddButton = ({ add }: AddButtonProps) => ( - -); - -const meta = { - component: List, - args: { - AddButton, - RemoveButton, - }, -}; - -export default meta; - -const AddHobbyButton = ({ add }: AddButtonProps) => ( - -); - -const listStory = ( - storyObj: { - args: ListProps>; - } & Omit, "args">, -) => ({ - ...storyObj, - decorators: [ - (Story: () => JSX.Element) => ( - - {() => } - - ), - ], -}); - -export const ListOfObjects = listStory({ - parameters: { - docs: { - description: { - story: - "Usually the List is used to capture a list of objects like addresses or environment variables:.", - }, - }, - }, - args: { - field: listField({ - name: "environment", - value: [ - { variable: "GITHUB_TOKEN", value: "ff52d09a" }, - { variable: "NPM_TOKEN", value: "deepsecret" }, - ], - builder: ({ variable, value }) => ({ - variable: textField({ name: "variable", value: variable }), - value: textField({ name: "value", value: value }), - }), - }), - children: ({ fields, RemoveButton }) => ( -
-
- } - /> - -
-
- ( - - )} - /> - -
-
- -
-
- ), - }, -}); - -export const ListOfPrimitiveValues = listStory({ - parameters: { - docs: { - description: { - story: - "Your `listField` builder can produce plain field atoms, as opposed to the common `FormFields` object. This is useful when you want to capture list of primitives, e.g. `string[]` or `number[]`. For example we can capture list of pros (and cons) as if in eshop product review:", - }, - }, - }, - args: { - field: listField({ - name: "productReview", - value: ["quality materials used", "not so heavy"], - builder: (value) => textField({ value }), - }), - children: ({ fields, RemoveButton }) => ( -
- - -
- ), - }, -}); - -type ListFields = T extends TListField ? Fields : never; - -const productPros = listField({ - name: "productReview", - value: ["quality materials used", "not so heavy"], - builder: (value) => textField({ value }), -}); - -export const CustomAddButton = listStory({ - parameters: { - docs: { - description: { - story: - "The `AddButton` render prop allows not only to render a custom button. It also enables you to supply custom `FormFields` object to the `add` action. This is useful when you want to create a customized list item (e.g. with initial value).", - }, - }, - }, - args: { - field: productPros, - AddButton: ({ add }: AddButtonProps>) => ( - - ), - children: ({ fields, RemoveButton }) => ( -
- - -
- ), - }, -}); - -export const EmptyRenderProp = listStory({ - parameters: { - docs: { - description: { - story: - "Provide `Empty` render prop, to render a blank slate when the list is empty.", - }, - }, - }, - args: { - field: listField({ - value: [], - builder: (value) => textField({ value }), - }), - AddButton: AddHobbyButton, - Empty: () => ( -
-

- You don't have any hobbies in your list. Start by adding your first - one. -

-
- ), - children: ({ fields, RemoveButton }) => ( -
- - -
- ), - }, -}); - -export const Prepend = listStory({ - parameters: { - docs: { - description: { - story: "New list items can be prepended to any of the existing items.", - }, - }, - }, - args: { - field: listField({ - name: "hobbies", - value: ["gardening"], - builder: (value) => textField({ value }), - }), - AddButton: AddHobbyButton, - children: ({ fields, RemoveButton, add, item }) => ( -
- - - -
- ), - }, -}); - -export const OrderingItems = listStory({ - parameters: { - docs: { - description: { - story: - "The list items can be reordered by calling the `moveUp` and `moveDown` actions.", - }, - }, - }, - args: { - initialValue: ["coding", "gardening", "mountain bike"], - field: listField({ - value: [], - name: "hobbies", - builder: (value) => textField({ value }), - }), - AddButton: AddHobbyButton, - children: ({ fields, moveUp, moveDown, RemoveButton }) => ( -
- - - - -
- ), - }, -}); - -export const NestedList = listStory({ - parameters: { - docs: { - description: { - story: - "Since the `listField()` supports nesting, we can render `` within ``. As an example we capture multiple people with multiple banking accounts:", - }, - }, - }, - args: { - field: listField({ - name: "users", - value: [ - { - name: "Jerry", - lastName: "Park", - accounts: [{ iban: "SK89 7500 0000 0000 1234 5671" }], - }, - ], - builder: ({ name, lastName, accounts = [] }) => ({ - name: textField({ value: name, name: "name" }), - lastName: textField({ value: lastName, name: "lastName" }), - accounts: listField({ - name: "accounts", - value: accounts, - builder: ({ iban }) => ({ - iban: textField({ value: iban, name: "iban" }), - }), - }), - }), - }), - AddButton: ({ add }) => ( - - ), - children: ({ fields, index, remove }) => ( -
-
- -
-
-
- - } - /> -
-
- - } - /> -
-
- ( - - )} - RemoveButton={RemoveButton} - > - {({ fields, index, RemoveButton: RemoveIban }) => ( - <> - -
- } - /> - -
- - )} -
-
- ), - }, -}); - -export const ComposedListField = listStory({ - parameters: { - docs: { - description: { - story: - "In practice, you will want to display List together with `FieldErrors`, `FieldLabel` or `RequirementIndicator` in a custom layout. Here is an example for a `ListField`:", - }, - }, - }, - args: { - field: listField({ - value: [], - name: "hobbies", - builder: (value) => textField({ value }), - }), - children: ({ fields, RemoveButton }) => ( -
- - -
- ), - }, - render: (props) => { - return ; - }, -}); diff --git a/src/components/list/List.test.tsx b/src/components/list/List.test.tsx deleted file mode 100644 index a1a516b..0000000 --- a/src/components/list/List.test.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { act, render, renderHook, screen } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import { InputField, formAtom, useFormSubmit } from "form-atoms"; -import { describe, expect, it, vi } from "vitest"; - -import { List } from "./List"; -import { listField, numberField, stringField, textField } from "../../fields"; -import { NumberInput } from "../../fields/number-field/NumberInput.mock"; - -describe("", () => { - it("works with flat list of fields", async () => { - const fields = { - friends: listField({ - value: ["Bob", "Alice"], - builder: (value) => textField({ value }), - }), - }; - - const form = formAtom(fields); - const { result } = renderHook(() => useFormSubmit(form)); - render( - - {({ fields }) => } - , - ); - - expect(screen.getByDisplayValue("Bob")).toBeInTheDocument(); - expect(screen.getByDisplayValue("Alice")).toBeInTheDocument(); - - const onSubmit = vi.fn(); - await act(async () => result.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ friends: ["Bob", "Alice"] }); - }); - - describe("initialValue prop", () => { - it("is used as submit value", async () => { - const fields = { - friends: listField({ - value: ["Bob", "Alice"], - builder: (value) => textField({ value }), - }), - }; - - const form = formAtom(fields); - const { result } = renderHook(() => useFormSubmit(form)); - render( - - {({ fields }) => } - , - ); - - const onSubmit = vi.fn(); - await act(async () => result.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ friends: ["Mark"] }); - }); - - it("(bugfix #104) initializes with optional input without infine loop caused by perpetual store.set()", () => { - type User = { id?: string; name: string }; - // there is no "id" in initialValue - const users: User[] = [{ name: "Alice" }]; - - const userList = listField({ - value: [], - builder: ({ name, id }: User) => ({ - // the field.value will have "id: undefined" after initialization - id: stringField({ value: id }).optional(), - name: textField({ value: name }), - }), - }); - - render( - - {({ fields }) => } - , - ); - }); - }); - - describe("RemoveButton", () => { - it("has 'Remove' label by default", () => { - const fields = { - luckyNumbers: listField({ - value: [3], - builder: (value) => numberField({ value }), - }), - }; - - render( - - {({ RemoveButton }) => } - , - ); - - const RemoveButton = screen.getByText("Remove"); - - expect(RemoveButton).toBeInTheDocument(); - expect(RemoveButton).toHaveAttribute("type", "button"); - }); - - it("removes the respective list item", async () => { - const fields = { - luckyNumbers: listField({ - value: [3], - builder: (value) => numberField({ value }), - }), - }; - - render( - - {({ fields, RemoveButton }) => ( - <> - - - - )} - , - ); - - const RemoveButton = screen.getByText("Remove"); - - expect(RemoveButton).toBeInTheDocument(); - expect(screen.queryByDisplayValue("3")).toBeInTheDocument(); - - await act(() => userEvent.click(RemoveButton)); - - expect(screen.queryByDisplayValue("3")).not.toBeInTheDocument(); - }); - }); - - describe("AddButton", () => { - it("has 'Add item' label by default", () => { - const fields = { - luckyNumbers: listField({ - value: [3, 6, 9], - builder: (value) => numberField({ value }), - }), - }; - - render({() => <>}); - - const AddButton = screen.getByText("Add item"); - - expect(AddButton).toBeInTheDocument(); - expect(AddButton).toHaveAttribute("type", "button"); - }); - - it("appends empty item to the list by calling the item builder prop", async () => { - const fields = { - luckyNumbers: listField({ - value: [], - builder: () => numberField({ value: 6 }), - }), - }; - - render( - - {({ fields }) => } - , - ); - - const AddButton = screen.getByText("Add item"); - - expect(AddButton).toBeInTheDocument(); - - await act(() => userEvent.click(AddButton)); - - expect(screen.queryByDisplayValue("6")).toBeInTheDocument(); - expect(screen.queryAllByLabelText("lucky")).toHaveLength(1); - }); - - it("can add item with explicit fields", async () => { - const fields = { - luckyNumbers: listField({ - value: [], - builder: () => numberField(), - }), - }; - - render( - ( - - )} - > - {({ fields }) => } - , - ); - - const AddButton = screen.getByText("add fields"); - - await act(() => userEvent.click(AddButton)); - - expect(screen.queryByDisplayValue("9")).toBeInTheDocument(); - expect(screen.queryAllByLabelText("lucky")).toHaveLength(1); - }); - }); - - describe("Empty", () => { - it("renders when the initial list is empty", async () => { - const fields = { - luckyNumbers: listField({ - value: [], - builder: (value) => numberField({ value }), - }), - }; - - render( -

No lucky numbers

}> - {({ fields }) => } -
, - ); - - expect(screen.queryByText("No lucky numbers")).toBeInTheDocument(); - }); - }); -}); diff --git a/src/components/list/List.tsx b/src/components/list/List.tsx deleted file mode 100644 index 61e7998..0000000 --- a/src/components/list/List.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { FieldAtom, FormFields } from "form-atoms"; -import { Fragment, useCallback } from "react"; -import { RenderProp } from "react-render-prop-type"; - -import { ListAtomItems, ListAtomValue, ListItem } from "../../atoms/list-atom"; -import { type ListField } from "../../fields"; -import { useListField } from "../../hooks"; - -export type RemoveButtonProps = { remove: () => void }; -export type RemoveButtonProp = RenderProp; - -export type AddButtonProps< - Fields extends ListAtomItems = Record, -> = { - add: (fields?: Fields) => void; -}; -export type AddButtonProp = RenderProp< - AddButtonProps, - "AddButton" ->; - -export type EmptyProp = RenderProp; - -export type ListItemProps = { - item: ListItem; - /** - * The index of the current item. - */ - index: number; - /** - * Total count of items in the list. - */ - count: number; - /** - * The fields of current item, as returned from the builder function. - */ - fields: Fields; - /** - * Append a new item to the end of the list. - * WHen called with current item, it will be prepend with a new item. - */ - add: (before?: ListItem) => void; - /** - * Removes the current item. - */ - remove: () => void; - /** - * Moves the current item one slot up in the list. - * When called for the first item, the action is no-op. - */ - moveUp: () => void; - /** - * Moves the current item one slot down in the list. - * When called for the last item, the item moves to the start of the list. - */ - moveDown: () => void; -} & RenderProp; -export type ListItemProp = RenderProp< - ListItemProps ->; - -export type ListFields = FieldAtom[] | FormFields[]; - -export type ListProps< - Fields extends ListAtomItems, - Value extends ListAtomValue, -> = Partial & EmptyProp> & { - field: ListField; - initialValue?: Value[]; -} & ListItemProp; - -export function List< - Fields extends ListAtomItems, - Value extends ListAtomValue, ->({ - field, - initialValue, - children, - RemoveButton = ({ remove }) => ( - - ), - AddButton = ({ add }) => ( - - ), - Empty, -}: ListProps) { - const { add, isEmpty, items } = useListField(field, { initialValue }); - - return ( - <> - {isEmpty && Empty ? : undefined} - {items.map(({ remove, fields, key, item, moveUp, moveDown }, index) => ( - - {children({ - item, - add, - remove, - moveUp, - moveDown, - fields, - index, - count: items.length, - RemoveButton: () => , - })} - - ))} - add(undefined, fields), [add])} - /> - - ); -} diff --git a/src/components/list/List.withRadioControl.stories.tsx b/src/components/list/List.withRadioControl.stories.tsx deleted file mode 100644 index 65ce494..0000000 --- a/src/components/list/List.withRadioControl.stories.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { InputField } from "form-atoms"; - -import { List, RemoveButtonProps } from "./List"; -import { checkboxField, listField, textField } from "../../fields"; -import { formStory, meta } from "../../scenarios/StoryForm"; -import { FieldLabel } from "../field-label"; -import { Radio, RadioControl } from "../radio"; - -export default { - ...meta, - title: "components/List", -}; - -export const Experimental_WithRadioControl = formStory({ - parameters: { - docs: { - description: { - story: - "The item fields are regular fields and can be managed from outside. Here we have an advanced example where the `` is wrapped in a custom ` which manages the item's primary field realized as a `booleanField()`.", - }, - }, - }, - args: { - fields: { - phones: listField({ - value: [ - { - number: "+421 200 400 600", - isPrimary: false, - }, - { - number: "+420 900 700 500", - isPrimary: true, - }, - ], - builder: ({ number }) => ({ - number: textField({ value: number }), - isPrimary: checkboxField({ name: "primaryPhone" }).optional(), - }), - }), - }, - children: ({ fields }) => ( - - {({ control }) => ( - ( - - )} - RemoveButton={RemoveButton} - > - {({ fields, RemoveButton }) => ( - <> -
- ( - - )} - /> - - -
- - {(props) => ( -
- - -
- )} -
- - )} -
- )} -
- ), - }, -}); - -const RemoveButton = ({ remove }: RemoveButtonProps) => ( - -); diff --git a/src/components/list/ListField.mock.tsx b/src/components/list/ListField.mock.tsx deleted file mode 100644 index e80c6ef..0000000 --- a/src/components/list/ListField.mock.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ReactNode } from "react"; - -import { FieldLabel } from ".."; -import { ListAtomItems, ListAtomValue } from "../../atoms/list-atom"; -import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; - -import { List, ListProps } from "."; - -export const ListField = ({ - field, - label, - ...listProps -}: { - label: ReactNode; -} & ListProps>) => { - return ( - <> - - - - - ); -}; diff --git a/src/components/list/index.ts b/src/components/list/index.ts deleted file mode 100644 index 870e1dc..0000000 --- a/src/components/list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./List"; diff --git a/src/components/list/item/Item.stories.tsx b/src/components/list/item/Item.stories.tsx deleted file mode 100644 index 80e258f..0000000 --- a/src/components/list/item/Item.stories.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Meta } from "@storybook/react"; - -import { ListAtomItems } from "../../../atoms/list-atom"; -import { ListItemProps } from "../List"; - -const ListItem = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - props: ListItemProps, -) => <>; - -const meta = { - component: ListItem, - // TODO: drop the custom argTypes, as it should be autogenerated from the component property! - argTypes: { - item: { - description: - "Current list item. Can be used as argument to `add`, `move` or `remove`.", - table: { - type: { - summary: "ListItem", - }, - }, - }, - index: { - description: "Index of the current item", - table: { - type: { - summary: "number", - }, - }, - }, - count: { - description: "Total count of items", - table: { - type: { - summary: "number", - }, - }, - }, - add: { - description: - "Append a new item to the end of the list. WHen called with current item, it will be prepend with a new item.", - table: { - type: { - summary: "(before?: ListItem) => void", - }, - }, - }, - remove: { - description: "Removes the current item.", - table: { - type: { - summary: "() => void", - }, - }, - }, - moveUp: { - description: - "Moves the current item one slot up in the list. When called for the first item, the action is no-op.", - table: { - type: { - summary: "() => void", - }, - }, - }, - moveDown: { - description: - "Moves the current item one slot down in the list. When called for the last item, the item moves to the start of the list.", - table: { - type: { - summary: "() => void", - }, - }, - }, - RemoveButton: { - description: - "A component to render the `RemoveButton` passed to the original ``. It removes the current item when clicked.. It doesn't accept props.", - table: { - type: { - summary: "FunctionComponent", - }, - }, - }, - }, -} satisfies Meta; - -export default meta; - -export const Primary = {}; diff --git a/src/fields/list-field/Docs.mdx b/src/fields/list-field/Docs.mdx index f6a5f92..5ab999f 100644 --- a/src/fields/list-field/Docs.mdx +++ b/src/fields/list-field/Docs.mdx @@ -1,6 +1,7 @@ import { Meta, Stories } from "@storybook/blocks"; +import * as listFieldStories from "./listField.stories"; - +
@@ -8,7 +9,7 @@ import { Meta, Stories } from "@storybook/blocks"; # `listField(): RequiredListField` -A field to capture list of generic items. You can think of each item as a separate form. It requires a `builder` function returning fields, which will be used inside of the form. +A field to capture list of generic items. You can think of each item as a separate form. It requires a `fields` function returning `FormFields`, which will be used inside of the form.
@@ -29,7 +30,7 @@ import { listField } from "@form-atoms/field"; ## Configuration -### `builder` (required) +### `fields` (required) #### Type @@ -37,7 +38,7 @@ import { listField } from "@form-atoms/field"; #### Description -The `listField` is defined by the shape of the fields returned from its `builder`. If you supply an initial `value`, each of the items will be applied to the builder. +The `listField` is defined by the shape of the fields returned from its form `fields`. If you supply an initial `value`, each of the items will be applied to the `fields` constructor. #### Usage @@ -51,24 +52,13 @@ const environmentVariables = listField({ { name: "GITHUB_TOKEN", value: "myToken" }, { name: "NPM_TOKEN", value: "privateToken" }, ], - builder: ({ name, value }) => ({ + fields: ({ name, value }) => ({ name: textField({ value: name }), value: textField({ value }), }), }); ``` -##### Building a list of primitive values - -Here, our items are plain strings `typeof item === "string"`: - -```js -const typeScriptBenefits = listField({ - value: ["safe function calls", "it's fast"], - builder: (item) => textField({ value: item }), -}); -``` - ### `schema` #### Type @@ -86,8 +76,8 @@ As an example, we can require the user to insert between 1-3 recovery phrases: ```js const recoveryPhrases = listField({ value: [], - schema: z.array(z.any()).nonempty().max(3), - builder: ({ hint, phrase }) => ({ + schema: z.array(z.any()).nonempty().max(2), + fields: ({ hint, phrase }) => ({ hint: textField({ value: hint }), phrase: textField({ value: phrase }), }), @@ -114,7 +104,7 @@ The error to add to the `listField` when submitted while there are errors in the const recoveryPhrases = listField({ name: "recoveryPhrases", value: [], - builder: ({ hint, phrase }) => ({ + fields: ({ hint, phrase }) => ({ hint: textField({ value: hint }), phrase: textField({ value: phrase }), }), @@ -167,30 +157,4 @@ const field = listField(config).optional(); field === field.optional(); // true ``` -## Recipes - -### Initializing values - -In practice the `config.value` is not used, as the field is usually initialized 'statically' in the module - outside of the React components, where there is no access to the values. -The `listField` can be initialized as any other field with the `useFieldInitialValue` hook: - -```tsx -import { useFieldInitialValue } from "form-atoms"; -import { listField } from "@form-atoms/field"; - -// no access to props/query data outside of component! -const typeScriptBenefits = listField({ - value: [], - builder: (item) => textField({ value: item }), -}); - -const MyComponent = (props: { initialValue?: string[] }) => { - const { initialValue = ["safe function calls", "it's fast"] } = props; - - useFieldInitialValue(item, initialValue); - - return <>; -}; -``` - -Generally, this is not needed, as most of the time we use the `useListField()` hook to manage the list field. + diff --git a/src/fields/list-field/ListField.mock.tsx b/src/fields/list-field/ListField.mock.tsx new file mode 100644 index 0000000..dd21af5 --- /dev/null +++ b/src/fields/list-field/ListField.mock.tsx @@ -0,0 +1,22 @@ +import { List, type ListProps } from "@form-atoms/list-atom"; +import type { FormFields } from "form-atoms"; +import type { ReactNode } from "react"; + +import { FieldLabel } from "../../components"; +import { PicoFieldErrors } from "../../scenarios/PicoFieldErrors"; + +export const ListField = ({ + atom, + label, + ...listProps +}: { + label: ReactNode; +} & ListProps) => { + return ( + <> + + + + + ); +}; diff --git a/src/fields/list-field/listField.stories.tsx b/src/fields/list-field/listField.stories.tsx new file mode 100644 index 0000000..643b9fc --- /dev/null +++ b/src/fields/list-field/listField.stories.tsx @@ -0,0 +1,219 @@ +import { + AddButtonProps, + List, + ListProps, + RemoveButtonProps, +} from "@form-atoms/list-atom"; +import { StoryObj } from "@storybook/react"; +import { FormFields, InputField } from "form-atoms"; +import { z } from "zod"; + +import { ListField } from "./ListField.mock"; +import { TextField, listField, textField } from ".."; +import { StoryForm } from "../../scenarios/StoryForm"; + +const meta = { + title: "fields/listField", + component: List, + args: { + AddButton: ({ add }: AddButtonProps) => ( + + ), + RemoveButton: ({ remove }: RemoveButtonProps) => ( + + ), + }, +}; + +export default meta; + +const listStory = ( + storyObj: { + args: ListProps; + } & Omit, "args">, +) => ({ + ...storyObj, + decorators: [ + (Story: () => JSX.Element) => ( + + {() => } + + ), + ], + render: (props: ListProps) => { + return ; + }, +}); + +export const RequiredListField = listStory({ + parameters: { + docs: { + description: { + story: + "Required `listField` (default) must have at least 1 item of valid fields. When submitted empty, the field will get the `required_error` message.", + }, + }, + }, + args: { + atom: listField({ + name: "environment", + value: [], + fields: ({ variable, value }) => ({ + variable: textField({ name: "variable", value: variable }), + value: textField({ name: "value", value: value }), + }), + }), + children: ({ fields, RemoveButton }) => ( +
+
+ } + /> +
+
+ ( + + )} + /> +
+
+ +
+
+ ), + }, +}); + +export const OptionalListField = listStory({ + parameters: { + docs: { + description: { + story: "Optional `listField` can be submitted empty.", + }, + }, + }, + args: { + atom: listField({ + name: "environment", + value: [], + fields: ({ variable, value }) => ({ + variable: textField({ name: "variable", value: variable }), + value: textField({ name: "value", value: value }), + }), + }).optional(), + children: ({ fields, RemoveButton }) => ( +
+
+ } + /> +
+
+ ( + + )} + /> +
+
+ +
+
+ ), + }, +}); + +export const RequiredListFieldWithCustomSchema = listStory({ + parameters: { + docs: { + description: { + story: + "The required list length can be constrained with custom `schema` passed to the `listField`, here `z.array(z.any()).nonempty().max(2)`.", + }, + }, + }, + args: { + AddButton: ({ + add, + }: AddButtonProps<{ phrase: TextField; hint: TextField }>) => ( + + ), + atom: listField({ + name: "recoveryPhrases", + value: [ + { + phrase: "pinkipinkyponky", + hint: "favorite song (lower case; no spaces)", + }, + { + phrase: "cherry walnut walnut", + hint: "trees in garden, front to back, lower case, spaced", + }, + ], + schema: z.array(z.any()).nonempty().max(2), + fields: ({ hint, phrase }) => ({ + hint: textField({ name: "hint", value: hint }), + phrase: textField({ name: "phrase", value: phrase }), + }), + invalidItemError: + "Some of your phrases are empty. Please remove or complete them.", + }), + children: ({ fields, RemoveButton }) => ( +
+
+ ( + + )} + /> +
+
+ } + /> +
+
+ +
+
+ ), + }, + render: (props) => { + return ; + }, +}); diff --git a/src/fields/list-field/listField.test-d.ts b/src/fields/list-field/listField.test-d.ts index acdafab..2de67fa 100644 --- a/src/fields/list-field/listField.test-d.ts +++ b/src/fields/list-field/listField.test-d.ts @@ -1,32 +1,43 @@ +import { ListAtom } from "@form-atoms/list-atom"; import { formAtom } from "form-atoms"; import { expectTypeOf, test } from "vitest"; -import { listField } from "./listField"; +import { ListField, listField } from "./listField"; import { FormSubmitValues } from "../../components/form"; -import { numberField } from "../number-field"; +import { NumberField, numberField } from "../number-field"; -test("required listField has '[itemType, ...itemType[]]' submit value", () => { - const form = formAtom({ - field: listField({ - value: [], - builder: (value) => numberField({ value }), - }), - }); +// test("required listField has '[itemType, ...itemType[]]' submit value", () => { +// const form = formAtom({ +// field: listField({ +// value: [], +// fields: ({ age }) => ({ age: numberField({ value: age }) }), +// }), +// }); - expectTypeOf>().toEqualTypeOf<{ - field: [number, ...number[]]; - }>(); +// expectTypeOf>().toEqualTypeOf<{ +// field: [{ age: number }, ...{ age: number }[]]; +// }>(); +// }); + +test("ListField is assignable to ListAtom", () => { + type Fields = { age: NumberField }; + + expectTypeOf< + ListField extends ListAtom + ? true + : false + >().toEqualTypeOf(); }); test("optional listField has 'itemType[]' submit value", () => { const form = formAtom({ field: listField({ value: [], - builder: (value) => numberField({ value }), + fields: ({ age }) => ({ age: numberField({ value: age }) }), }).optional(), }); expectTypeOf>().toEqualTypeOf<{ - field: number[]; + field: { age: number }[]; }>(); }); diff --git a/src/fields/list-field/listField.test.tsx b/src/fields/list-field/listField.test.tsx index 1e6e6b1..008fab6 100644 --- a/src/fields/list-field/listField.test.tsx +++ b/src/fields/list-field/listField.test.tsx @@ -1,28 +1,17 @@ -import { act, render, renderHook, screen } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import { - InputField, - formAtom, - useFieldActions, - useFieldValue, - useFormActions, - useFormSubmit, -} from "form-atoms"; -import { useAtomValue } from "jotai"; -import { describe, expect, it, test, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { formAtom, useFormSubmit } from "form-atoms"; +import { describe, expect, it, vi } from "vitest"; import { listField } from "./listField"; -import { List } from "../../components"; import { useFieldError } from "../../hooks"; import { numberField } from "../number-field"; -import { textField } from "../text-field"; describe("listField()", () => { describe("when required (default)", () => { it("can't be submitted with empty value", async () => { const list = listField({ value: [], - builder: (age) => numberField({ value: age }), + fields: ({ age }) => ({ age: numberField({ value: age }) }), }); const form = formAtom({ list }); @@ -37,7 +26,7 @@ describe("listField()", () => { it("has the default error when submitted empty", async () => { const list = listField({ value: [], - builder: (age) => numberField({ value: age }), + fields: ({ age }) => ({ age: numberField({ value: age }) }), }); const form = formAtom({ list }); @@ -56,7 +45,7 @@ describe("listField()", () => { it("can submit with empty value", async () => { const list = listField({ value: [], - builder: (age) => numberField({ value: age }), + fields: ({ age }) => ({ age: numberField({ value: age }) }), }).optional(); const form = formAtom({ list }); @@ -71,7 +60,7 @@ describe("listField()", () => { it("returns the same field when calling optional", () => { const list = listField({ value: [], - builder: (age) => numberField({ value: age }), + fields: ({ age }) => ({ age: numberField({ value: age }) }), }).optional(); const listRef = list.optional().optional(); @@ -80,147 +69,86 @@ describe("listField()", () => { }); }); - test("can be submitted within formAtom", async () => { - const nums = listField({ - value: [10, 20], - builder: (value) => numberField({ value }), - }); - - const form = formAtom({ nums }); - - const { result: submit } = renderHook(() => useFormSubmit(form)); - - const onSubmit = vi.fn(); - - await act(async () => submit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ nums: [10, 20] }); - }); - - describe("empty atom", () => { - it("is true when values is empty array", () => { - const list = listField({ - value: [], - builder: ({ age }) => ({ age: numberField({ value: age }) }), - }); - - const { result } = renderHook(() => - useAtomValue(useAtomValue(list).empty), - ); - - expect(result.current).toBe(true); - }); - - it("is false when value contain data", () => { - const list = listField({ - value: [{ age: 3 }], - builder: ({ age }) => ({ age: numberField({ value: age }) }), - }); - - const { result } = renderHook(() => - useAtomValue(useAtomValue(list).empty), - ); - - expect(result.current).toBe(false); - }); - }); - - test("useFieldValue() reads list of object value", () => { - const list = listField({ - value: [{ age: 80 }, { age: 70 }], - builder: ({ age }) => ({ age: numberField({ value: age }) }), - }); - - const result = renderHook(() => useFieldValue(list)); - - expect(result.result.current).toEqual([{ age: 80 }, { age: 70 }]); - }); - - test("useFieldValue() reads list of primitive value", () => { - const list = listField({ - value: [10, 20, 30], - builder: (age) => numberField({ value: age }), - }); - - const result = renderHook(() => useFieldValue(list)); - - expect(result.result.current).toEqual([10, 20, 30]); - }); - - describe("resetting value", () => { - test("the formResetAction resets value", async () => { - const ages = listField({ - value: [10], - builder: (age) => numberField({ value: age }), - }); - const form = formAtom({ ages }); - - const { result: formActions } = renderHook(() => useFormActions(form)); - const { result: fieldActions } = renderHook(() => useFieldActions(ages)); - - await act(async () => fieldActions.current.setValue([30])); - const onSubmit = vi.fn(); - await act(async () => formActions.current.submit(onSubmit)()); - expect(onSubmit).toHaveBeenCalledWith({ ages: [30] }); - - await act(async () => formActions.current.reset()); - - const reset_onSubmit = vi.fn(); - await act(async () => formActions.current.submit(reset_onSubmit)()); - expect(reset_onSubmit).toHaveBeenCalledWith({ ages: [10] }); - }); - - test("nested list is reset", async () => { - const users = listField({ - name: "users", - value: [{ name: "Johnson", accounts: [] }], - builder: ({ name, accounts }) => ({ - name: textField({ value: name }), - accounts: listField({ - name: "bank-accounts", - value: accounts, - builder: (iban) => textField({ name: "iban", value: iban }), - }), - }), - }); - - const form = formAtom({ users }); - const { result: formActions } = renderHook(() => useFormActions(form)); - - render( - - {({ fields }) => ( - ( - - )} - > - {({ fields }) => ( - ( - - )} - /> - )} - - )} - , - ); - - expect(screen.getByText("add iban")).toBeInTheDocument(); - expect(screen.queryByTestId("input-iban")).not.toBeInTheDocument(); - - await userEvent.click(screen.getByText("add iban")); - - expect(screen.queryByTestId("input-iban")).toBeInTheDocument(); - - await act(async () => formActions.current.reset()); - - expect(screen.queryByTestId("input-iban")).not.toBeInTheDocument(); - }); - }); + // describe("empty atom", () => { + // it("is true when values is empty array", () => { + // const list = listField({ + // value: [], + // builder: ({ age }) => ({ age: numberField({ value: age }) }), + // }); + + // const { result } = renderHook(() => + // useAtomValue(useAtomValue(list).empty), + // ); + + // expect(result.current).toBe(true); + // }); + + // it("is false when value contain data", () => { + // const list = listField({ + // value: [{ age: 3 }], + // builder: ({ age }) => ({ age: numberField({ value: age }) }), + // }); + + // const { result } = renderHook(() => + // useAtomValue(useAtomValue(list).empty), + // ); + + // expect(result.current).toBe(false); + // }); + // }); + + // describe("resetting value", () => { + // test("nested list is reset", async () => { + // const users = listField({ + // name: "users", + // value: [{ name: "Johnson", accounts: [] }], + // builder: ({ name, accounts }) => ({ + // name: textField({ value: name }), + // accounts: listField({ + // name: "bank-accounts", + // value: accounts, + // builder: (iban) => textField({ name: "iban", value: iban }), + // }), + // }), + // }); + + // const form = formAtom({ users }); + // const { result: formActions } = renderHook(() => useFormActions(form)); + + // render( + // + // {({ fields }) => ( + // ( + // + // )} + // > + // {({ fields }) => ( + // ( + // + // )} + // /> + // )} + // + // )} + // , + // ); + + // expect(screen.getByText("add iban")).toBeInTheDocument(); + // expect(screen.queryByTestId("input-iban")).not.toBeInTheDocument(); + + // await userEvent.click(screen.getByText("add iban")); + + // expect(screen.queryByTestId("input-iban")).toBeInTheDocument(); + + // await act(async () => formActions.current.reset()); + + // expect(screen.queryByTestId("input-iban")).not.toBeInTheDocument(); + // }); + // }); }); diff --git a/src/fields/list-field/listField.ts b/src/fields/list-field/listField.ts index d293369..78eeb15 100644 --- a/src/fields/list-field/listField.ts +++ b/src/fields/list-field/listField.ts @@ -1,67 +1,55 @@ +import { ListAtom, ListAtomConfig, listAtom } from "@form-atoms/list-atom"; +import { FormFieldValues, FormFields } from "form-atoms"; import { Atom } from "jotai"; import { ZodAny, ZodArray, z } from "zod"; -import { extendFieldAtom } from "../../atoms/extendFieldAtom"; -import { - ListAtom, - ListAtomConfig, - ListAtomItems, - ListAtomSubmitValue, - ListAtomValue, - listAtom, -} from "../../atoms/list-atom"; +import { extendAtom } from "../../atoms/extendAtom"; import { ReadRequired, ValidateConfig, WritableRequiredAtom, schemaValidate, } from "../../atoms/schemaValidate"; +import { FormFieldSubmitValues } from "../../components"; import { ZodParams, defaultParams } from "../zod-field"; -export type ExtendListAtom< - Fields extends ListAtomItems, - Value extends ListAtomValue, - State, -> = +export type ExtendListAtom = ListAtom extends Atom ? Atom : never; export type ListField< - Fields extends ListAtomItems, - Value extends ListAtomValue, + Fields extends FormFields, + Value, RequiredAtom = Atom, > = ExtendListAtom & { optional: (readRequired?: ReadRequired) => OptionalListField; }; export type ListFieldSubmitValue< - Fields extends ListAtomItems, + Fields extends FormFields, Required, > = Required extends WritableRequiredAtom - ? ListAtomSubmitValue[] + ? FormFieldSubmitValues[] : Required extends Atom - ? [ListAtomSubmitValue, ...ListAtomSubmitValue[]] + ? [FormFieldSubmitValues, ...FormFieldSubmitValues[]] : never; /** * This is an alias to ListField, it hides the 2nd and 3rd argument from type tooltip. */ -type RequiredListField = ListField< +type RequiredListField = ListField< Fields, - ListAtomValue + FormFieldValues >; -export type OptionalListField = ListField< +export type OptionalListField = ListField< Fields, - ListAtomValue, + FormFieldValues, WritableRequiredAtom >; -export const listField = < - Fields extends ListAtomItems, - Value extends ListAtomValue, ->({ +export const listField = ({ required_error = defaultParams.required_error, schema, optionalSchema, @@ -76,8 +64,8 @@ export const listField = < optionalSchema: optionalSchema ?? z.array(z.any()), }); - const listFieldAtom = extendFieldAtom( - listAtom({ ...config, validate }), + const listFieldAtom = extendAtom( + listAtom({ ...config, validate }) as any, () => ({ required: requiredAtom, }), @@ -86,8 +74,8 @@ export const listField = < listFieldAtom.optional = (readRequired: ReadRequired = () => false) => { const { validate, requiredAtom } = makeOptional(readRequired); - const optionalZodFieldAtom = extendFieldAtom( - listAtom({ ...config, validate }), + const optionalZodFieldAtom = extendAtom( + listAtom({ ...config, validate }) as any, () => ({ required: requiredAtom }), ) as unknown as OptionalListField; diff --git a/src/fields/zod-field/zodField.ts b/src/fields/zod-field/zodField.ts index 050b9c7..2fe9baa 100644 --- a/src/fields/zod-field/zodField.ts +++ b/src/fields/zod-field/zodField.ts @@ -2,13 +2,14 @@ import { FieldAtom, FieldAtomConfig, fieldAtom } from "form-atoms"; import { Atom } from "jotai"; import { ZodAny, ZodUndefined, z } from "zod"; -import { ExtendFieldAtom, extendFieldAtom } from "../../atoms/extendFieldAtom"; +import { extendAtom } from "../../atoms/extendAtom"; import { ReadRequired, ValidateConfig, WritableRequiredAtom, schemaValidate, } from "../../atoms/schemaValidate"; +import { ExtendFieldAtom, PrimitiveFieldAtom } from "../../atoms/types"; export type ZodFieldConfig< Schema extends z.Schema, @@ -68,8 +69,10 @@ export function zodField< optionalSchema, }); - const zodFieldAtom = extendFieldAtom( - fieldAtom({ ...config, validate }), + const zodFieldAtom = extendAtom( + fieldAtom({ ...config, validate }) as unknown as PrimitiveFieldAtom< + z.output + >, () => ({ required: requiredAtom, ...(nameAtom ? { name: nameAtom } : {}), @@ -79,8 +82,10 @@ export function zodField< zodFieldAtom.optional = (readRequired: ReadRequired = () => false) => { const { validate, requiredAtom } = makeOptional(readRequired); - const optionalZodFieldAtom = extendFieldAtom( - fieldAtom({ ...config, validate }), + const optionalZodFieldAtom = extendAtom( + fieldAtom({ ...config, validate }) as unknown as PrimitiveFieldAtom< + z.output | z.output + >, () => ({ required: requiredAtom, ...(nameAtom ? { name: nameAtom } : {}), diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f109a04..fd05cdf 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,9 +5,6 @@ export * from "./use-date-field-props"; export * from "./use-field-error"; export * from "./use-field-props"; export * from "./use-files-field-props"; -export * from "./use-hydrate-field"; -export * from "./use-list-actions"; -export * from "./use-list-field"; export * from "./use-multiselect-field-props"; export * from "./use-number-field-props"; export * from "./use-options"; diff --git a/src/hooks/use-hydrate-field/index.ts b/src/hooks/use-hydrate-field/index.ts deleted file mode 100644 index 65b4ec7..0000000 --- a/src/hooks/use-hydrate-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useHydrateField"; diff --git a/src/hooks/use-hydrate-field/useHydrateField.ts b/src/hooks/use-hydrate-field/useHydrateField.ts deleted file mode 100644 index 6151146..0000000 --- a/src/hooks/use-hydrate-field/useHydrateField.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FieldAtom, RESET, UseAtomOptions } from "form-atoms"; -import { useAtomValue } from "jotai"; -import { useHydrateAtoms } from "jotai/utils"; - -export const useHydrateField = ( - fieldAtom: FieldAtom, - initialValue?: Value | typeof RESET, - options?: UseAtomOptions, -) => { - const field = useAtomValue(fieldAtom); - useHydrateAtoms( - initialValue - ? [ - [field.value, initialValue], - [field._initialValue, initialValue], - ] - : [], - options, - ); -}; diff --git a/src/hooks/use-list-actions/index.ts b/src/hooks/use-list-actions/index.ts deleted file mode 100644 index 400870a..0000000 --- a/src/hooks/use-list-actions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useListActions"; diff --git a/src/hooks/use-list-actions/useListActions.ts b/src/hooks/use-list-actions/useListActions.ts deleted file mode 100644 index 5cbc336..0000000 --- a/src/hooks/use-list-actions/useListActions.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { UseFieldOptions } from "form-atoms"; -import { useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useTransition } from "react"; - -import { - ListAtom, - ListAtomItems, - ListAtomValue, - ListItem, -} from "../../atoms/list-atom"; -import { listItemForm } from "../../atoms/list-atom/listItemForm"; - -export const useListActions = < - Fields extends ListAtomItems, - Value extends ListAtomValue, ->( - list: ListAtom, - options?: UseFieldOptions, -) => { - const atoms = useAtomValue(list); - const validate = useSetAtom(atoms.validate, options); - const dispatchSplitList = useSetAtom(atoms._splitList); - const [, startTransition] = useTransition(); - - const remove = useCallback((item: ListItem) => { - dispatchSplitList({ type: "remove", atom: item }); - startTransition(() => { - validate("change"); - }); - }, []); - - const add = useCallback((before?: ListItem, fields?: Fields) => { - dispatchSplitList({ - type: "insert", - value: fields - ? listItemForm({ - fields, - getListNameAtom: (get) => get(list).name, - formListAtom: atoms._formList, - }) - : atoms.buildItem(), - before, - }); - startTransition(() => { - validate("change"); - }); - }, []); - - const move = useCallback( - (item: ListItem, before?: ListItem) => { - dispatchSplitList({ type: "move", atom: item, before }); - }, - [], - ); - - return { remove, add, move }; -}; diff --git a/src/hooks/use-list-field-initial-value/index.ts b/src/hooks/use-list-field-initial-value/index.ts deleted file mode 100644 index b9e95b3..0000000 --- a/src/hooks/use-list-field-initial-value/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useListFieldInitialValue"; diff --git a/src/hooks/use-list-field-initial-value/useListFieldInitialValue.test.ts b/src/hooks/use-list-field-initial-value/useListFieldInitialValue.test.ts deleted file mode 100644 index e7e2e61..0000000 --- a/src/hooks/use-list-field-initial-value/useListFieldInitialValue.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { useFieldState } from "form-atoms"; -import { describe, expect, it } from "vitest"; - -import { useListFieldInitialValue } from "./useListFieldInitialValue"; -import { listAtom } from "../../atoms"; -import { numberField } from "../../fields"; -import { useListActions } from "../use-list-actions"; - -describe("useListFieldInitialValue()", () => { - it("reinitializes the field value", async () => { - const field = listAtom({ - value: [] as number[], - builder: (value) => numberField({ value }), - }); - - const { result: state } = renderHook(() => useFieldState(field)); - const { rerender } = renderHook( - (props) => useListFieldInitialValue(field, props.initialValue), - { initialProps: { initialValue: [1, 2] } }, - ); - - // make list dirty - const { result: listActions } = renderHook(() => useListActions(field)); - await act(async () => listActions.current.add()); - expect(state.current.dirty).toBe(true); - - const initialValue = [42, 84]; - - // initialization makes field pristine - rerender({ initialValue }); - expect(state.current.dirty).toBe(false); - - // make list dirty again - await act(async () => listActions.current.add()); - expect(state.current.dirty).toBe(true); - - // re-inititialation skipped with the same initialValue (useEffect dependency) - rerender({ initialValue }); - expect(state.current.dirty).toBe(true); - }); -}); diff --git a/src/hooks/use-list-field-initial-value/useListFieldInitialValue.ts b/src/hooks/use-list-field-initial-value/useListFieldInitialValue.ts deleted file mode 100644 index 3730594..0000000 --- a/src/hooks/use-list-field-initial-value/useListFieldInitialValue.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FieldAtom, RESET, UseAtomOptions } from "form-atoms"; - -import { _useFieldInitialValue } from "../../atoms"; -import { useHydrateField } from "../use-hydrate-field"; - -export function useListFieldInitialValue( - fieldAtom: FieldAtom, - initialValue?: Value | typeof RESET, - options?: UseAtomOptions, -): void { - useHydrateField(fieldAtom, initialValue); - _useFieldInitialValue(fieldAtom, initialValue, options); -} diff --git a/src/hooks/use-list-field/Docs.mdx b/src/hooks/use-list-field/Docs.mdx deleted file mode 100644 index e488ff7..0000000 --- a/src/hooks/use-list-field/Docs.mdx +++ /dev/null @@ -1,110 +0,0 @@ -import { Meta, Markdown, Canvas } from "@storybook/blocks"; - - - -
- -

Hooks

- -# `useListField(listField, options): UseListField` - -A hook to manage a [listField()](?path=/docs/fields-listField--docs). It provides list of items ready to be rendered, together with actions to add, remove or reorder the items. - -
- -This hook is used internally in the [ListField](?path=/docs/components-listfield--docs) component which is the preffered way to manage the listFields. - -```tsx -import { listField, useListField } from "@form-atoms/field"; - -const myList = listField({ ... }); - -const MyListField ({initialValue}) => { - const {remove, add, move, items, isEmpty} = useListField(myList, {initialValue}); - - return <>{items.map(({key, fields, remove, moveUp, moveDown})) =>
{/* render item form */}
} -} -``` - -### Features - -- ✅ **Remove, add and move actions** for both list and each item. -- ✅ **Gives each item a unique & stable `key`** for performant list rendering. -- ✅ Computes the `isEmpty` meta data, for easier blank slate implementation. - -### Arguments - -#### `listField: ListField` - -The first argument is your [listField()](?path=/docs/fields-listField--docs) instance. - -#### `options?: UseFieldOptions[]>` - -Use options argument to initialize your field. The `initialValue` must be an array of form values produced by the form fields of your listField builder: - -```ts -// empty list field -const addressesList = listField({ - builder: (address) => ({ city: textField(), street: textField() }), -}); - -const addresses = useListField(myList, { - initialValue: [{ city: "Helsinki", street: "Mannerheimintie" }], -}); -``` - -### Returns - -```ts -import { PrimitiveAtom, FormAtom, FormFields } from "form-atoms"; - -type UseListField = { - /** - * An action to remove the item from the list. - */ - remove(item): void; - /** - * An action to append new item to the list, or prepend specific item when specified. - */ - add(before?): void; - /** - * An action to move an item before another item. - */ - move(item, before?): void; - /** - * Indicates whether the items array is empty. - */ - isEmpty: boolean; - items: ReadonlyArray<{ - /** - * Unique item key defined by its inner form atom. - */ - key: string; - /** - * @internal - * The atom representing the current item. - */ - item: PrimitiveAtom< - FormAtom<{ - fields: Fields; - }> - >; - /** - * The form fields of this item as returned from the listField's builder. - */ - fields: Fields; - /** - * An action to remove this item. - */ - remove(): void; - /** - * An action to move this item up. Noop for the first item. - */ - moveUp(): void; - /** - * An action to move this item down. Makes item first for the last item. - */ - moveDown(): void; - }>; -}; -``` diff --git a/src/hooks/use-list-field/index.ts b/src/hooks/use-list-field/index.ts deleted file mode 100644 index a8bb3cb..0000000 --- a/src/hooks/use-list-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./useListField"; diff --git a/src/hooks/use-list-field/useListField.test.ts b/src/hooks/use-list-field/useListField.test.ts deleted file mode 100644 index 886f3c8..0000000 --- a/src/hooks/use-list-field/useListField.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { formAtom, useFormSubmit } from "form-atoms"; -import { describe, expect, it, vi } from "vitest"; -import { z } from "zod"; - -import { useListField } from "./useListField"; -import { listField, numberField } from "../../fields"; -import { useFieldError } from "../use-field-error"; - -describe("useListField()", () => { - describe("adding item to the list", () => { - it("appends the new item to the end of the list", async () => { - const fields = { - luckyNumbers: listField({ - name: "luckyNumbers", - value: [6], - builder: (value = 9) => numberField({ value }), - }), - }; - - const form = formAtom(fields); - - const { result: list } = renderHook(() => - useListField(fields.luckyNumbers), - ); - - const { result: formSubmit } = renderHook(() => useFormSubmit(form)); - - await act(() => list.current.add()); - await act(() => list.current.add()); - - const onSubmit = vi.fn(); - await act(() => formSubmit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ luckyNumbers: [6, 9, 9] }); - }); - - it("adds the item before a field when specified", async () => { - const fields = { - luckyNumbers: listField({ - name: "luckyNumbers", - value: [6], - builder: (value = 9) => numberField({ value }), - }), - }; - const form = formAtom(fields); - - const { result: list } = renderHook(() => - useListField(fields.luckyNumbers), - ); - - const { result: formSubmit } = renderHook(() => useFormSubmit(form)); - - await act(() => list.current.add(list.current.items[0]?.item)); - - const onSubmit = vi.fn(); - await act(() => formSubmit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ luckyNumbers: [9, 6] }); - }); - - it("clears the 'field is required' error (when previously empty & required list submitted)", async () => { - const list = listField({ - value: [], - builder: (value) => numberField({ value }), - }); - - const form = formAtom({ list }); - const { result: submit } = renderHook(() => useFormSubmit(form)); - - await act(async () => submit.current(vi.fn())()); - - const { result } = renderHook(() => useFieldError(list)); - const { result: field } = renderHook(() => useListField(list)); - - expect(result.current.isInvalid).toBe(true); - - await act(async () => field.current.add()); - - expect(result.current.isInvalid).toBe(false); - }); - }); - - describe("removing item from the list", () => { - it("form becomes empty & submits empty array when the last item is removed", async () => { - const fields = { - luckyNumbers: listField({ - name: "luckyNumbers", - value: [6], - builder: (value) => numberField({ value }), - }).optional(), - }; - const form = formAtom(fields); - - const { result: list } = renderHook(() => - useListField(fields.luckyNumbers), - ); - - const { result: formSubmit } = renderHook(() => useFormSubmit(form)); - - await act(async () => list.current.items[0]?.remove()); - - const onSubmit = vi.fn(); - await act(async () => formSubmit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ luckyNumbers: [] }); - expect(list.current.isEmpty).toBe(true); - }); - - it("clears the validation error, when over-the-bounds item is removed", async () => { - const list = listField({ - value: [1, 2, 3, 4], - schema: z.array(z.any()).nonempty().max(3), - builder: (value) => numberField({ value }), - }); - - const form = formAtom({ list }); - const { result: submit } = renderHook(() => useFormSubmit(form)); - - await act(async () => submit.current(vi.fn())()); - - const { result } = renderHook(() => useFieldError(list)); - const { result: field } = renderHook(() => useListField(list)); - - expect(result.current.isInvalid).toBe(true); - - await act(async () => field.current.remove(field.current.items[0]!.item)); - - expect(result.current.isInvalid).toBe(false); - }); - }); - - describe("moving items", () => { - it("moves item before previous when moveUp is called", async () => { - const fields = { - luckyNumbers: listField({ - name: "luckyNumbers", - value: [6, 9], - builder: (value) => numberField({ value }), - }), - }; - const form = formAtom(fields); - - const { result: list } = renderHook(() => - useListField(fields.luckyNumbers), - ); - - const { result: formSubmit } = renderHook(() => useFormSubmit(form)); - - await act(() => list.current.items[1]?.moveUp()); - - const onSubmit = vi.fn(); - await act(() => formSubmit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ luckyNumbers: [9, 6] }); - }); - - it("moves item after previous when moveDown is called", async () => { - const fields = { - luckyNumbers: listField({ - name: "luckyNumbers", - value: [6, 9], - builder: (value) => numberField({ value }), - }), - }; - const form = formAtom(fields); - - const { result: list } = renderHook(() => - useListField(fields.luckyNumbers), - ); - - const { result: formSubmit } = renderHook(() => useFormSubmit(form)); - - await act(() => list.current.items[0]?.moveDown()); - - const onSubmit = vi.fn(); - await act(() => formSubmit.current(onSubmit)()); - - expect(onSubmit).toHaveBeenCalledWith({ luckyNumbers: [9, 6] }); - }); - }); -}); diff --git a/src/hooks/use-list-field/useListField.ts b/src/hooks/use-list-field/useListField.ts deleted file mode 100644 index b13cbdd..0000000 --- a/src/hooks/use-list-field/useListField.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { UseFieldOptions } from "form-atoms"; -import { useAtomValue } from "jotai"; - -import { ListAtom, ListAtomItems, ListAtomValue } from "../../atoms/list-atom"; -import { ListField } from "../../fields"; -import { useListActions } from "../use-list-actions"; -import { useListFieldInitialValue } from "../use-list-field-initial-value"; - -export const useListField = < - Fields extends ListAtomItems, - Value extends ListAtomValue, ->( - list: ListField | ListAtom, - options?: UseFieldOptions, -) => { - useListFieldInitialValue(list, options?.initialValue, options); - const atoms = useAtomValue(list); - const splitItems = useAtomValue(atoms._splitList); - const formList = useAtomValue(atoms._formList); - const formFields = useAtomValue(atoms._formFields); - const isEmpty = useAtomValue(atoms.empty); - const { add, move, remove } = useListActions(list); - - const items = splitItems.map((item, index) => ({ - item, - key: `${formList[index]}`, - fields: formFields[index]!, - remove: () => remove(item), - moveUp: () => move(item, splitItems[index - 1]), - moveDown: () => move(item, splitItems[index + 2]), - })); - - return { remove, add, move, isEmpty, items }; -}; diff --git a/src/scenarios/PicoFieldErrors.tsx b/src/scenarios/PicoFieldErrors.tsx index 9a8be21..b8ac271 100644 --- a/src/scenarios/PicoFieldErrors.tsx +++ b/src/scenarios/PicoFieldErrors.tsx @@ -1,6 +1,6 @@ import { FieldErrors, FieldErrorsProps } from "../components"; -const style = { color: "var(--del-color)" }; +const style = { color: "var(--pico-color-red-550)" }; export const PicoFieldErrors = (props: Omit) => ( diff --git a/src/scenarios/PicoFieldName.tsx b/src/scenarios/PicoFieldName.tsx deleted file mode 100644 index 9f80bb3..0000000 --- a/src/scenarios/PicoFieldName.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { FieldAtom } from "form-atoms"; -import { useAtomValue } from "jotai"; - -const useFieldName = (fieldAtom: FieldAtom) => - useAtomValue(useAtomValue(fieldAtom).name); - -export const PicoFieldName = ({ field }: { field: FieldAtom }) => { - const name = useFieldName(field); - - return ( - - My name is {name} - - ); -}; diff --git a/yarn.lock b/yarn.lock index 65dbe4e..aa55086 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2707,6 +2707,7 @@ __metadata: resolution: "@form-atoms/field@workspace:." dependencies: "@emotion/react": "npm:^11.11.3" + "@form-atoms/list-atom": "npm:^1.0.5" "@picocss/pico": "npm:^2.0.6" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/commit-analyzer": "npm:^11.1.0" @@ -2763,6 +2764,7 @@ __metadata: vitest: "npm:^1.3.1" zod: "npm:3.22.4" peerDependencies: + "@form-atoms/list-atom": ^1 form-atoms: ^3 jotai: ^2 jotai-effect: ^0 @@ -2771,6 +2773,20 @@ __metadata: languageName: unknown linkType: soft +"@form-atoms/list-atom@npm:^1.0.5": + version: 1.0.5 + resolution: "@form-atoms/list-atom@npm:1.0.5" + dependencies: + react-render-prop-type: "npm:0.1.0" + peerDependencies: + form-atoms: ^3 + jotai: ^2 + jotai-effect: ^0 + react: ">=16.8" + checksum: d9c669b80a7809199b8fd167fd280708585383f977d6f174c37206deabed339c6ed860abfdf2443db0f5aef79fe94c7cc13c031b423b8e1a0679560f363949c6 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3"