@homebound/form-state 2.4.1 → 2.5.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.
@@ -20,7 +20,7 @@ function FormStateApp() {
20
20
  return state.firstName.value === state.lastName.value ? "Last name cannot equal first name" : undefined;
21
21
  });
22
22
  },
23
- autoSave() {
23
+ async autoSave() {
24
24
  console.log("saving", formState.changedValue);
25
25
  },
26
26
  });
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createObjectState, FieldState, ListFieldState, ObjectConfig, ObjectState, required, Rule, } from "./formState";
2
2
  export { useFormState } from "./useFormState";
3
+ export { useFormStates } from "./useFormStates";
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useFormState = exports.required = exports.createObjectState = void 0;
3
+ exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
4
4
  var formState_1 = require("./formState");
5
5
  Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return formState_1.createObjectState; } });
6
6
  Object.defineProperty(exports, "required", { enumerable: true, get: function () { return formState_1.required; } });
7
7
  var useFormState_1 = require("./useFormState");
8
8
  Object.defineProperty(exports, "useFormState", { enumerable: true, get: function () { return useFormState_1.useFormState; } });
9
+ var useFormStates_1 = require("./useFormStates");
10
+ Object.defineProperty(exports, "useFormStates", { enumerable: true, get: function () { return useFormStates_1.useFormStates; } });
@@ -37,21 +37,9 @@ export declare type UseFormStateOpts<T, I> = {
37
37
  *
38
38
  * Does not need to be stable/useMemo'd.
39
39
  */
40
- autoSave?: (state: ObjectState<T>) => void;
40
+ autoSave?: (state: ObjectState<T>) => Promise<void>;
41
41
  };
42
42
  /**
43
43
  * Creates a formState instance for editing in a form.
44
44
  */
45
45
  export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): ObjectState<T>;
46
- /**
47
- * Picks ony fields out of `instance` that are in the `formConfig`.
48
- *
49
- * This is useful for creating a form input from a GraphQL query result,
50
- * but ignoring extra data in the query that we don't want/need in the form.
51
- *
52
- * (Especially because the form.value will become our mutation's input type
53
- * that goes on the wire, we don't want superfluous data in it.)
54
- */
55
- export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
56
- [K in keyof T]: K extends keyof I ? I[K] : never;
57
- };
@@ -1,8 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pickFields = exports.useFormState = void 0;
3
+ exports.useFormState = void 0;
4
4
  const is_plain_object_1 = require("is-plain-object");
5
- const mobx_1 = require("mobx");
6
5
  const react_1 = require("react");
7
6
  const formState_1 = require("./formState");
8
7
  const utils_1 = require("./utils");
@@ -10,6 +9,9 @@ const utils_1 = require("./utils");
10
9
  // in some business logic right before their GraphQL mutation call, ignore it
11
10
  // so that we don't infinite loop.
12
11
  let isAutoSaving = false;
12
+ // `pendingAutoSave` is a flag for determining if we need to immediately call `maybeAutoSave` again after the initial Promise finishes
13
+ // This could happen if a field triggers auto-save while another field's auto-save is already in progress.
14
+ let pendingAutoSave = false;
13
15
  /**
14
16
  * Creates a formState instance for editing in a form.
15
17
  */
