diff --git a/src/atoms/index.ts b/src/atoms/index.ts new file mode 100644 index 0000000..fb74521 --- /dev/null +++ b/src/atoms/index.ts @@ -0,0 +1 @@ +export * from "./list-atom"; diff --git a/src/atoms/list-atom/listAtom.test.ts b/src/atoms/list-atom/listAtom.test.ts index 56765e6..aa3c9dd 100644 --- a/src/atoms/list-atom/listAtom.test.ts +++ b/src/atoms/list-atom/listAtom.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react"; import { formAtom, useFieldActions, + useFieldErrors, useFieldValue, useFormActions, useFormSubmit, @@ -12,6 +13,7 @@ import { describe, expect, it, test, vi } from "vitest"; import { listAtom } from "./listAtom"; import { numberField, textField } from "../../fields"; import { useFieldError } from "../../hooks"; +import { useListActions } from "../../hooks/use-list-actions"; describe("listAtom()", () => { test("can be submitted within formAtom", async () => { @@ -126,6 +128,50 @@ describe("listAtom()", () => { }); }); + describe("validation", () => { + it("adding item clear the error", async () => { + const field = listAtom({ + value: [], + builder: (value) => numberField({ value }), + validate: ({ value }) => { + const errors = []; + if (value.length === 0) { + errors.push("Can't be empty"); + } + return errors; + }, + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + + expect(errors.current).toEqual(["Can't be empty"]); + + const { result: listActions } = renderHook(() => useListActions(field)); + + await act(async () => listActions.current.add()); + + expect(errors.current).toEqual([]); + }); + + it("validates the inner form items", async () => { + const field = listAtom({ + value: [undefined], + invalidItemError: "err", + builder: (value) => numberField({ value }), + }); + + const { result: actions } = renderHook(() => useFieldActions(field)); + const { result: errors } = renderHook(() => useFieldErrors(field)); + + await act(async () => actions.current.validate()); + + expect(errors.current).toEqual(["err"]); + }); + }); + describe("nested validation", () => { it("can't be submitted with invalid item's field", async () => { const field = listAtom({ diff --git a/src/atoms/list-atom/listAtom.ts b/src/atoms/list-atom/listAtom.ts index aac3b34..830e593 100644 --- a/src/atoms/list-atom/listAtom.ts +++ b/src/atoms/list-atom/listAtom.ts @@ -18,6 +18,14 @@ import { } from "./listBuilder"; import { ExtendFieldAtom } from "../extendFieldAtom"; +type ListItemForm = FormAtom<{ + fields: Fields; +}>; + +export type ListItem = PrimitiveAtom< + ListItemForm +>; + // copied from jotai/utils type SplitAtomAction = | { @@ -42,10 +50,16 @@ export type ListAtom< Value[], { empty: Atom; + /** + * TODO - review + * Reusing the ListItem and ListItemForm from above will cause an error preventing compilation the library: + * error TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. + */ buildItem(): FormAtom<{ fields: Fields; }>; _formFields: Atom; + _formList: PrimitiveAtom< FormAtom<{ fields: Fields; @@ -150,7 +164,7 @@ export function listAtom< // eslint-disable-next-line @typescript-eslint/no-unused-vars (_get, _set, _value: string[]) => { // intentional NO-OP - // the errors atom must be writable, as the `validateAtoms` will write the errors returned from `_validateCallback` + // the errors atom must be writable, as the `validateAtom` will write the errors returned from `_validateCallback` // but we ignore it, as we already manage the `listErrors` internally }, ); @@ -223,6 +237,13 @@ export function listAtom< set(touchedAtom, true); } + // run validation for nested forms + await Promise.all( + get(_formListAtom).map((formAtom) => + validateFormFields(formAtom as any, get, set, event), + ), + ); + let errors: string[] = []; const maybeValidatePromise = config.validate?.({ @@ -237,13 +258,13 @@ export function listAtom< if (isPromise(maybeValidatePromise)) { ptr === get(validateCountAtom) && set(validateResultAtom, "validating"); - errors = (await maybeValidatePromise) ?? get(errorsAtom); + errors = (await maybeValidatePromise) ?? get(listErrorsAtom); } else { - errors = maybeValidatePromise ?? get(errorsAtom); + errors = maybeValidatePromise ?? get(listErrorsAtom); } if (ptr === get(validateCountAtom)) { - set(errorsAtom, errors); + set(listErrorsAtom, errors); set(validateResultAtom, errors.length > 0 ? "invalid" : "valid"); } } diff --git a/src/components/list/List.tsx b/src/components/list/List.tsx index 49fab31..e776ba5 100644 --- a/src/components/list/List.tsx +++ b/src/components/list/List.tsx @@ -2,9 +2,9 @@ import { FieldAtom, FormFields } from "form-atoms"; import { Fragment, useCallback } from "react"; import { RenderProp } from "react-render-prop-type"; -import { ListAtomItems, ListAtomValue } from "../../atoms/list-atom"; +import { ListAtomItems, ListAtomValue, ListItem } from "../../atoms/list-atom"; import { type ListField } from "../../fields"; -import { ListItem, useListField } from "../../hooks"; +import { useListField } from "../../hooks"; export type RemoveButtonProps = { remove: () => void }; export type RemoveButtonProp = RenderProp; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a5a07b2..1380e1e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,6 +5,7 @@ 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-list-actions"; export * from "./use-list-field"; export * from "./use-multiselect-field-props"; export * from "./use-number-field-props"; diff --git a/src/hooks/use-list-actions/index.ts b/src/hooks/use-list-actions/index.ts new file mode 100644 index 0000000..400870a --- /dev/null +++ b/src/hooks/use-list-actions/index.ts @@ -0,0 +1 @@ +export * from "./useListActions"; diff --git a/src/hooks/use-list-actions/useListActions.ts b/src/hooks/use-list-actions/useListActions.ts new file mode 100644 index 0000000..e08e609 --- /dev/null +++ b/src/hooks/use-list-actions/useListActions.ts @@ -0,0 +1,46 @@ +import { UseFieldOptions } from "form-atoms"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useTransition } from "react"; + +import { + ListAtom, + ListAtomItems, + ListAtomValue, + ListItem, +} from "../../atoms/list-atom"; + +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) => { + dispatchSplitList({ type: "insert", value: 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/useListField.ts b/src/hooks/use-list-field/useListField.ts index 320ae18..b74acc4 100644 --- a/src/hooks/use-list-field/useListField.ts +++ b/src/hooks/use-list-field/useListField.ts @@ -1,15 +1,9 @@ -import { FormAtom, UseFieldOptions, useFieldInitialValue } from "form-atoms"; -import { PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; -import { useCallback, useTransition } from "react"; +import { UseFieldOptions, useFieldInitialValue } from "form-atoms"; +import { useAtomValue } from "jotai"; import { ListAtomItems, ListAtomValue } from "../../atoms/list-atom"; import { ListField } from "../../fields"; - -export type ListItem = PrimitiveAtom< - FormAtom<{ - fields: Fields; - }> ->; +import { useListActions } from "../use-list-actions"; export const useListField = < Fields extends ListAtomItems, @@ -19,35 +13,13 @@ export const useListField = < options?: UseFieldOptions, ) => { const atoms = useAtomValue(list); - const validate = useSetAtom(atoms.validate, options); - const [splitItems, dispatch] = useAtom(atoms._splitList); + const splitItems = useAtomValue(atoms._splitList); const formList = useAtomValue(atoms._formList); const formFields = useAtomValue(atoms._formFields); const isEmpty = useAtomValue(atoms.empty); - const [, startTransition] = useTransition(); + const { add, move, remove } = useListActions(list); useFieldInitialValue(list, options?.initialValue, options); - const remove = useCallback((item: ListItem) => { - dispatch({ type: "remove", atom: item }); - startTransition(() => { - validate("change"); - }); - }, []); - - const add = useCallback((before?: ListItem) => { - dispatch({ type: "insert", value: atoms.buildItem(), before }); - startTransition(() => { - validate("change"); - }); - }, []); - - const move = useCallback( - (item: ListItem, before?: ListItem) => { - dispatch({ type: "move", atom: item, before }); - }, - [], - ); - const items = splitItems.map((item, index) => ({ item, key: `${formList[index]}`, diff --git a/src/index.ts b/src/index.ts index 775918e..b0ca30e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from "./atoms"; export * from "./components"; export * from "./fields"; export * from "./hooks";