@homebound/form-state 2.5.0 → 2.5.1

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.
@@ -4,5 +4,10 @@ require("@testing-library/jest-dom");
4
4
  const mobx_1 = require("mobx");
5
5
  // formState doesn't use actions
6
6
  mobx_1.configure({ enforceActions: "never" });
7
- beforeAll(() => jest.useFakeTimers("modern"));
8
- afterAll(() => jest.useRealTimers());
7
+ beforeEach(() => {
8
+ jest.useFakeTimers("modern");
9
+ });
10
+ afterEach(() => {
11
+ jest.runOnlyPendingTimers();
12
+ jest.useRealTimers();
13
+ });
@@ -37,7 +37,7 @@ 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>) => Promise<void>;
40
+ autoSave?: (state: ObjectState<T>) => Promise<unknown>;
41
41
  };
42
42
  /**
43
43
  * Creates a formState instance for editing in a form.
@@ -12,6 +12,7 @@ let isAutoSaving = false;
12
12
  // `pendingAutoSave` is a flag for determining if we need to immediately call `maybeAutoSave` again after the initial Promise finishes
13
13
  // This could happen if a field triggers auto-save while another field's auto-save is already in progress.
14
14
  let pendingAutoSave = false;
15
+ let timerId;
15
16
  /**
16
17
  * Creates a formState instance for editing in a form.
17
18
  */