@@ -20,28 +22,34 @@ function useFormState(opts) {
20
22
  autoSaveRef.current = autoSave;
21
23
  const firstRunRef = react_1.useRef(true);
22
24
  // This is a little weird, but we need to know ahead of time, before the form useMemo, if we're working with classes/mobx proxies
23
- const [firstInitValue] = react_1.useState(() => initValue(config, init));
25
+ const [firstInitValue] = react_1.useState(() => utils_1.initValue(config, init));
24
26
  const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
25
27
  // If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
26
28
  const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
27
29
  const form = react_1.useMemo(() => {
28
- function maybeAutoSave() {
30
+ async function maybeAutoSave() {
31
+ if (isAutoSaving) {
32
+ pendingAutoSave = true;
33
+ }
29
34
  // Don't use canSave() because we don't want to set touched for all the fields
30
35
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
31
36
  try {
32
37
  isAutoSaving = true;
33
- autoSaveRef.current(form);
38
+ await autoSaveRef.current(form);
34
39
  }
35
40
  finally {
36
41
  isAutoSaving = false;
42
+ if (pendingAutoSave) {
43
+ pendingAutoSave = false;
44
+ await maybeAutoSave();
45
+ }
37
46
  }
38
47
  }
39
48
  }
40
- const value = firstRunRef.current ? firstInitValue : initValue(config, init);
49
+ const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
41
50
  const form = formState_1.createObjectState(config, value, { maybeAutoSave });
42
51
  form.readOnly = readOnly;
43
52
  // The identity of `addRules` is not stable, but assume that it is for better UX.
44
- // eslint-disable-next-line react-hooks/exhaustive-deps
45
53
  (addRules || (() => { }))(form);
46
54
  firstRunRef.current = true;
47
55
  return form;
@@ -59,7 +67,7 @@ function useFormState(opts) {
59
67
  firstRunRef.current = false;
60
68
  return;
61
69
  }
62
- form.set(initValue(config, init), { refreshing: true });
70
+ form.set(utils_1.initValue(config, init), { refreshing: true });
63
71
  }, [form, ...dep]);
64
72
  // Use useEffect so that we don't touch the form.init proxy during a render
65
73
  react_1.useEffect(() => {
@@ -68,59 +76,3 @@ function useFormState(opts) {
68
76
  return form;
69
77
  }
70
78
  exports.useFormState = useFormState;
71
- /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
72
- function initValue(config, init) {
73
- const initValue = init && "input" in init && "map" in init
74
- ? init.input
75
- ? init.map(init.input)
76
- : init.ifUndefined || {}
77
- : init || {};
78
- return pickFields(config, initValue);
79
- }
80
- /**
81
- * Picks ony fields out of `instance` that are in the `formConfig`.
82
- *
83
- * This is useful for creating a form input from a GraphQL query result,
84
- * but ignoring extra data in the query that we don't want/need in the form.
85
- *
86
- * (Especially because the form.value will become our mutation's input type
87
- * that goes on the wire, we don't want superfluous data in it.)
88
- */
89
- function pickFields(formConfig, instance) {
90
- // If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
91
- if (!is_plain_object_1.isPlainObject(instance)) {
92
- return instance;
93
- }
94
- return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
95
- const keyConfig = _keyConfig;
96
- const value = instance[key];
97
- if (keyConfig.type === "object") {
98
- if (value) {
99
- return [key, pickFields(keyConfig.config, value)];
100
- }
101
- else {
102
- return [key, value];
103
- }
104
- }
105
- else if (keyConfig.type === "list") {
106
- if (mobx_1.isObservable(value)) {
107
- // If we hit an observable array, leave it as the existing proxy so the our
108
- // ListFieldState will react to changes in the original array.
109
- return [key, value];
110
- }
111
- else if (value) {
112
- return [key, value.map((u) => pickFields(keyConfig.config, u))];
113
- }
114
- else {
115
- return [key, value];
116
- }
117
- }
118
- else if (keyConfig.type === "value") {
119
- return [key, value];
120
- }
121
- else {
122
- return utils_1.assertNever(keyConfig);
123
- }
124
- }));
125
- }
126
- exports.pickFields = pickFields;
@@ -2,7 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const jsx_runtime_1 = require("react/jsx-runtime");
4
4
  const rtl_utils_1 = require("@homebound/rtl-utils");
5
- const mobx_1 = require("mobx");
6
5
  const mobx_react_1 = require("mobx-react");
7
6
  const react_1 = require("react");
8
7
  const formState_1 = require("./formState");
