Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(listAtom): validate controls listErrors & calls nested form valid… #75

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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
},
);
Expand Down Expand Up @@ -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?.({
Expand All @@ -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");
}
}
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";