Skip to content

Commit

Permalink
fix(listAtom): support scoped names of nested fields (#110)
Browse files Browse the repository at this point in the history
* test(listAtom): scoped names

* listItem as extended formAtom with name

* use extended listItemForm

* fix types so build passes

* add effect for syncing field names

* docs(List): add names
  • Loading branch information
MiroslavPetrik committed Feb 16, 2024
1 parent d780fb2 commit f8201da
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 65 deletions.
32 changes: 22 additions & 10 deletions src/atoms/extendFieldAtom.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,34 @@
import { FieldAtom } from "form-atoms";
import { Atom, atom } from "jotai";
import { Atom, Getter, atom } from "jotai";

export type ExtendFieldAtom<Value, State> =
FieldAtom<Value> extends Atom<infer DefaultState>
? Atom<DefaultState & State>
: never;

export const extendFieldAtom = <
T extends FieldAtom<any>,
T extends Atom<any>,
E extends Record<string, unknown>,
>(
field: T,
makeAtoms: (cfg: T extends Atom<infer Config> ? Config : never) => E,
makeAtoms: (
cfg: T extends Atom<infer Config> ? Config : never,
get: Getter,
) => E,
) =>
atom((get) => {
const base = get(field);
return {
...base,
...makeAtoms(base as T extends Atom<infer Config> ? Config : never),
};
});
atom(
(get) => {
const base = get(field);
return {
...base,
...makeAtoms(
base as T extends Atom<infer Config> ? Config : never,
get,
),
};
},
(get, set, update: T extends Atom<infer Config> ? Config : never) => {
// @ts-expect-error fieldAtom is PrimitiveAtom
set(field, { ...get(field), ...update });
},
);
109 changes: 107 additions & 2 deletions src/atoms/list-atom/listAtom.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { act, renderHook } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import {
FieldAtom,
formAtom,
useFieldActions,
useFieldErrors,
Expand All @@ -13,7 +14,7 @@ import { describe, expect, it, test, vi } from "vitest";

import { listAtom } from "./listAtom";
import { numberField, textField } from "../../fields";
import { useFieldError, useListActions } from "../../hooks";
import { useFieldError, useListActions, useListField } from "../../hooks";

describe("listAtom()", () => {
test("can be submitted within formAtom", async () => {
Expand Down Expand Up @@ -370,4 +371,108 @@ describe("listAtom()", () => {
expect(state.current.dirty).toBe(false);
});
});

describe("scoped name of list fields", () => {
const useFieldName = <T extends FieldAtom<any>>(fieldAtom: T) =>
useAtomValue(useAtomValue(fieldAtom).name);

describe("list of primitive fieldAtoms", () => {
it("field name contains list name and index", async () => {
const field = listAtom({
name: "recipients",
value: ["[email protected]", "[email protected]"],
builder: (value) => textField({ value }),
});

const { result: list } = renderHook(() => useListField(field));
const { result: names } = renderHook(() => [
useFieldName(list.current.items[0]!.fields),
useFieldName(list.current.items[1]!.fields),
]);

await waitFor(() => Promise.resolve());

expect(names.current).toEqual(["recipients[0]", "recipients[1]"]);
});
});

describe("list of form fields", () => {
it("field name contains list name, index and field name", async () => {
const field = listAtom({
name: "contacts",
value: [{ email: "[email protected]" }, { email: "[email protected]" }],
builder: ({ email }) => ({
email: textField({ value: email, name: "email" }),
}),
});

const { result: list } = renderHook(() => useListField(field));
const { result: names } = renderHook(() => [
useFieldName(list.current.items[0]!.fields.email),
useFieldName(list.current.items[1]!.fields.email),
]);

await waitFor(() => Promise.resolve());

expect(names.current).toEqual([
"contacts[0].email",
"contacts[1].email",
]);
});
});

describe("nested listAtom", () => {
// passes but throws error
it.skip("has prefix of the parent listAtom", async () => {
const field = listAtom({
name: "contacts",
value: [
{
email: "[email protected]",
addresses: [{ type: "home", city: "Kezmarok" }],
},
{
email: "[email protected]",
addresses: [
{ type: "home", city: "Humenne" },
{ type: "work", city: "Nove Zamky" },
],
},
],
builder: ({ email, addresses = [] }) => ({
email: textField({ value: email, name: "email" }),
addresses: listAtom({
name: "addresses",
value: addresses,
builder: ({ type, city }) => ({
type: textField({ value: type, name: "type" }),
city: textField({ value: city, name: "city" }),
}),
}),
}),
});

const { result: list } = renderHook(() => useListField(field));
const { result: secondContactAddresses } = renderHook(() =>
useListField(list.current.items[1]!.fields.addresses),
);

const { result: names } = renderHook(() => [
useFieldName(secondContactAddresses.current.items[0]!.fields.type),
useFieldName(secondContactAddresses.current.items[0]!.fields.city),
useFieldName(secondContactAddresses.current.items[1]!.fields.type),
useFieldName(secondContactAddresses.current.items[1]!.fields.city),
]);

await waitFor(() => Promise.resolve());

expect(names.current).toEqual([
"contacts[1].addresses[0].type",
"contacts[1].addresses[0].city",
"contacts[1].addresses[1].type",
"contacts[1].addresses[1].city",
]);
});
});
});
});
77 changes: 36 additions & 41 deletions src/atoms/list-atom/listAtom.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import {
FieldAtomConfig,
FormAtom,
Validate,
ValidateOn,
ValidateStatus,
formAtom,
walkFields,
} from "form-atoms";
import { Atom, PrimitiveAtom, WritableAtom, atom } from "jotai";
import { RESET, atomWithReset, splitAtom } from "jotai/utils";
import { Atom, PrimitiveAtom, SetStateAction, WritableAtom, atom } from "jotai";
import { RESET, atomWithDefault, atomWithReset, splitAtom } from "jotai/utils";

import {
type ListAtomItems,
type ListAtomValue,
listBuilder,
} from "./listBuilder";
import { ListItemForm, listItemForm } from "./listItemForm";
import { ExtendFieldAtom } from "../extendFieldAtom";

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

export type ListItem<Fields extends ListAtomItems> = PrimitiveAtom<
ListItemForm<Fields>
>;
Expand Down Expand Up @@ -49,37 +44,20 @@ 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;
}>;
buildItem(): ListItemForm<Fields>;
_formFields: Atom<Fields[]>;

_formList: PrimitiveAtom<
FormAtom<{
fields: Fields;
}>[]
_formList: WritableAtom<
ListItemForm<Fields>[],
[typeof RESET | SetStateAction<ListItemForm<Fields>[]>],
void
>;

/**
* A splitAtom() managing adding, removing and moving items in the list.
*/
_splitList: WritableAtom<
PrimitiveAtom<
FormAtom<{
fields: Fields;
}>
>[],
[
SplitAtomAction<
FormAtom<{
fields: Fields;
}>
>,
],
PrimitiveAtom<ListItemForm<Fields>>[],
[SplitAtomAction<ListItemForm<Fields>>],
void
>;
}
Expand Down Expand Up @@ -114,14 +92,24 @@ export function listAtom<
const formBuilder = listBuilder(config.builder);