@@ -314,7 +313,7 @@ describe("useFormState", () => {
314
313
  // When the data/child is now available
315
314
  rtl_utils_1.click(r.refreshData);
316
315
  // And the field is blurred
317
- rtl_utils_1.click(r.blurBookOne);
316
+ await rtl_utils_1.clickAndWait(r.blurBookOne);
318
317
  // Then we don't auto-save because nothing has changed
319
318
  expect(autoSave).toBeCalledTimes(0);
320
319
  // And when adding a new book
@@ -323,7 +322,7 @@ describe("useFormState", () => {
323
322
  // that say the new row can't be empty)
324
323
  expect(autoSave).toBeCalledTimes(1);
325
324
  // And the new book is blurred
326
- rtl_utils_1.click(r.blurBookTwo);
325
+ await rtl_utils_1.clickAndWait(r.blurBookTwo);
327
326
  // Then we auto save again
328
327
  expect(autoSave).toBeCalledTimes(2);
329
328
  });
@@ -337,7 +336,7 @@ describe("useFormState", () => {
337
336
  config,
338
337
  init: data,
339
338
  // And there is reactive business logic in the `autoSave` method
340
- autoSave(state) {
339
+ async autoSave(state) {
341
340
  state.lastName.set("l2");
342
341
  autoSaves++;
343
342
  },
@@ -350,75 +349,45 @@ describe("useFormState", () => {
350
349
  // When autoSave didn't infinite loop
351
350
  expect(autoSaves).toEqual(1);
352
351
  });
353
- describe("pickFields", () => {
354
- it("can pick a value field", () => {
355
- const a = useFormState_1.pickFields(
356
- //
357
- { firstName: { type: "value" } }, { firstName: "a", b: "ignored" });
358
- expect(a).toMatchInlineSnapshot(`
359
- Object {
360
- "firstName": "a",
361
- }
362
- `);
363
- });
364
- it("can pick an unset object fields", () => {
365
- const a = useFormState_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored" });
366
- expect(a).toMatchInlineSnapshot(`
367
- Object {
368
- "address": undefined,
369
- "firstName": "a",
370
- "id": undefined,
371
- "lastName": undefined,
372
- }
373
- `);
374
- });
375
- it("can pick a set object field", () => {
376
- const a = useFormState_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored", address: {} });
377
- expect(a).toMatchInlineSnapshot(`
378
- Object {
379
- "address": Object {
380
- "city": undefined,
381
- "street": undefined,
382
- },
383
- "firstName": "a",
384
- "id": undefined,
385
- "lastName": undefined,
386
- }
387
- `);
388
- });
389
- it("can pick an unset list field", () => {
390
- const a = useFormState_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored" });
391
- expect(a).toMatchInlineSnapshot(`
392
- Object {
393
- "books": undefined,
394
- "firstName": "a",
395
- "id": undefined,
396
- "lastName": undefined,
397
- }
398
- `);
399
- });
400
- it("can pick a set list field", () => {
401
- const a = useFormState_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books: [{}] });
402
- expect(a).toMatchInlineSnapshot(`
403
- Object {
404
- "books": Array [
405
- Object {
406
- "classification": undefined,
407
- "id": undefined,
408
- "title": undefined,
409
- },
410
- ],
411
- "firstName": "a",
412
- "id": undefined,
413
- "lastName": undefined,
414
- }
415
- `);
416
- });
417
- it("can pick a set observable list field", () => {
418
- const books = mobx_1.observable([]);
419
- const a = useFormState_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books });
420
- expect(mobx_1.isObservable(a.books)).toEqual(true);
421
- });
352
+ it("can queue up changes for auto save if a save is already in progress", async () => {
353
+ const autoSaveStub = jest.fn();
354
+ const config = {
355
+ id: { type: "value" },
356
+ firstName: { type: "value" },
357
+ lastName: { type: "value" },
358
+ };
359
+ // Given a component using `getObjectState` for lazily creating ObjectStates
360
+ function TestComponent() {
361
+ const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon", lastName: "Dow" });
362
+ const state = useFormState_1.useFormState({ config, autoSave, init: { input: apiData, map: (v) => v } });
363
+ async function autoSave(form) {
364
+ autoSaveStub(form.changedValue);
365
+ // Pretend to make an API call and update the local state
366
+ setApiData((prevState) => ({ ...prevState, ...form.changedValue }));
367
+ await Promise.resolve(1);
368
+ }
369
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
370
+ state.firstName.focus();
371
+ state.firstName.set("Foo");
372
+ state.firstName.maybeAutoSave();
373
+ state.firstName.blur();
374
+ } }, void 0),
375
+ jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveFieldLastName", onClick: () => {
376
+ state.lastName.focus();
377
+ state.lastName.set("Bar");
378
+ state.lastName.maybeAutoSave();
379
+ } }, void 0)] }, void 0));
380
+ }
381
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
382
+ // And triggering the auto save behavior before awaiting the initial promise to resolve so we have pending changes.
383
+ rtl_utils_1.click(r.focusSetAndSaveField());
384
+ rtl_utils_1.click(r.focusSetAndSaveFieldLastName());
385
+ // Awaits the promises for all methods triggered above
386
+ await rtl_utils_1.wait();
387
+ // Then expect the auto save to only have been called twice. Once with each changedValues.
388
+ expect(autoSaveStub).toBeCalledTimes(2);
389
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
390
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
422
391
  });
423
392
  });
