diff --git a/src/atoms/extendFieldAtom.ts b/src/atoms/extendFieldAtom.ts index 46c6165..a4031a1 100644 --- a/src/atoms/extendFieldAtom.ts +++ b/src/atoms/extendFieldAtom.ts @@ -1,5 +1,5 @@ import { FieldAtom } from "form-atoms"; -import { Atom, atom } from "jotai"; +import { Atom, Getter, atom } from "jotai"; export type ExtendFieldAtom = FieldAtom extends Atom @@ -7,16 +7,28 @@ export type ExtendFieldAtom = : never; export const extendFieldAtom = < - T extends FieldAtom, + T extends Atom, E extends Record, >( field: T, - makeAtoms: (cfg: T extends Atom ? Config : never) => E, + makeAtoms: ( + cfg: T extends Atom ? Config : never, + get: Getter, + ) => E, ) => - atom((get) => { - const base = get(field); - return { - ...base, - ...makeAtoms(base as T extends Atom ? Config : never), - }; - }); + atom( + (get) => { + const base = get(field); + return { + ...base, + ...makeAtoms( + base as T extends Atom ? Config : never, + get, + ), + }; + }, + (get, set, update: T extends Atom ? Config : never) => { + // @ts-expect-error fieldAtom is PrimitiveAtom + set(field, { ...get(field), ...update }); + }, + ); diff --git a/src/atoms/list-atom/listAtom.test.ts b/src/atoms/list-atom/listAtom.test.ts index 0ca0cf7..017bda4 100644 --- a/src/atoms/list-atom/listAtom.test.ts +++ b/src/atoms/list-atom/listAtom.test.ts @@ -1,5 +1,6 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { + FieldAtom, formAtom, useFieldActions, useFieldErrors, @@ -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 () => { @@ -370,4 +371,148 @@ describe("listAtom()", () => { expect(state.current.dirty).toBe(false); }); }); + + describe("scoped name of list fields", () => { + const useFieldName = (fieldAtom: FieldAtom) => + useAtomValue(useAtomValue(fieldAtom).name); + + describe("list of primitive fieldAtoms", () => { + it("field name contains list name and index", async () => { + const field = listAtom({ + name: "recipients", + value: ["foo@bar.com", "fizz@buzz.com"], + 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]"]); + }); + + it("updates the index for current value, when moved in the list", async () => { + const field = listAtom({ + name: "recipients", + value: ["foo@bar.com", "fizz@buzz.com"], + builder: (value) => textField({ value }), + }); + + const { result: list } = renderHook(() => useListField(field)); + const { result: values } = renderHook(() => [ + useFieldValue(list.current.items[0]!.fields), + useFieldName(list.current.items[0]!.fields), + useFieldValue(list.current.items[1]!.fields), + useFieldName(list.current.items[1]!.fields), + ]); + const { result: listItems } = renderHook(() => + useAtomValue(useAtomValue(field)._splitList), + ); + + await waitFor(() => Promise.resolve()); + + expect(values.current).toEqual([ + "foo@bar.com", + "recipients[0]", + "fizz@buzz.com", + "recipients[1]", + ]); + + const { result: listActions } = renderHook(() => useListActions(field)); + + // moves first item down + await act(async () => listActions.current.move(listItems.current[0]!)); + + expect(values.current).toEqual([ + "fizz@buzz.com", + "recipients[0]", + "foo@bar.com", + "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: "foo@bar.com" }, { email: "fizz@buzz.com" }], + 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: "foo@bar.com", + addresses: [{ type: "home", city: "Kezmarok" }], + }, + { + email: "fizz@buzz.com", + 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", + ]); + }); + }); + }); }); diff --git a/src/atoms/list-atom/listAtom.ts b/src/atoms/list-atom/listAtom.ts index bd4e4f8..b0f2ee2 100644 --- a/src/atoms/list-atom/listAtom.ts +++ b/src/atoms/list-atom/listAtom.ts @@ -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 = FormAtom<{ - fields: Fields; -}>; - export type ListItem = PrimitiveAtom< ListItemForm >; @@ -49,37 +44,20 @@ 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; - }>; + buildItem(): ListItemForm; _formFields: Atom; - - _formList: PrimitiveAtom< - FormAtom<{ - fields: Fields; - }>[] + _formList: WritableAtom< + ListItemForm[], + [typeof RESET | SetStateAction[]>], + void >; + /** * A splitAtom() managing adding, removing and moving items in the list. */ _splitList: WritableAtom< - PrimitiveAtom< - FormAtom<{ - fields: Fields; - }> - >[], - [ - SplitAtomAction< - FormAtom<{ - fields: Fields; - }> - >, - ], + PrimitiveAtom>[], + [SplitAtomAction>], void >; } @@ -114,14 +92,24 @@ export function listAtom< const formBuilder = listBuilder(config.builder); function buildItem(): ListItemForm { - 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[]>(formList); - const _formListAtom = atomWithReset(formList); + const makeFormList = (): ListItemForm[] => + 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); /** @@ -228,6 +216,7 @@ export function listAtom< ) => { if (value === RESET) { set(_formListAtom, value); + set(initialFormListAtom, value); const forms = get(_formListAtom); @@ -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); @@ -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 { diff --git a/src/atoms/list-atom/listItemForm.ts b/src/atoms/list-atom/listItemForm.ts new file mode 100644 index 0000000..1ff0320 --- /dev/null +++ b/src/atoms/list-atom/listItemForm.ts @@ -0,0 +1,201 @@ +import { + FormAtom, + FormFieldErrors, + FormFieldValues, + FormFields, + RESET, + SubmitStatus, + TouchedFields, + ValidateOn, + ValidateStatus, + formAtom, + walkFields, +} from "form-atoms"; +import { + Atom, + Getter, + SetStateAction, + Setter, + WritableAtom, + atom, +} from "jotai"; +import { atomEffect } from "jotai-effect"; + +import { ListAtomItems } from "./listBuilder"; +import { extendFieldAtom } from "../extendFieldAtom"; + +export type ExtendFormAtom = + FormAtom extends Atom + ? Atom + : never; + +// TODO(types): ExtendFormAtom does not work +// The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. +type NamedFormAtom = Atom<{ + nameAtom: Atom; + + /** + * An atom containing an object of nested field atoms + */ + fields: WritableAtom< + Fields, + [Fields | typeof RESET | ((prev: Fields) => Fields)], + void + >; + /** + * An read-only atom that derives the form's values from + * its nested field atoms. + */ + values: Atom>; + /** + * An read-only atom that derives the form's errors from + * its nested field atoms. + */ + errors: Atom>; + /** + * A read-only atom that returns `true` if any of the fields in + * the form are dirty. + */ + dirty: Atom; + /** + * A read-only atom derives the touched state of its nested field atoms. + */ + touchedFields: Atom>; + /** + * A write-only atom that resets the form's nested field atoms + */ + reset: WritableAtom; + /** + * A write-only atom that validates the form's nested field atoms + */ + validate: WritableAtom; + /** + * A read-only atom that derives the form's validation status + */ + validateStatus: Atom; + /** + * A write-only atom for submitting the form + */ + submit: WritableAtom< + null, + [(value: FormFieldValues) => void | Promise], + void + >; + /** + * A read-only atom that reads the number of times the form has + * been submitted + */ + submitCount: Atom; + /** + * An atom that contains the form's submission status + */ + submitStatus: WritableAtom; + _validateFields: ( + get: Getter, + set: Setter, + event: ValidateOn, + ) => Promise; +}>; + +export type ListItemForm = NamedFormAtom<{ + fields: Fields; +}>; + +// export type ListItemForm = ExtendFormAtom< +// { +// fields: Fields; +// }, +// { +// nameAtom: Atom; +// } +// >; + +export function listItemForm({ + fields, + formListAtom, + getListNameAtom, +}: { + /** + * The fields of the item form. + */ + fields: Fields; + /** + * The atom where this list item will be stored. + */ + formListAtom: WritableAtom< + ListItemForm[], + [typeof RESET | SetStateAction[]>], + void + >; + /** + * The nameAtom of the parent listAtom. + */ + getListNameAtom: ( + get: Getter, + ) => + | Atom + | WritableAtom< + string | undefined, + [string | undefined | typeof RESET], + void + >; +}) { + const itemFormAtom: ListItemForm = extendFieldAtom( + formAtom({ fields }), + (base, get) => { + console.log("building itemFormAtom"); + const nameAtom = atom((get) => { + const list: ListItemForm[] = get(formListAtom); + const listName = get(getListNameAtom(get)); + + return `${listName ?? ""}[${list.indexOf(itemFormAtom)}]`; + }); + + const patchNamesEffect = atomEffect((get, set) => { + const fields = get(base.fields); + + console.log("runs effect"); + + walkFields(fields, (field) => { + const { name: originalFieldNameAtom } = get(field); + + const scopedNameAtom = atom( + (get) => { + return [get(nameAtom), get(originalFieldNameAtom)] + .filter(Boolean) + .join("."); + }, + (_, set, update: string) => { + set(originalFieldNameAtom, update); + }, + ); + + // @ts-expect-error field is PrimitiveAtom + set(field, { name: scopedNameAtom, originalFieldNameAtom }); + }); + + return () => { + walkFields(fields, (field) => { + // @ts-expect-error oh yes + const { originalFieldNameAtom } = get(field); + + // @ts-expect-error field is PrimitiveAtom + set(field, { + // drop the scopedNameAtom, as to not make it original on next mount + name: originalFieldNameAtom, + originalFieldNameAtom: undefined, + }); + }); + }; + }); + + get(patchNamesEffect); // subscribe + + return { + name: nameAtom, + }; + }, + ); + + return itemFormAtom; +} diff --git a/src/components/list/Docs.mdx b/src/components/list/Docs.mdx index 42f0f0e..24f91bd 100644 --- a/src/components/list/Docs.mdx +++ b/src/components/list/Docs.mdx @@ -19,6 +19,7 @@ import { List } from "@form-atoms/field"; - ✅ Handles **adding, removal and ordering** of the list items with custom render props. - ✅ **Blank Slate ready**. Can render a custom `` component via render prop when the list is empty. - ✅ **Reorder items** with pre-configured **`moveUp` & `moveDown` actions.** +- ✅ Scoped field `name` attribute with dynamic index position. - ✅ Supports **nested lists in lists**. ## Props diff --git a/src/components/list/List.stories.tsx b/src/components/list/List.stories.tsx index a9bbefb..9b9eb91 100644 --- a/src/components/list/List.stories.tsx +++ b/src/components/list/List.stories.tsx @@ -9,6 +9,7 @@ import { listField, textField, } from "../../fields"; +import { PicoFieldName } from "../../scenarios/PicoFieldName"; import { StoryForm } from "../../scenarios/StoryForm"; import { FieldLabel } from "../field-label"; @@ -67,6 +68,7 @@ export const ListOfObjects = listStory({ }, args: { field: listField({ + name: "environment", value: [ { variable: "GITHUB_TOKEN", value: "ff52d09a" }, { variable: "NPM_TOKEN", value: "deepsecret" }, @@ -84,15 +86,25 @@ export const ListOfObjects = listStory({ gridTemplateColumns: "auto auto min-content", }} > - } - /> - } - /> - +
+ } + /> + +
+
+ ( + + )} + /> + +
+
+ +
), }, @@ -109,6 +121,7 @@ export const ListOfPrimitiveValues = listStory({ }, args: { field: listField({ + name: "productReview", value: ["quality materials used", "not so heavy"], builder: (value) => textField({ value }), }), @@ -130,6 +143,7 @@ export const ListOfPrimitiveValues = listStory({ type ListFields = T extends TListField ? Fields : never; const productPros = listField({ + name: "productReview", value: ["quality materials used", "not so heavy"], builder: (value) => textField({ value }), }); @@ -298,12 +312,14 @@ export const NestedList = listStory({ }, ], builder: ({ name, lastName, accounts = [] }) => ({ - name: textField({ value: name }), - lastName: textField({ value: lastName }), + name: textField({ value: name, name: "name" }), + lastName: textField({ value: lastName, name: "lastName" }), accounts: listField({ name: "accounts", value: accounts, - builder: ({ iban }) => ({ iban: textField({ value: iban }) }), + builder: ({ iban }) => ({ + iban: textField({ value: iban, name: "iban" }), + }), }), }), }), diff --git a/src/fields/list-field/listField.ts b/src/fields/list-field/listField.ts index 8f4f46e..d293369 100644 --- a/src/fields/list-field/listField.ts +++ b/src/fields/list-field/listField.ts @@ -89,7 +89,7 @@ export const listField = < const optionalZodFieldAtom = extendFieldAtom( listAtom({ ...config, validate }), () => ({ required: requiredAtom }), - ) as OptionalListField; + ) as unknown as OptionalListField; optionalZodFieldAtom.optional = () => optionalZodFieldAtom; diff --git a/src/fields/zod-field/zodField.ts b/src/fields/zod-field/zodField.ts index 30d9454..050b9c7 100644 --- a/src/fields/zod-field/zodField.ts +++ b/src/fields/zod-field/zodField.ts @@ -14,7 +14,7 @@ export type ZodFieldConfig< Schema extends z.Schema, OptSchema extends z.Schema = ZodUndefined, > = FieldAtomConfig & - ValidateConfig; + ValidateConfig & { nameAtom?: Atom }; export type ZodFieldValue = Field extends FieldAtom ? Value : never; @@ -57,7 +57,12 @@ export type ZodField< export function zodField< Schema extends z.Schema, OptSchema extends z.Schema = ZodUndefined, ->({ schema, optionalSchema, ...config }: ZodFieldConfig) { +>({ + schema, + optionalSchema, + nameAtom, + ...config +}: ZodFieldConfig) { const { validate, requiredAtom, makeOptional } = schemaValidate({ schema, optionalSchema, @@ -67,6 +72,7 @@ export function zodField< fieldAtom({ ...config, validate }), () => ({ required: requiredAtom, + ...(nameAtom ? { name: nameAtom } : {}), }), ) as unknown as RequiredZodField; @@ -75,8 +81,11 @@ export function zodField< const optionalZodFieldAtom = extendFieldAtom( fieldAtom({ ...config, validate }), - () => ({ required: requiredAtom }), - ) as OptionalZodField; + () => ({ + required: requiredAtom, + ...(nameAtom ? { name: nameAtom } : {}), + }), + ) as unknown as OptionalZodField; optionalZodFieldAtom.optional = () => optionalZodFieldAtom; diff --git a/src/hooks/use-list-actions/useListActions.ts b/src/hooks/use-list-actions/useListActions.ts index 5a67e95..5cbc336 100644 --- a/src/hooks/use-list-actions/useListActions.ts +++ b/src/hooks/use-list-actions/useListActions.ts @@ -1,4 +1,4 @@ -import { UseFieldOptions, formAtom } from "form-atoms"; +import { UseFieldOptions } from "form-atoms"; import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useTransition } from "react"; @@ -8,6 +8,7 @@ import { ListAtomValue, ListItem, } from "../../atoms/list-atom"; +import { listItemForm } from "../../atoms/list-atom/listItemForm"; export const useListActions = < Fields extends ListAtomItems, @@ -31,7 +32,13 @@ export const useListActions = < const add = useCallback((before?: ListItem, fields?: Fields) => { dispatchSplitList({ type: "insert", - value: fields ? formAtom({ fields }) : atoms.buildItem(), + value: fields + ? listItemForm({ + fields, + getListNameAtom: (get) => get(list).name, + formListAtom: atoms._formList, + }) + : atoms.buildItem(), before, }); startTransition(() => { diff --git a/src/scenarios/PicoFieldName.tsx b/src/scenarios/PicoFieldName.tsx new file mode 100644 index 0000000..9f80bb3 --- /dev/null +++ b/src/scenarios/PicoFieldName.tsx @@ -0,0 +1,15 @@ +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/src/scenarios/dependent-validation/PasswordValidation.stories.tsx b/src/scenarios/dependent-validation/PasswordValidation.stories.tsx index a64cc9a..5397085 100644 --- a/src/scenarios/dependent-validation/PasswordValidation.stories.tsx +++ b/src/scenarios/dependent-validation/PasswordValidation.stories.tsx @@ -22,8 +22,6 @@ const confirmPassword = textField({ (get) => { const initialPassword = get(get(password).value); - console.log("hm"); - return z.literal(initialPassword); }, {