@@ -27,23 +28,36 @@ function useFormState(opts) {
27
28
  // If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
28
29
  const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
29
30
  const form = react_1.useMemo(() => {
30
- async function maybeAutoSave() {
31
- if (isAutoSaving) {
31
+ function maybeAutoSave() {
32
+ if (isAutoSaving === "in-flight") {
32
33
  pendingAutoSave = true;
33
34
  }
35
+ else if (isAutoSaving === "queued") {
36
+ return;
37
+ }
34
38
  // Don't use canSave() because we don't want to set touched for all the fields
35
39
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
36
- try {
37
- isAutoSaving = true;
38
- await autoSaveRef.current(form);
39
- }
40
- finally {
41
- isAutoSaving = false;
42
- if (pendingAutoSave) {
43
- pendingAutoSave = false;
44
- await maybeAutoSave();
40
+ isAutoSaving = "queued";
41
+ // We use setTimeout as a cheap way to wait until the end of the current event listener
42
+ timerId = setTimeout(async () => {
43
+ try {
44
+ // We technically don't flip to in-flight until after the call in case the
45
+ // user's autoSave function itself wants to call a .set.
46
+ const promise = autoSaveRef.current(form);
47
+ isAutoSaving = "in-flight";
48
+ await promise;
49
+ }
50
+ finally {
51
+ isAutoSaving = false;
52
+ if (pendingAutoSave) {
53
+ pendingAutoSave = false;
54
+ // Push out the follow-up by 1 tick to allow refreshes to happen to potentially
55
+ // un-dirty the just-saved data (e.g. if we run right away, the caller's maybeAutoSave
56
+ // will see a form.changedValue that thinks the just-saved data is still dirty).
57
+ setTimeout(maybeAutoSave, 0);
58
+ }
45
59
  }
46
- }
60
+ }, 0);
47
61
  }
48
62
  }
49
63
  const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
@@ -2,8 +2,9 @@
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 react_1 = require("@testing-library/react");
5
6
  const mobx_react_1 = require("mobx-react");
6
- const react_1 = require("react");
7
+ const react_2 = require("react");
7
8
  const formState_1 = require("./formState");
8
9
  const useFormState_1 = require("./useFormState");
9
10
  describe("useFormState", () => {
@@ -26,7 +27,7 @@ describe("useFormState", () => {
26
27
  it("memoizes on init.input being an array", async () => {
27
28
  const config = { firstName: { type: "value" } };
28
29
  function TestComponent() {
29
- const [, setTick] = react_1.useState(0);
30
+ const [, setTick] = react_2.useState(0);
30
31
  const form = useFormState_1.useFormState({
31
32
  config,
32
33
  init: { input: ["a", "b"], map: ([a, b]) => ({ firstName: a + b }) },
@@ -85,7 +86,7 @@ describe("useFormState", () => {
85
86
  it("uses init if set as a value", async () => {
86
87
  const config = { firstName: { type: "value" } };
87
88
  function TestComponent() {
88
- const [, setTick] = react_1.useState(0);
89
+ const [, setTick] = react_2.useState(0);
89
90
  const form = useFormState_1.useFormState({
90
91
  config,
91
92
  // That's using a raw init value
@@ -153,7 +154,7 @@ describe("useFormState", () => {
153
154
  ],
154
155
  };
155
156
  // And we start out with data1
156
- const [data, setData] = react_1.useState(data1);
157
+ const [data, setData] = react_2.useState(data1);
157
158
  const form = useFormState_1.useFormState({ config, init: { input: data, map: (d) => d } });
158
159
  function makeLocalChanges() {
159
160
  form.firstName.value = "local";
@@ -229,7 +230,7 @@ describe("useFormState", () => {
229
230
  books: [{ title: "a2" }],
230
231
  };
231
232
  // And we start out with data1
232
- const [data, setData] = react_1.useState(data1);
233
+ const [data, setData] = react_2.useState(data1);
233
234
  const form = useFormState_1.useFormState({
234
235
  config,
235
236
  init: { input: data, map: (d) => d },
@@ -270,9 +271,9 @@ describe("useFormState", () => {
270
271
  const data1 = { firstName: "f1", lastName: "l1" };
271
272
  const data2 = { firstName: "f2", lastName: "l2" };
272
273
  // And we start out with data1
273
- const [data, setData] = react_1.useState(data1);
274
- const author = react_1.useMemo(() => new AuthorRow(data.firstName, data.lastName), [data]);
275
- const config = react_1.useMemo(() => ({
274
+ const [data, setData] = react_2.useState(data1);
275
+ const author = react_2.useMemo(() => new AuthorRow(data.firstName, data.lastName), [data]);
276
+ const config = react_2.useMemo(() => ({
276
277
  firstName: { type: "value" },
277
278
  lastName: { type: "value" },
278
279
  fullName: { type: "value", computed: true },
@@ -296,7 +297,7 @@ describe("useFormState", () => {
296
297
  // Given a component
297
298
  function TestComponent() {
298
299
  // When the data is initially undefined
299
- const [data, setData] = react_1.useState();
300
+ const [data, setData] = react_2.useState();
300
301
  const data2 = { books: [{ title: "Title 1" }] };
301
302
  const form = useFormState_1.useFormState({
302
303
  config: authorWithBooksConfig,
@@ -317,7 +318,7 @@ describe("useFormState", () => {
317
318
  // Then we don't auto-save because nothing has changed
318
319
  expect(autoSave).toBeCalledTimes(0);
319
320
  // And when adding a new book
320
- rtl_utils_1.click(r.add);
321
+ await rtl_utils_1.clickAndWait(r.add);
321
322
  // We autoSave the new row right away (because we don't have any validation rules
322
323
  // that say the new row can't be empty)
323
324
  expect(autoSave).toBeCalledTimes(1);
@@ -345,11 +346,35 @@ describe("useFormState", () => {
345
346
  }
346
347
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
347
348
  // When we change firstName
348
- rtl_utils_1.click(r.setFirst);
349
+ await rtl_utils_1.clickAndWait(r.setFirst);
349
350
  // When autoSave didn't infinite loop
350
351
  expect(autoSaves).toEqual(1);
351
352
  });
352
- it("can queue up changes for auto save if a save is already in progress", async () => {
353
+ it("batches calls to auto-save", async () => {
354
+ // Given a component
355
+ const autoSave = jest.fn();
356
+ function TestComponent() {
357
+ const config = authorConfig;
358
+ const data = { firstName: "f1", lastName: "f1" };
359
+ const form = useFormState_1.useFormState({
360
+ config,
361
+ init: data,
362
+ autoSave: (form) => autoSave(form.changedValue),
363
+ });
364
+ return (jsx_runtime_1.jsx("div", { children: jsx_runtime_1.jsx("button", { "data-testid": "set", onClick: () => {
365
+ // And it sets two fields in a single callback
366
+ form.firstName.set("f2");
367
+ form.lastName.set("l2");
368
+ } }, void 0) }, void 0));
369
+ }
370
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
371
+ // When we change both
372
+ await rtl_utils_1.clickAndWait(r.set);
373
+ // Then we only call autoSave once
374
+ expect(autoSave).toBeCalledTimes(1);
375
+ expect(autoSave).toBeCalledWith({ firstName: "f2", lastName: "l2" });
376
+ });
377
+ it("queues changes for auto save if a save is already in progress", async () => {
353
378
  const autoSaveStub = jest.fn();
354
379
  const config = {
355
380
  id: { type: "value" },
@@ -358,15 +383,17 @@ describe("useFormState", () => {
358
383
  };
359
384
  // Given a component using `getObjectState` for lazily creating ObjectStates
360
385
  function TestComponent() {
361
- const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon", lastName: "Dow" });
386
+ const [apiData, setApiData] = react_2.useState({ id: "a:1", firstName: "Brandon", lastName: "Dow" });
362
387
  const state = useFormState_1.useFormState({ config, autoSave, init: { input: apiData, map: (v) => v } });
363
388
  async function autoSave(form) {
364
389
  autoSaveStub(form.changedValue);
390
+ const changed = form.changedValue;
365
391
  // Pretend to make an API call and update the local state
366
- setApiData((prevState) => ({ ...prevState, ...form.changedValue }));
367
392
  await Promise.resolve(1);
393
+ setApiData((prevState) => ({ ...prevState, ...changed }));
368
394
  }
369
- return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
395
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsxs("div", Object.assign({ "data-testid": "name" }, { children: [apiData.firstName, " ", apiData.lastName] }), void 0),
396
+ jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
370
397
  state.firstName.focus();
371
398
  state.firstName.set("Foo");
372
399
  state.firstName.maybeAutoSave();
@@ -379,10 +406,19 @@ describe("useFormState", () => {
379
406
  } }, void 0)] }, void 0));
380
407
  }
381
408
  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
409
+ // And triggering the auto save behavior before awaiting the initial promise to
410
+ // resolve so we have pending changes.
411
+ rtl_utils_1.click(r.focusSetAndSaveField(), { allowAsync: true });
412
+ // Let the initial autoSave be called
413
+ react_1.act(() => {
414
+ jest.runOnlyPendingTimers();
415
+ });
416
+ expect(r.name()).toHaveTextContent("Brandon Dow");
417
+ expect(autoSaveStub).toBeCalledTimes(1);
418
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
419
+ // And while that is in flight, trigger another user action
420
+ rtl_utils_1.click(r.focusSetAndSaveFieldLastName(), { allowAsync: true });
421
+ // (Use `wait` so that our timer flushes before the Promise.resolve(1) is ran)
386
422
  await rtl_utils_1.wait();
387
423
  // Then expect the auto save to only have been called twice. Once with each changedValues.
388
424
  expect(autoSaveStub).toBeCalledTimes(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -40,16 +40,16 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@babel/core": "^7.13.1",
43
- "@homebound/rtl-utils": "^2.40.0",
43
+ "@homebound/rtl-utils": "^2.59.2",
44
44
  "@semantic-release/git": "^9.0.0",
45
45
  "@storybook/addon-essentials": "^6.2.9",
46
46
  "@storybook/addon-info": "^5.3.21",
47
47
  "@storybook/addon-links": "^6.2.9",
48
48
  "@storybook/addons": "^6.2.9",
49
49
  "@storybook/react": "^6.2.9",
50
- "@testing-library/dom": "^7.31.0",
51
- "@testing-library/jest-dom": "^5.12.0",
52
- "@testing-library/react": "^11.2.7",
50
+ "@testing-library/dom": "^8.11.3",
51
+ "@testing-library/jest-dom": "^5.16.2",
52
+ "@testing-library/react": "^12.1.2",
53
53
  "@tsconfig/recommended": "^1.0.1",
54
54
  "@types/jest": "^26.0.20",
55
55
  "@types/react": "^16.14.5",