424
393
  const authorConfig = {
@@ -426,18 +395,6 @@ const authorConfig = {
426
395
  firstName: { type: "value" },
427
396
  lastName: { type: "value" },
428
397
  };
429
- const authorWithAddressConfig = {
430
- id: { type: "value" },
431
- firstName: { type: "value" },
432
- lastName: { type: "value" },
433
- address: {
434
- type: "object",
435
- config: {
436
- street: { type: "value", rules: [formState_1.required] },
437
- city: { type: "value" },
438
- },
439
- },
440
- };
441
398
  const authorWithBooksConfig = {
442
399
  id: { type: "value" },
443
400
  firstName: { type: "value" },
@@ -0,0 +1,12 @@
1
+ import { ObjectConfig, ObjectState } from "./formState";
2
+ export declare type ObjectStateCache<T, I> = Record<string, [ObjectState<T>, I]>;
3
+ declare type UseFormStatesOpts<T, I> = {
4
+ config: ObjectConfig<T>;
5
+ autoSave?: (state: ObjectState<T>) => Promise<void>;
6
+ getId: (v: I) => string;
7
+ map?: (input: Exclude<I, null | undefined>) => T;
8
+ };
9
+ export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
10
+ getObjectState: (input: I) => ObjectState<T>;
11
+ };
12
+ export {};
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useFormStates = void 0;
4
+ const react_1 = require("react");
5
+ const formState_1 = require("./formState");
6
+ const utils_1 = require("./utils");
7
+ function useFormStates(opts) {
8
+ const { config, autoSave, getId, map } = opts;
9
+ const objectStateCache = react_1.useMemo(() => ({}), [config]);
10
+ // Keep track of ObjectStates that triggered auto-save when a save was already in progress.
11
+ const pendingAutoSaves = react_1.useRef([]);
12
+ // Use a ref so our memo'ized `autoSave` always see the latest value
13
+ const autoSaveRef = react_1.useRef(autoSave);
14
+ autoSaveRef.current = autoSave;
15
+ async function maybeAutoSave(form) {
16
+ if (isAutoSaving && !pendingAutoSaves.current.includes(form)) {
17
+ pendingAutoSaves.current.push(form);
18
+ }
19
+ // Don't use canSave() because we don't want to set touched for all the fields
20
+ if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
21
+ try {
22
+ isAutoSaving = true;
23
+ await autoSaveRef.current(form);
24
+ }
25
+ finally {
26
+ isAutoSaving = false;
27
+ if (pendingAutoSaves.current.length > 0) {
28
+ await maybeAutoSave(pendingAutoSaves.current.shift());
29
+ }
30
+ }
31
+ }
32
+ }
33
+ return {
34
+ getObjectState: (input) => {
35
+ const existing = objectStateCache[getId(input)];
36
+ const form = existing
37
+ ? existing[0]
38
+ : formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
39
+ maybeAutoSave: () => maybeAutoSave(form),
40
+ });
41
+ // If it didn't exist, then add to the cache.
42
+ if (!existing) {
43
+ objectStateCache[getId(input)] = [form, input];
44
+ }
45
+ // If the source of truth changed, then update the existing state and return it.
46
+ if (existing && existing[1] !== input) {
47
+ existing[0].set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
48
+ existing[1] = input;
49
+ }
50
+ return form;
51
+ },
52
+ };
53
+ }
54
+ exports.useFormStates = useFormStates;
55
+ // If the user's autoSave hook makes some last-minute `.set` calls to sneak
56
+ // in some business logic right before their GraphQL mutation call, ignore it
57
+ // so that we don't infinite loop.
58
+ let isAutoSaving = false;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const jsx_runtime_1 = require("react/jsx-runtime");
4
+ const rtl_utils_1 = require("@homebound/rtl-utils");
5
+ const react_1 = require("react");
6
+ const formState_1 = require("./formState");
7
+ const useFormStates_1 = require("./useFormStates");
8
+ describe("useFormStates", () => {
9
+ it("can lazily create form states", async () => {
10
+ const autoSave = jest.fn();
11
+ // Given a parent and child component, where the formState is created only for the child component.
12
+ function ChildComponent({ os }) {
13
+ return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
14
+ }
15
+ function TestComponent() {
16
+ const config = { id: { type: "value" }, firstName: { type: "value" } };
17
+ const { getObjectState } = useFormStates_1.useFormStates({
18
+ config,
19
+ autoSave,
20
+ getId: (o) => o.id,
21
+ });
22
+ return (jsx_runtime_1.jsx("div", { children: jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0) }, void 0));
23
+ }
24
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
25
+ // And the child component has defined state
26
+ expect(r.firstName()).toHaveTextContent("Brandon");
27
+ });
28
+ it("can update existing object state from cache with new values", async () => {
29
+ const autoSave = jest.fn();
30
+ const config = { id: { type: "value" }, firstName: { type: "value" } };
31
+ // Given a component using `getObjectState` for lazily creating ObjectStates
32
+ function TestComponent() {
33
+ const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon" });
34
+ const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
35
+ // Memoize an original for comparing the update against.
36
+ const originalState = react_1.useMemo(() => getObjectState(apiData), []);
37
+ const state = getObjectState(apiData);
38
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
39
+ jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
40
+ jsx_runtime_1.jsx("button", { "data-testid": "updateApiData", onClick: () => setApiData((prevState) => ({ ...prevState, firstName: "Bob" })) }, void 0)] }, void 0));
41
+ }
42
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
43
+ // And the initial values for the form state display
44
+ expect(r.firstName()).toHaveTextContent("Brandon");
45
+ expect(r.statesEqual()).toHaveTextContent("true");
46
+ // When updating the API data
47
+ await rtl_utils_1.clickAndWait(r.updateApiData());
48
+ // Then the new value is shown in the component
49
+ expect(r.firstName()).toHaveTextContent("Bob");
50
+ // And the two states are using the same reference
51
+ expect(r.statesEqual()).toHaveTextContent("true");
52
+ });
53
+ it("can queue up changes for auto save if a save is already in progress - works across multiple states", async () => {
54
+ const autoSaveStub = jest.fn();
55
+ const config = {
56
+ id: { type: "value" },
57
+ firstName: { type: "value" },
58
+ lastName: { type: "value" },
59
+ };
60
+ // Given a component using `getObjectState` for lazily creating ObjectStates
61
+ function TestComponent() {
62
+ const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Tony", lastName: "Stark" });
63
+ const [apiData2, setApiData2] = react_1.useState({ id: "a:2", firstName: "Steve", lastName: "Rogers" });
64
+ const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
65
+ const state = getObjectState(apiData);
66
+ const state2 = getObjectState(apiData2);
67
+ async function autoSave(form) {
68
+ autoSaveStub(form.changedValue);
69
+ // Pretend to make an API call and update the local state
70
+ if (form.id.value === "a:1") {
71
+ setApiData((prevState) => ({ ...prevState, ...form.changedValue }));
72
+ }
73
+ else {
74
+ setApiData2((prevState) => ({ ...prevState, ...form.changedValue }));
75
+ }
76
+ await Promise.resolve(1);
77
+ }
78
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
79
+ jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
80
+ state.firstName.focus();
81
+ state.firstName.set("Foo");
82
+ state.firstName.maybeAutoSave();
83
+ } }, void 0),
84
+ jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveFieldLastName", onClick: () => {
85
+ state.lastName.focus();
86
+ state.lastName.set("Bar");
87
+ state.lastName.maybeAutoSave();
88
+ } }, void 0),
89
+ jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField2", onClick: () => {
90
+ state2.lastName.focus();
91
+ state2.lastName.set("Bar");
92
+ state2.lastName.maybeAutoSave();
93
+ } }, void 0)] }, void 0));
94
+ }
95
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
96
+ // And triggering the auto save behavior before awaiting the initial promise to resolve so we have pending changes.
97
+ rtl_utils_1.click(r.focusSetAndSaveField());
98
+ rtl_utils_1.click(r.focusSetAndSaveFieldLastName());
99
+ rtl_utils_1.click(r.focusSetAndSaveField2());
100
+ // Awaits the promises for all methods triggered above
101
+ await rtl_utils_1.wait();
102
+ // Then expect the auto save to only have been called three times. Once with each set of changedValues.
103
+ expect(autoSaveStub).toBeCalledTimes(3);
104
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
105
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
106
+ expect(autoSaveStub).toBeCalledWith({ id: "a:2", lastName: "Bar" });
107
+ });
108
+ it("clears out cache if configuration changes", async () => {
109
+ const autoSave = jest.fn();
110
+ // Given a component with stable API data.
111
+ const apiData = { id: "a:1", firstName: "Brandon", lastName: "Dow" };
112
+ // And two sets of configurations
113
+ const originalConfig = { id: { type: "value" }, firstName: { type: "value" } };
114
+ const updatedConfig = {
115
+ id: { type: "value" },
116
+ firstName: { type: "value", rules: [formState_1.required] },
117
+ };
118
+ function TestComponent() {
119
+ const [config, setConfig] = react_1.useState(originalConfig);
120
+ const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
121
+ // Memoize an original for comparing the update against.
122
+ const originalState = react_1.useMemo(() => getObjectState(apiData), []);
123
+ const state = getObjectState(apiData);
124
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
125
+ jsx_runtime_1.jsx("button", { "data-testid": "updateConfig", onClick: () => setConfig(updatedConfig) }, void 0)] }, void 0));
126
+ }
127
+ // When rendered with the original configuration
128
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
129
+ // Then the two form-states generated are equal
130
+ expect(r.statesEqual()).toHaveTextContent("true");
131
+ // When updating the configuration object
132
+ rtl_utils_1.click(r.updateConfig);
133
+ // Then the two form-states are no longer equal.
134
+ expect(r.statesEqual()).toHaveTextContent("false");
135
+ });
136
+ });
package/dist/utils.d.ts CHANGED
@@ -1,2 +1,17 @@
1
+ import { ObjectConfig } from "./formState";
1
2
  export declare function fail(message?: string): never;
