@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.
- package/dist/setupTests.js +7 -2
- package/dist/useFormState.d.ts +1 -1
- package/dist/useFormState.js +26 -12
- package/dist/useFormState.test.js +55 -19
- package/package.json +5 -5
package/dist/setupTests.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
jest.useFakeTimers("modern");
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
jest.runOnlyPendingTimers();
|
|
12
|
+
jest.useRealTimers();
|
|
13
|
+
});
|
package/dist/useFormState.d.ts
CHANGED
|
@@ -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<
|
|
40
|
+
autoSave?: (state: ObjectState<T>) => Promise<unknown>;
|
|
41
41
|
};
|
|
42
42
|
/**
|
|
43
43
|
* Creates a formState instance for editing in a form.
|
package/dist/useFormState.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
await
|
|
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
|
|
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] =
|
|
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] =
|
|
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] =
|
|
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] =
|
|
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] =
|
|
274
|
-
const author =
|
|
275
|
-
const config =
|
|
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] =
|
|
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.
|
|
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.
|
|
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("
|
|
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] =
|
|
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.
|
|
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
|
|
383
|
-
|
|
384
|
-
rtl_utils_1.click(r.
|
|
385
|
-
//
|
|
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.
|
|
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.
|
|
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": "^
|
|
51
|
-
"@testing-library/jest-dom": "^5.
|
|
52
|
-
"@testing-library/react": "^
|
|
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",
|