Skip to content

Commit

Permalink
fix(listAtom): validate controls listErrors (#72) (#75)
Browse files Browse the repository at this point in the history
  • Loading branch information
MiroslavPetrik authored Jan 14, 2024
1 parent d964880 commit d011313
Show file tree
Hide file tree
Showing 9 changed files with 128 additions and 39 deletions.
1 change: 1 addition & 0 deletions src/atoms/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./list-atom";
46 changes: 46 additions & 0 deletions src/atoms/list-atom/listAtom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react";
import {
formAtom,
useFieldActions,
useFieldErrors,
useFieldValue,
useFormActions,
useFormSubmit,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand Down
29 changes: 25 additions & 4 deletions src/atoms/list-atom/listAtom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ import {
} from "./listBuilder";
import { ExtendFieldAtom } from "../extendFieldAtom";

type ListItemForm<Fields extends ListAtomItems> = FormAtom<{
fields: Fields;
}>;

export type ListItem<Fields extends ListAtomItems> = PrimitiveAtom<
ListItemForm<Fields>
>;

// copied from jotai/utils
type SplitAtomAction<Item> =
| {
Expand All @@ -42,10 +50,16 @@ export type ListAtom<
Value[],
{
empty: Atom<boolean>;
/**
* 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<Fields[]>;

_formList: PrimitiveAtom<
FormAtom<{
fields: Fields;
Expand Down Expand Up @@ -161,7 +175,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
},
);
Expand Down Expand Up @@ -234,6 +248,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?.({
Expand All @@ -248,13 +269,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");
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/list/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemoveButtonProps, "RemoveButton">;
Expand Down
1 change: 1 addition & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-list-actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./useListActions";
46 changes: 46 additions & 0 deletions src/hooks/use-list-actions/useListActions.ts
Original file line number Diff line number Diff line change
@@ -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<Fields>,
>(
list: ListAtom<Fields, Value>,
options?: UseFieldOptions<Value[]>,
) => {
const atoms = useAtomValue(list);
const validate = useSetAtom(atoms.validate, options);
const dispatchSplitList = useSetAtom(atoms._splitList);
const [, startTransition] = useTransition();

const remove = useCallback((item: ListItem<Fields>) => {
dispatchSplitList({ type: "remove", atom: item });
startTransition(() => {
validate("change");
});
}, []);

const add = useCallback((before?: ListItem<Fields>) => {
dispatchSplitList({ type: "insert", value: atoms.buildItem(), before });
startTransition(() => {
validate("change");
});
}, []);

const move = useCallback(
(item: ListItem<Fields>, before?: ListItem<Fields>) => {
dispatchSplitList({ type: "move", atom: item, before });
},
[],
);

return { remove, add, move };
};
38 changes: 5 additions & 33 deletions src/hooks/use-list-field/useListField.ts
Original file line number Diff line number Diff line change
@@ -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<Fields extends ListAtomItems> = PrimitiveAtom<
FormAtom<{
fields: Fields;
}>
>;
import { useListActions } from "../use-list-actions";

export const useListField = <
Fields extends ListAtomItems,
Expand All @@ -19,35 +13,13 @@ export const useListField = <
options?: UseFieldOptions<Value[]>,
) => {
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<Fields>) => {
dispatch({ type: "remove", atom: item });
startTransition(() => {
validate("change");
});
}, []);

const add = useCallback((before?: ListItem<Fields>) => {
dispatch({ type: "insert", value: atoms.buildItem(), before });
startTransition(() => {
validate("change");
});
}, []);

const move = useCallback(
(item: ListItem<Fields>, before?: ListItem<Fields>) => {
dispatch({ type: "move", atom: item, before });
},
[],
);

const items = splitItems.map((item, index) => ({
item,
key: `${formList[index]}`,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./atoms";
export * from "./components";
export * from "./fields";
export * from "./hooks";

0 comments on commit d011313

Please sign in to comment.