2
3
  export declare function assertNever(x: never): never;
4
+ /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
5
+ export declare function initValue<T>(config: ObjectConfig<T>, init: any): T;
6
+ /**
7
+ * Picks ony fields out of `instance` that are in the `formConfig`.
8
+ *
9
+ * This is useful for creating a form input from a GraphQL query result,
10
+ * but ignoring extra data in the query that we don't want/need in the form.
11
+ *
12
+ * (Especially because the form.value will become our mutation's input type
13
+ * that goes on the wire, we don't want superfluous data in it.)
14
+ */
15
+ export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
16
+ [K in keyof T]: K extends keyof I ? I[K] : never;
17
+ };
package/dist/utils.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.assertNever = exports.fail = void 0;
3
+ exports.pickFields = exports.initValue = exports.assertNever = exports.fail = void 0;
4
+ const is_plain_object_1 = require("is-plain-object");
5
+ const mobx_1 = require("mobx");
4
6
  function fail(message) {
5
7
  throw new Error(message || "Failed");
6
8
  }
@@ -9,3 +11,60 @@ function assertNever(x) {
9
11
  throw new Error("Unexpected object: " + x);
10
12
  }
11
13
  exports.assertNever = assertNever;
14
+ /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
15
+ function initValue(config, init) {
16
+ const initValue = init && "input" in init && "map" in init
17
+ ? init.input
18
+ ? init.map(init.input)
19
+ : init.ifUndefined || {}
20
+ : init || {};
21
+ return pickFields(config, initValue);
22
+ }
23
+ exports.initValue = initValue;
24
+ /**
25
+ * Picks ony fields out of `instance` that are in the `formConfig`.
26
+ *
27
+ * This is useful for creating a form input from a GraphQL query result,
28
+ * but ignoring extra data in the query that we don't want/need in the form.
29
+ *
30
+ * (Especially because the form.value will become our mutation's input type
31
+ * that goes on the wire, we don't want superfluous data in it.)
32
+ */
33
+ function pickFields(formConfig, instance) {
34
+ // If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
35
+ if (!is_plain_object_1.isPlainObject(instance)) {
36
+ return instance;
37
+ }
38
+ return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
39
+ const keyConfig = _keyConfig;
40
+ const value = instance[key];
41
+ if (keyConfig.type === "object") {
42
+ if (value) {
43
+ return [key, pickFields(keyConfig.config, value)];
44
+ }
45
+ else {
46
+ return [key, value];
47
+ }
48
+ }
49
+ else if (keyConfig.type === "list") {
50
+ if (mobx_1.isObservable(value)) {
51
+ // If we hit an observable array, leave it as the existing proxy so the our
52
+ // ListFieldState will react to changes in the original array.
53
+ return [key, value];
54
+ }
55
+ else if (value) {
56
+ return [key, value.map((u) => pickFields(keyConfig.config, u))];
57
+ }
58
+ else {
59
+ return [key, value];
60
+ }
61
+ }
62
+ else if (keyConfig.type === "value") {
63
+ return [key, value];
64
+ }
65
+ else {
66
+ return assertNever(keyConfig);
67
+ }
68
+ }));
69
+ }
70
+ exports.pickFields = pickFields;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const mobx_1 = require("mobx");
4
+ const formState_1 = require("./formState");
5
+ const utils_1 = require("./utils");
6
+ describe("utils", () => {
7
+ describe("pickFields", () => {
8
+ it("can pick a value field", () => {
9
+ const a = utils_1.pickFields(
10
+ //
11
+ { firstName: { type: "value" } }, { firstName: "a", b: "ignored" });
12
+ expect(a).toMatchInlineSnapshot(`
13
+ Object {
14
+ "firstName": "a",
15
+ }
16
+ `);
17
+ });
18
+ it("can pick an unset object fields", () => {
19
+ const a = utils_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored" });
20
+ expect(a).toMatchInlineSnapshot(`
21
+ Object {
22
+ "address": undefined,
23
+ "firstName": "a",
24
+ "id": undefined,
25
+ "lastName": undefined,
26
+ }
27
+ `);
28
+ });
29
+ it("can pick a set object field", () => {
30
+ const a = utils_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored", address: {} });
31
+ expect(a).toMatchInlineSnapshot(`
32
+ Object {
33
+ "address": Object {
34
+ "city": undefined,
35
+ "street": undefined,
36
+ },
37
+ "firstName": "a",
38
+ "id": undefined,
39
+ "lastName": undefined,
40
+ }
41
+ `);
42
+ });
43
+ it("can pick an unset list field", () => {
44
+ const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored" });
45
+ expect(a).toMatchInlineSnapshot(`
46
+ Object {
47
+ "books": undefined,
48
+ "firstName": "a",
49
+ "id": undefined,
50
+ "lastName": undefined,
51
+ }
52
+ `);
53
+ });
54
+ it("can pick a set list field", () => {
55
+ const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books: [{}] });
56
+ expect(a).toMatchInlineSnapshot(`
57
+ Object {
58
+ "books": Array [
59
+ Object {
60
+ "classification": undefined,
61
+ "id": undefined,
62
+ "title": undefined,
63
+ },
64
+ ],
65
+ "firstName": "a",
66
+ "id": undefined,
67
+ "lastName": undefined,
68
+ }
69
+ `);
70
+ });
71
+ it("can pick a set observable list field", () => {
72
+ const books = mobx_1.observable([]);
73
+ const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books });
74
+ expect(mobx_1.isObservable(a.books)).toEqual(true);
75
+ });
76
+ });
77
+ });
78
+ const authorWithAddressConfig = {
79
+ id: { type: "value" },
80
+ firstName: { type: "value" },
81
+ lastName: { type: "value" },
82
+ address: {
83
+ type: "object",
84
+ config: {
85
+ street: { type: "value", rules: [formState_1.required] },
86
+ city: { type: "value" },
87
+ },
88
+ },
89
+ };
90
+ const authorWithBooksConfig = {
91
+ id: { type: "value" },
92
+ firstName: { type: "value" },
93
+ lastName: { type: "value" },
94
+ books: {
95
+ type: "list",
96
+ config: {
97
+ id: { type: "value" },
98
+ title: { type: "value", rules: [formState_1.required] },
99
+ classification: { type: "value" },
100
+ },
101
+ },
102
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.4.1",
3
+ "version": "2.5.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -21,6 +21,14 @@
21
21
  "format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,md}\"",
22
22
  "semantic-release": "semantic-release"
23
23
  },
24
+ "husky": {
25
+ "hooks": {
26
+ "pre-commit": "lint-staged --allow-empty"
27
+ }
28
+ },
29
+ "lint-staged": {
30
+ "src/**/*.{ts,tsx,css,md}": "prettier --write"
31
+ },
24
32
  "dependencies": {
25
33
  "fast-deep-equal": "^3.1.3",
26
34
  "is-plain-object": "^5.0.0",
@@ -59,8 +67,9 @@
59
67
  "eslint-plugin-jsx-a11y": "^6.4.1",
60
68
  "eslint-plugin-react": "^7.22.0",
61
69
  "eslint-plugin-react-hooks": "^4.2.0",
62
- "husky": "^5.1.1",
70
+ "husky": "^3.0.9",
63
71
  "jest": "^27.0.2",
72
+ "lint-staged": "^10.1.2",
64
73
  "mobx": "^6.3.2",
65
74
  "mobx-react": "^7.2.0",
66
75
  "prettier": "^2.2.1",