@homebound/form-state 2.23.2 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,11 +9,13 @@ function FormStateApp() {
9
9
  config: formConfig,
10
10
  // Simulate getting the initial form state back from a server call
11
11
  init: {
12
- firstName: "a1",
13
- books: [...Array(2)].map((_, i) => ({
14
- title: `b${i}`,
15
- classification: { number: `10${i + 1}`, category: `Test Category ${i}` },
16
- })),
12
+ input: {
13
+ firstName: "a1",
14
+ books: [...Array(2)].map((_, i) => ({
15
+ title: `b${i}`,
16
+ classification: { number: `10${i + 1}`, category: `Test Category ${i}` },
17
+ })),
18
+ },
17
19
  },
18
20
  addRules(state) {
19
21
  state.lastName.rules.push(() => {
@@ -7,7 +7,7 @@ export type Query<I> = {
7
7
  };
8
8
  export type InputAndMap<T, I> = {
9
9
  input: I;
10
- map: (input: Exclude<I, null | undefined>) => T;
10
+ map?: (input: Exclude<I, null | undefined>) => T;
11
11
  ifUndefined?: T;
12
12
  };
13
13
  export type QueryAndMap<T, I> = {
@@ -15,6 +15,12 @@ export type QueryAndMap<T, I> = {
15
15
  map: (input: Exclude<I, null | undefined>) => T;
16
16
  ifUndefined?: T;
17
17
  };
18
+ /**
19
+ * The opts has for `useFormState`.
20
+ *
21
+ * @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
22
+ * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
23
+ */
18
24
  export type UseFormStateOpts<T, I> = {
19
25
  /** The form configuration, should be a module-level const or useMemo'd. */
20
26
  config: ObjectConfig<T>;
@@ -35,7 +41,7 @@ export type UseFormStateOpts<T, I> = {
35
41
  * only call `init.map` if it's set, otherwise we'll use `init.ifDefined` or `{}`, saving you
36
42
  * from having to null check within your `init.map` function.
37
43
  */
38
- init?: T | InputAndMap<T, I> | QueryAndMap<T, I>;
44
+ init?: InputAndMap<T, I> | QueryAndMap<T, I>;
39
45
  /**
40
46
  * A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
41
47
  *
@@ -83,28 +83,6 @@ describe("useFormState", () => {
83
83
  expect(r.firstName.textContent).toEqual("default");
84
84
  expect(r.changedValue.textContent).toEqual(JSON.stringify({ firstName: "default" }));
85
85
  });
86
- it("uses init if set as a value", async () => {
87
- const config = { firstName: { type: "value" } };
88
- function TestComponent() {
89
- const [, setTick] = (0, react_2.useState)(0);
90
- const form = (0, useFormState_1.useFormState)({
91
- config,
92
- // That's using a raw init value
93
- init: { firstName: "bob" },
94
- });
95
- return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("button", { "data-testid": "change", onClick: () => {
96
- // When that value changes
97
- form.firstName.set("fred");
98
- // And also we re-render the component
99
- setTick(1);
100
- } }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "firstName", children: form.firstName.value })] }));
101
- }
102
- const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
103
- expect(r.firstName).toHaveTextContent("bob");
104
- (0, rtl_utils_1.click)(r.change);
105
- // Then the change didn't get dropped due to init being unstable
106
- expect(r.firstName).toHaveTextContent("fred");
107
- });
108
86
  it("doesn't required an init value", async () => {
109
87
  function TestComponent() {
110
88
  const config = { firstName: { type: "value" } };
@@ -154,7 +132,7 @@ describe("useFormState", () => {
154
132
  };
155
133
  // And we start out with data1
156
134
  const [data, setData] = (0, react_2.useState)(data1);
157
- const form = (0, useFormState_1.useFormState)({ config, init: { input: data, map: (d) => d } });
135
+ const form = (0, useFormState_1.useFormState)({ config, init: { input: data } });
158
136
  function makeLocalChanges() {
159
137
  form.firstName.value = "local";
160
138
  form.address.street.value = "local";
@@ -222,7 +200,7 @@ describe("useFormState", () => {
222
200
  const [data, setData] = (0, react_2.useState)(data1);
223
201
  const form = (0, useFormState_1.useFormState)({
224
202
  config,
225
- init: { input: data, map: (d) => d },
203
+ init: { input: data },
226
204
  // And the form is read only
227
205
  readOnly: true,
228
206
  });
@@ -318,7 +296,7 @@ describe("useFormState", () => {
318
296
  const data2 = { books: [{ title: "Title 1" }] };
319
297
  const form = (0, useFormState_1.useFormState)({
320
298
  config: authorWithBooksConfig,
321
- init: { input: data, map: (d) => d, ifUndefined: { books: [] } },
299
+ init: { input: data, ifUndefined: { books: [] } },
322
300
  autoSave,
323
301
  });
324
302
  return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => setData(data2) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "add", onClick: () => form.books.add({ title: "New Book" }) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "blurBookOne", onClick: () => focusAndBlur(form.books.rows[0].title) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "blurBookTwo", onClick: () => focusAndBlur(form.books.rows[1].title) })] })) }));
@@ -349,7 +327,7 @@ describe("useFormState", () => {
349
327
  const data = { firstName: "f1", lastName: "f1" };
350
328
  const form = (0, useFormState_1.useFormState)({
351
329
  config,
352
- init: data,
330
+ init: { input: data },
353
331
  // And there is reactive business logic in the `autoSave` method
354
332
  async autoSave(state) {
355
333
  state.lastName.set("l2");
@@ -372,7 +350,7 @@ describe("useFormState", () => {
372
350
  const data = { firstName: "f1", lastName: "f1" };
373
351
  const form = (0, useFormState_1.useFormState)({
374
352
  config,
375
- init: data,
353
+ init: { input: data },
376
354
  autoSave: (form) => autoSave(form.changedValue),
377
355
  });
378
356
  return ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("button", { "data-testid": "set", onClick: () => {
@@ -453,7 +431,7 @@ describe("useFormState", () => {
453
431
  (0, mobx_1.reaction)(() => fs.firstName.value, (curr) => (fs.lastName.value = curr));
454
432
  },
455
433
  autoSave: (fs) => autoSaveStub(fs.changedValue),
456
- init: { id: "a:1" },
434
+ init: { input: { id: "a:1" } },
457
435
  });
458
436
  return (0, jsx_runtime_1.jsx)(FormStateApp_1.TextField, { field: fs.firstName });
459
437
  }
@@ -482,7 +460,7 @@ describe("useFormState", () => {
482
460
  it("sets loading if input.data is undefined", async () => {
483
461
  const config = { firstName: { type: "value" } };
484
462
  function TestComponent({ data }) {
485
- const form = (0, useFormState_1.useFormState)({ config, init: { input: data, map: (d) => d } });
463
+ const form = (0, useFormState_1.useFormState)({ config, init: { input: data } });
486
464
  return (0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => (0, jsx_runtime_1.jsx)("div", { "data-testid": "loading", children: String(form.loading) }) });
487
465
  }
488
466
  // And we initially pass in `init.input: undefined`
@@ -546,7 +524,7 @@ describe("useFormState", () => {
546
524
  const config = authorConfig;
547
525
  const form = (0, useFormState_1.useFormState)({
548
526
  config,
549
- init: { firstName: "f1", lastName: "f1" },
527
+ init: { input: { firstName: "f1", lastName: "f1" } },
550
528
  autoSave: async (form) => {
551
529
  autoSave(form.changedValue);
552
530
  // And the autoSave functions erroneously calls commitChanges
@@ -1,6 +1,12 @@
1
1
  import { ObjectConfig } from "./config";
2
2
  import { ObjectState } from "./fields/objectField";
3
3
  export type ObjectStateCache<T, I> = Record<string, [ObjectState<T>, I]>;
4
+ /**
5
+ * The opts has for `useFormStates`.
6
+ *
7
+ * @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
8
+ * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
9
+ */
4
10
  type UseFormStatesOpts<T, I> = {
5
11
  /**
6
12
  * The config to use for each form state.
@@ -45,5 +51,18 @@ type UseFormStatesHook<T, I> = {
45
51
  readOnly?: boolean;
46
52
  }) => ObjectState<T>;
47
53
  };
54
+ /**
55
+ * A hook to manage many "mini-forms" on a single page, typically one form per row
56
+ * in a table.
57
+ *
58
+ * This hook basically provides the page/table with a cache, so each table row naively ask "what's
59
+ * the form state for this given row's data?" and get back a new-or-existing `ObjectState` instance
60
+ * that, if already existing, still has any of the user's WIP changes.
61
+ *
62
+ * Each mini-form/row can have its own autoSave calls, independent of the other rows.
63
+ *
64
+ * @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
65
+ * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
66
+ */
48
67
  export declare function useFormStates<T, I = T>(opts: UseFormStatesOpts<T, I>): UseFormStatesHook<T, I>;
49
68
  export {};
@@ -4,6 +4,19 @@ exports.useFormStates = void 0;
4
4
  const react_1 = require("react");
5
5
  const objectField_1 = require("./fields/objectField");
6
6
  const utils_1 = require("./utils");
7
+ /**
8
+ * A hook to manage many "mini-forms" on a single page, typically one form per row
9
+ * in a table.
10
+ *
11
+ * This hook basically provides the page/table with a cache, so each table row naively ask "what's
12
+ * the form state for this given row's data?" and get back a new-or-existing `ObjectState` instance
13
+ * that, if already existing, still has any of the user's WIP changes.
14
+ *
15
+ * Each mini-form/row can have its own autoSave calls, independent of the other rows.
16
+ *
17
+ * @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
18
+ * @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
19
+ */
7
20
  function useFormStates(opts) {
8
21
  const { config, autoSave, getId, map, addRules, readOnly = false } = opts;
9
22
  const objectStateCache = (0, react_1.useMemo)(() => ({}),
@@ -54,7 +67,7 @@ function useFormStates(opts) {
54
67
  }
55
68
  // If it didn't exist, then add to the cache.
56
69
  if (!form) {
57
- form = (0, objectField_1.createObjectState)(config, (0, utils_1.initValue)(config, map ? { map, input } : input), {
70
+ form = (0, objectField_1.createObjectState)(config, (0, utils_1.initValue)(config, { map, input }), {
58
71
  maybeAutoSave: () => maybeAutoSave(form),
59
72
  });
60
73
  if (addRules) {
@@ -64,7 +77,7 @@ function useFormStates(opts) {
64
77
  }
65
78
  // If the source of truth changed, then update the existing state and return it.
66
79
  if (existing && existing[1] !== input) {
67
- form.set((0, utils_1.initValue)(config, map ? { map, input } : input), {
80
+ form.set((0, utils_1.initValue)(config, { map, input }), {
68
81
  refreshing: true,
69
82
  });
70
83
  existing[1] = input;
package/dist/utils.d.ts CHANGED
@@ -13,7 +13,7 @@ export type OmitIf<Base, Condition> = Pick<Base, {
13
13
  }[keyof Base]>;
14
14
  export declare function fail(message?: string): never;
15
15
  export declare function assertNever(x: never): never;
16
- /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
16
+ /** Introspects the `init` prop to see if it has a `map` function/etc. and returns the form value. */
17
17
  export declare function initValue<T>(config: ObjectConfig<T>, init: any): T;
18
18
  export declare function isInput<T, I>(init: UseFormStateOpts<T, I>["init"]): init is InputAndMap<T, I>;
19
19
  export declare function isQuery<T, I>(init: UseFormStateOpts<T, I>["init"]): init is QueryAndMap<T, I>;
package/dist/utils.js CHANGED
@@ -15,23 +15,27 @@ function assertNever(x) {
15
15
  throw new Error("Unexpected object: " + x);
16
16
  }
17
17
  exports.assertNever = assertNever;
18
- /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
18
+ /** Introspects the `init` prop to see if it has a `map` function/etc. and returns the form value. */
19
19
  function initValue(config, init) {
20
20
  let value;
21
21
  if (isInput(init)) {
22
- value = init.input ? init.map(init.input) : init.ifUndefined;
22
+ value = init.input ? (init.map ? init.map(init.input) : init.input) : init.ifUndefined;
23
23
  }
24
24
  else if (isQuery(init)) {
25
25
  value = init.query.data ? init.map(init.query.data) : init.ifUndefined;
26
26
  }
27
+ else if (init === undefined) {
28
+ // allow completely undefined init
29
+ }
27
30
  else {
28
- value = init;
31
+ throw new Error("init must have an input or query key");
29
32
  }
33
+ // Given our form config, pick out only the subset of fields out of `value` (unless it's a mobx class)
30
34
  return pickFields(config, value !== null && value !== void 0 ? value : {});
31
35
  }
32
36
  exports.initValue = initValue;
33
37
  function isInput(init) {
34
- return !!init && typeof init === "object" && "input" in init && "map" in init;
38
+ return !!init && typeof init === "object" && "input" in init;
35
39
  }
36
40
  exports.isInput = isInput;
37
41
  function isQuery(init) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.23.2",
3
+ "version": "2.24.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",