function buildItem(): ListItemForm<Fields> {
return formAtom({ fields: formBuilder() });
return listItemForm({
fields: formBuilder(),
getListNameAtom: (get) => get(self).name,
formListAtom: _formListAtom,
});
}

const formList = formBuilder(config.value).map((fields) =>
formAtom({ fields }),
);
const initialFormListAtom = atomWithReset<ListItemForm<Fields>[]>(formList);
const _formListAtom = atomWithReset(formList);
const makeFormList = (): ListItemForm<Fields>[] =>
formBuilder(config.value).map((fields) =>
listItemForm({
fields,
getListNameAtom: (get) => get(self).name,
formListAtom: _formListAtom,
}),
);

const initialFormListAtom = atomWithDefault(makeFormList);
const _formListAtom = atomWithDefault((get) => get(initialFormListAtom));
const _splitListAtom = splitAtom(_formListAtom);

/**
Expand Down Expand Up @@ -228,6 +216,7 @@ export function listAtom<
) => {
if (value === RESET) {
set(_formListAtom, value);
set(initialFormListAtom, value);

const forms = get(_formListAtom);

Expand All @@ -237,7 +226,11 @@ export function listAtom<
}
} else if (Array.isArray(value)) {
const updatedFormList = formBuilder(value).map((fields) =>
formAtom({ fields }),
listItemForm({
fields,
getListNameAtom: (get) => get(self).name,
formListAtom: _formListAtom,
}),
);
set(initialFormListAtom, updatedFormList);
set(_formListAtom, updatedFormList);
Expand Down Expand Up @@ -357,8 +350,10 @@ export function listAtom<
_initialValue: initialValueAtom,
};

const self = atom(listAtoms);

// @ts-expect-error ref with HTMLFieldset is ok
return atom(listAtoms);
return self;
}

function isPromise(value: any): value is Promise<any> {
Expand Down
Loading

0 comments on commit f8201da

Please sign in to comment.