diff --git a/src/FormStateApp.tsx b/src/FormStateApp.tsx index 17970b4..3e7c96d 100644 --- a/src/FormStateApp.tsx +++ b/src/FormStateApp.tsx @@ -7,11 +7,13 @@ export function FormStateApp() { config: formConfig, // Simulate getting the initial form state back from a server call init: { - firstName: "a1", - books: [...Array(2)].map((_, i) => ({ - title: `b${i}`, - classification: { number: `10${i + 1}`, category: `Test Category ${i}` }, - })), + input: { + firstName: "a1", + books: [...Array(2)].map((_, i) => ({ + title: `b${i}`, + classification: { number: `10${i + 1}`, category: `Test Category ${i}` }, + })), + }, }, addRules(state) { state.lastName.rules.push(() => { diff --git a/src/useFormState.test.tsx b/src/useFormState.test.tsx index 629e76b..1ef8820 100644 --- a/src/useFormState.test.tsx +++ b/src/useFormState.test.tsx @@ -106,39 +106,6 @@ describe("useFormState", () => { expect(r.changedValue.textContent).toEqual(JSON.stringify({ firstName: "default" })); }); - it("uses init if set as a value", async () => { - // Given a component - type FormValue = Pick; - const config: ObjectConfig = { firstName: { type: "value" } }; - function TestComponent() { - const [, setTick] = useState(0); - const form = useFormState({ - config, - // That's using a raw init value - init: { firstName: "bob" }, - }); - return ( -
-
- ); - } - const r = await render(); - expect(r.firstName).toHaveTextContent("bob"); - click(r.change); - // Then the change didn't get dropped due to init being unstable - expect(r.firstName).toHaveTextContent("fred"); - }); - it("doesn't required an init value", async () => { function TestComponent() { type FormValue = Pick; @@ -191,7 +158,7 @@ describe("useFormState", () => { }; // And we start out with data1 const [data, setData] = useState(data1); - const form = useFormState({ config, init: { input: data, map: (d) => d } }); + const form = useFormState({ config, init: { input: data } }); function makeLocalChanges() { form.firstName.value = "local"; form.address.street.value = "local"; @@ -284,7 +251,7 @@ describe("useFormState", () => { const [data, setData] = useState(data1); const form = useFormState({ config, - init: { input: data, map: (d) => d }, + init: { input: data }, // And the form is read only readOnly: true, }); @@ -412,7 +379,7 @@ describe("useFormState", () => { const data2 = { books: [{ title: "Title 1" }] }; const form = useFormState({ config: authorWithBooksConfig, - init: { input: data, map: (d) => d, ifUndefined: { books: [] } }, + init: { input: data, ifUndefined: { books: [] } }, autoSave, }); return ( @@ -458,7 +425,7 @@ describe("useFormState", () => { const data = { firstName: "f1", lastName: "f1" }; const form = useFormState({ config, - init: data, + init: { input: data }, // And there is reactive business logic in the `autoSave` method async autoSave(state) { state.lastName.set("l2"); @@ -487,7 +454,7 @@ describe("useFormState", () => { const data = { firstName: "f1", lastName: "f1" }; const form = useFormState({ config, - init: data, + init: { input: data }, autoSave: (form) => autoSave(form.changedValue), }); return ( @@ -602,7 +569,7 @@ describe("useFormState", () => { ); }, autoSave: (fs) => autoSaveStub(fs.changedValue), - init: { id: "a:1" }, + init: { input: { id: "a:1" } }, }); return ; } @@ -638,7 +605,7 @@ describe("useFormState", () => { type FormValue = Pick; const config: ObjectConfig = { firstName: { type: "value" } }; function TestComponent({ data }: { data: AuthorInput | undefined }) { - const form = useFormState({ config, init: { input: data, map: (d) => d } }); + const form = useFormState({ config, init: { input: data } }); return {() =>
{String(form.loading)}
}
; } // And we initially pass in `init.input: undefined` @@ -722,7 +689,7 @@ describe("useFormState", () => { const config: ObjectConfig = authorConfig; const form = useFormState({ config, - init: { firstName: "f1", lastName: "f1" }, + init: { input: { firstName: "f1", lastName: "f1" } }, autoSave: async (form) => { autoSave(form.changedValue); // And the autoSave functions erroneously calls commitChanges diff --git a/src/useFormState.ts b/src/useFormState.ts index 9d092d8..221f962 100644 --- a/src/useFormState.ts +++ b/src/useFormState.ts @@ -9,7 +9,7 @@ export type Query = { data: I; loading: boolean; error?: any }; export type InputAndMap = { input: I; - map: (input: Exclude) => T; + map?: (input: Exclude) => T; ifUndefined?: T; }; @@ -19,6 +19,12 @@ export type QueryAndMap = { ifUndefined?: T; }; +/** + * The opts has for `useFormState`. + * + * @typeparam T the form type, which is usually as close as possible to your *GraphQL input* + * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query + */ export type UseFormStateOpts = { /** The form configuration, should be a module-level const or useMemo'd. */ config: ObjectConfig; @@ -40,7 +46,7 @@ export type UseFormStateOpts = { * only call `init.map` if it's set, otherwise we'll use `init.ifDefined` or `{}`, saving you * from having to null check within your `init.map` function. */ - init?: T | InputAndMap | QueryAndMap; + init?: InputAndMap | QueryAndMap; /** * A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL. diff --git a/src/useFormStates.ts b/src/useFormStates.ts index 6903706..f163479 100644 --- a/src/useFormStates.ts +++ b/src/useFormStates.ts @@ -5,6 +5,12 @@ import { initValue } from "src/utils"; export type ObjectStateCache = Record, I]>; +/** + * The opts has for `useFormStates`. + * + * @typeparam T the form type, which is usually as close as possible to your *GraphQL input* + * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query + */ type UseFormStatesOpts = { /** * The config to use for each form state. @@ -54,6 +60,19 @@ type UseFormStatesHook = { getFormState: (input: I, opts?: { readOnly?: boolean }) => ObjectState; }; +/** + * A hook to manage many "mini-forms" on a single page, typically one form per row + * in a table. + * + * This hook basically provides the page/table with a cache, so each table row naively ask "what's + * the form state for this given row's data?" and get back a new-or-existing `ObjectState` instance + * that, if already existing, still has any of the user's WIP changes. + * + * Each mini-form/row can have its own autoSave calls, independent of the other rows. + * + * @typeparam T the form type, which is usually as close as possible to your *GraphQL input* + * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query + */ export function useFormStates(opts: UseFormStatesOpts): UseFormStatesHook { const { config, autoSave, getId, map, addRules, readOnly = false } = opts; @@ -109,7 +128,7 @@ export function useFormStates(opts: UseFormStatesOpts): UseFormS // If it didn't exist, then add to the cache. if (!form) { - form = createObjectState(config, initValue(config, map ? { map, input } : input), { + form = createObjectState(config, initValue(config, { map, input }), { maybeAutoSave: () => maybeAutoSave(form), }); if (addRules) { @@ -120,7 +139,7 @@ export function useFormStates(opts: UseFormStatesOpts): UseFormS // If the source of truth changed, then update the existing state and return it. if (existing && existing[1] !== input) { - (form as any as ObjectStateInternal).set(initValue(config, map ? { map, input } : input), { + (form as any as ObjectStateInternal).set(initValue(config, { map, input }), { refreshing: true, }); existing[1] = input; diff --git a/src/utils.ts b/src/utils.ts index 4a2118b..08176de 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -35,21 +35,24 @@ export function assertNever(x: never): never { throw new Error("Unexpected object: " + x); } -/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */ +/** Introspects the `init` prop to see if it has a `map` function/etc. and returns the form value. */ export function initValue(config: ObjectConfig, init: any): T { let value: any; if (isInput(init)) { - value = init.input ? init.map(init.input) : init.ifUndefined; + value = init.input ? (init.map ? init.map(init.input) : init.input) : init.ifUndefined; } else if (isQuery(init)) { value = init.query.data ? init.map(init.query.data) : init.ifUndefined; + } else if (init === undefined) { + // allow completely undefined init } else { - value = init; + throw new Error("init must have an input or query key"); } + // Given our form config, pick out only the subset of fields out of `value` (unless it's a mobx class) return pickFields(config, value ?? {}) as T; } export function isInput(init: UseFormStateOpts["init"]): init is InputAndMap { - return !!init && typeof init === "object" && "input" in init && "map" in init; + return !!init && typeof init === "object" && "input" in init; } export function isQuery(init: UseFormStateOpts["init"]): init is QueryAndMap {