@homebound/form-state 2.12.0 → 2.15.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.
- package/README.md +12 -5
- package/dist/AutoSaveStatus/AutoSaveStatusProvider.d.ts +24 -0
- package/dist/AutoSaveStatus/AutoSaveStatusProvider.js +84 -0
- package/dist/AutoSaveStatus/index.d.ts +2 -0
- package/dist/AutoSaveStatus/index.js +8 -0
- package/dist/AutoSaveStatus/useAutoSaveStatus.d.ts +1 -0
- package/dist/AutoSaveStatus/useAutoSaveStatus.js +9 -0
- package/dist/AutoSaveStatus/useAutoSaveStatus.test.d.ts +1 -0
- package/dist/AutoSaveStatus/useAutoSaveStatus.test.js +152 -0
- package/dist/FormStateApp.js +2 -2
- package/dist/FormStateApp.test.js +2 -2
- package/dist/config.d.ts +2 -0
- package/dist/fields/listField.js +4 -6
- package/dist/fields/objectField.js +6 -5
- package/dist/fields/valueField.d.ts +3 -3
- package/dist/fields/valueField.js +5 -5
- package/dist/formState.test.js +29 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/useFormState.js +9 -0
- package/dist/useFormState.test.js +29 -0
- package/dist/useFormStates.js +9 -0
- package/dist/useFormStates.test.js +26 -0
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +7 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+

|
|
2
|
+
[](https://circleci.com/gh/homebound-team/form-state)
|
|
3
|
+
|
|
1
4
|
# form-state
|
|
2
5
|
|
|
3
6
|
form-state is a headless form state management library, built on top of mobx.
|
|
@@ -7,21 +10,25 @@ It acts as a buffer between the canonical data/entity (i.e. the server-side data
|
|
|
7
10
|
It also keeps track of low-level form UX details like:
|
|
8
11
|
|
|
9
12
|
* Which form fields are dirty
|
|
10
|
-
* Which
|
|
11
|
-
* Which
|
|
13
|
+
* Which form fields are valid/invalid
|
|
14
|
+
* Which form fields are touched (i.e. don't show validation errors for untouched fields)
|
|
12
15
|
* Enabling/disabling buttons/form UX based on the overall form-wide state
|
|
13
16
|
* Submitting the form should touch (validate) all fields
|
|
14
17
|
* Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
|
|
15
|
-
*
|
|
18
|
+
* Queueing auto-saves if one is already in-flight
|
|
19
|
+
* Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
|
|
20
|
+
* Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
|
|
16
21
|
* Building a wire payload that has only changed fields
|
|
17
|
-
*
|
|
22
|
+
* `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
|
|
23
|
+
* Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
|
|
24
|
+
* Child collections can be either exhausive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
|
|
18
25
|
|
|
19
26
|
# The Three Type/Shapes Mental Model
|
|
20
27
|
|
|
21
28
|
In general when working with forms (e.g. not just form-state), there are three types/shapes of data involved:
|
|
22
29
|
|
|
23
30
|
1. The input data/shape from the server (i.e. a GraphQL/REST query)
|
|
24
|
-
2. The form data/shape that is being reactively bound to form fields (i.e. used as `<TextField value=form.firstName onChange=(v) => form.firstName = v />`)
|
|
31
|
+
2. The form data/shape that is being reactively bound to form fields (i.e. used as `<TextField value={form.firstName} onChange={(v) => form.firstName = v} />`)
|
|
25
32
|
3. The mutation data/shape that will submit the change to the server (i.e. the GraphQL mutation/REST POST)
|
|
26
33
|
|
|
27
34
|
form-state generally refers to each of these shapes as:
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React, { PropsWithChildren } from "react";
|
|
2
|
+
export declare enum AutoSaveStatus {
|
|
3
|
+
IDLE = "idle",
|
|
4
|
+
SAVING = "saving",
|
|
5
|
+
DONE = "done",
|
|
6
|
+
ERROR = "error"
|
|
7
|
+
}
|
|
8
|
+
export interface AutoSaveStatusContextType {
|
|
9
|
+
status: AutoSaveStatus;
|
|
10
|
+
/** Resets status to IDLE, particularly useful if "Error" or "Done" is stale */
|
|
11
|
+
resetStatus: VoidFunction;
|
|
12
|
+
errors: unknown[];
|
|
13
|
+
/** Notifies AutoSaveContext that a request is in-flight */
|
|
14
|
+
triggerAutoSave: VoidFunction;
|
|
15
|
+
/** Notifies AutoSaveContext that a request has settled, optionally taking an error */
|
|
16
|
+
resolveAutoSave: (error?: unknown) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare const AutoSaveStatusContext: React.Context<AutoSaveStatusContextType>;
|
|
19
|
+
declare type AutoSaveStatusProviderProps = PropsWithChildren<{
|
|
20
|
+
/** After a successful save, reset Status back to `Idle` after this many milliseconds */
|
|
21
|
+
resetToIdleTimeout?: number;
|
|
22
|
+
}>;
|
|
23
|
+
export declare function AutoSaveStatusProvider({ children, resetToIdleTimeout }: AutoSaveStatusProviderProps): JSX.Element;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
|
5
|
+
}) : (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
o[k2] = m[k];
|
|
8
|
+
}));
|
|
9
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
10
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
11
|
+
}) : function(o, v) {
|
|
12
|
+
o["default"] = v;
|
|
13
|
+
});
|
|
14
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
15
|
+
if (mod && mod.__esModule) return mod;
|
|
16
|
+
var result = {};
|
|
17
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
18
|
+
__setModuleDefault(result, mod);
|
|
19
|
+
return result;
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.AutoSaveStatusProvider = exports.AutoSaveStatusContext = exports.AutoSaveStatus = void 0;
|
|
23
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
24
|
+
const react_1 = __importStar(require("react"));
|
|
25
|
+
var AutoSaveStatus;
|
|
26
|
+
(function (AutoSaveStatus) {
|
|
27
|
+
AutoSaveStatus["IDLE"] = "idle";
|
|
28
|
+
AutoSaveStatus["SAVING"] = "saving";
|
|
29
|
+
AutoSaveStatus["DONE"] = "done";
|
|
30
|
+
AutoSaveStatus["ERROR"] = "error";
|
|
31
|
+
})(AutoSaveStatus = exports.AutoSaveStatus || (exports.AutoSaveStatus = {}));
|
|
32
|
+
exports.AutoSaveStatusContext = react_1.default.createContext({
|
|
33
|
+
status: AutoSaveStatus.IDLE,
|
|
34
|
+
resetStatus() { },
|
|
35
|
+
errors: [],
|
|
36
|
+
triggerAutoSave() { },
|
|
37
|
+
resolveAutoSave() { },
|
|
38
|
+
});
|
|
39
|
+
function AutoSaveStatusProvider({ children, resetToIdleTimeout = 6000 }) {
|
|
40
|
+
const [status, setStatus] = react_1.useState(AutoSaveStatus.IDLE);
|
|
41
|
+
const [errors, setErrors] = react_1.useState([]);
|
|
42
|
+
const [inFlight, setInFlight] = react_1.useState(0);
|
|
43
|
+
const resetToIdleTimeoutRef = react_1.useRef(null);
|
|
44
|
+
/** Handles setting Status */
|
|
45
|
+
react_1.useEffect(() => {
|
|
46
|
+
if (inFlight > 0)
|
|
47
|
+
return setStatus(AutoSaveStatus.SAVING);
|
|
48
|
+
if (status === AutoSaveStatus.IDLE)
|
|
49
|
+
return;
|
|
50
|
+
if (errors.length)
|
|
51
|
+
return setStatus(AutoSaveStatus.ERROR);
|
|
52
|
+
return setStatus(AutoSaveStatus.DONE);
|
|
53
|
+
}, [errors.length, inFlight, status]);
|
|
54
|
+
const triggerAutoSave = react_1.useCallback(() => {
|
|
55
|
+
setInFlight((c) => c + 1);
|
|
56
|
+
setErrors([]);
|
|
57
|
+
}, []);
|
|
58
|
+
const resolveAutoSave = react_1.useCallback((error) => {
|
|
59
|
+
setInFlight((c) => Math.max(0, c - 1));
|
|
60
|
+
if (error)
|
|
61
|
+
setErrors((errs) => errs.concat(error));
|
|
62
|
+
}, []);
|
|
63
|
+
const resetStatus = react_1.useCallback(() => {
|
|
64
|
+
setStatus(AutoSaveStatus.IDLE);
|
|
65
|
+
setErrors([]);
|
|
66
|
+
}, []);
|
|
67
|
+
/** Resets AutoSaveStatus from "Done" to "Idle" after a timeout, if one is provided */
|
|
68
|
+
react_1.useEffect(() => {
|
|
69
|
+
if (resetToIdleTimeout === undefined)
|
|
70
|
+
return;
|
|
71
|
+
// Specifically avoid auto-reset if Errors are present
|
|
72
|
+
if (status !== AutoSaveStatus.DONE)
|
|
73
|
+
return;
|
|
74
|
+
// Only run the latest Timeout
|
|
75
|
+
if (resetToIdleTimeoutRef.current)
|
|
76
|
+
clearTimeout(resetToIdleTimeoutRef.current);
|
|
77
|
+
resetToIdleTimeoutRef.current = window.setTimeout(() => {
|
|
78
|
+
resetStatus();
|
|
79
|
+
resetToIdleTimeoutRef.current = null;
|
|
80
|
+
}, resetToIdleTimeout);
|
|
81
|
+
}, [resetStatus, resetToIdleTimeout, status]);
|
|
82
|
+
return (jsx_runtime_1.jsx(exports.AutoSaveStatusContext.Provider, Object.assign({ value: { status, resetStatus, errors, triggerAutoSave, resolveAutoSave } }, { children: children }), void 0));
|
|
83
|
+
}
|
|
84
|
+
exports.AutoSaveStatusProvider = AutoSaveStatusProvider;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useAutoSaveStatus = exports.AutoSaveStatusProvider = exports.AutoSaveStatusContext = void 0;
|
|
4
|
+
var AutoSaveStatusProvider_1 = require("./AutoSaveStatusProvider");
|
|
5
|
+
Object.defineProperty(exports, "AutoSaveStatusContext", { enumerable: true, get: function () { return AutoSaveStatusProvider_1.AutoSaveStatusContext; } });
|
|
6
|
+
Object.defineProperty(exports, "AutoSaveStatusProvider", { enumerable: true, get: function () { return AutoSaveStatusProvider_1.AutoSaveStatusProvider; } });
|
|
7
|
+
var useAutoSaveStatus_1 = require("./useAutoSaveStatus");
|
|
8
|
+
Object.defineProperty(exports, "useAutoSaveStatus", { enumerable: true, get: function () { return useAutoSaveStatus_1.useAutoSaveStatus; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useAutoSaveStatus(): import("./AutoSaveStatusProvider").AutoSaveStatusContextType;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useAutoSaveStatus = void 0;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const AutoSaveStatusProvider_1 = require("./AutoSaveStatusProvider");
|
|
6
|
+
function useAutoSaveStatus() {
|
|
7
|
+
return react_1.useContext(AutoSaveStatusProvider_1.AutoSaveStatusContext);
|
|
8
|
+
}
|
|
9
|
+
exports.useAutoSaveStatus = useAutoSaveStatus;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const react_hooks_1 = require("@testing-library/react-hooks");
|
|
5
|
+
const AutoSaveStatusProvider_1 = require("./AutoSaveStatusProvider");
|
|
6
|
+
const useAutoSaveStatus_1 = require("./useAutoSaveStatus");
|
|
7
|
+
describe(useAutoSaveStatus_1.useAutoSaveStatus, () => {
|
|
8
|
+
/** The internal setTimeout running after tests is spamming the console, so cancel them all here */
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
jest.clearAllTimers();
|
|
11
|
+
});
|
|
12
|
+
it("renders without a provider", () => {
|
|
13
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus());
|
|
14
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
15
|
+
});
|
|
16
|
+
it("renders with a provider", () => {
|
|
17
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
18
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
19
|
+
});
|
|
20
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
21
|
+
});
|
|
22
|
+
it("indicates when something is in-flight", () => {
|
|
23
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
24
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
25
|
+
});
|
|
26
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
27
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.SAVING);
|
|
28
|
+
});
|
|
29
|
+
it("indicates when a request has settled", () => {
|
|
30
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
31
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
32
|
+
});
|
|
33
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
34
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
35
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
36
|
+
});
|
|
37
|
+
it("indicates when an error happened", () => {
|
|
38
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
39
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
40
|
+
});
|
|
41
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
42
|
+
react_hooks_1.act(() => result.current.resolveAutoSave(new Error("Some error")));
|
|
43
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.ERROR);
|
|
44
|
+
expect(result.current.errors.length).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
it("resets status to Idle when told to", () => {
|
|
47
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
48
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
49
|
+
});
|
|
50
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
51
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
52
|
+
react_hooks_1.act(() => result.current.resetStatus());
|
|
53
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
54
|
+
});
|
|
55
|
+
it("status goes through the full lifecycle when passed a reset timeout", async () => {
|
|
56
|
+
// Given a timeout has been passed to `useAutoSave()`
|
|
57
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
58
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, Object.assign({ resetToIdleTimeout: 1000 }, { children: children }), void 0),
|
|
59
|
+
});
|
|
60
|
+
// When we trigger a save
|
|
61
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
62
|
+
// Then status is Saving
|
|
63
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.SAVING);
|
|
64
|
+
// And when we trigger a resolution
|
|
65
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
66
|
+
// Then status is Done
|
|
67
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
68
|
+
// But when the timer runs out
|
|
69
|
+
react_hooks_1.act(() => {
|
|
70
|
+
jest.runOnlyPendingTimers();
|
|
71
|
+
});
|
|
72
|
+
// Then the status is reset to Idle
|
|
73
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
74
|
+
});
|
|
75
|
+
it("clears errors on reset status", () => {
|
|
76
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
77
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
78
|
+
});
|
|
79
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
80
|
+
react_hooks_1.act(() => result.current.resolveAutoSave(new Error("some error")));
|
|
81
|
+
expect(result.current.errors.length).toBe(1);
|
|
82
|
+
react_hooks_1.act(() => result.current.resetStatus());
|
|
83
|
+
expect(result.current.errors.length).toBe(0);
|
|
84
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
85
|
+
});
|
|
86
|
+
it("does not automatically invoke reset timeout if there are errors", () => {
|
|
87
|
+
// Given a timeout has been passed to `useAutoSave()`
|
|
88
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
89
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
90
|
+
});
|
|
91
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
92
|
+
react_hooks_1.act(() => result.current.resolveAutoSave(new Error("Some error")));
|
|
93
|
+
react_hooks_1.act(() => {
|
|
94
|
+
jest.runOnlyPendingTimers();
|
|
95
|
+
});
|
|
96
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.ERROR);
|
|
97
|
+
expect(result.current.errors.length).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
it("does allow manual resetting even if there are errors", () => {
|
|
100
|
+
// Given a timeout has been passed to `useAutoSave()`
|
|
101
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
102
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
103
|
+
});
|
|
104
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
105
|
+
react_hooks_1.act(() => result.current.resolveAutoSave(new Error("Some error")));
|
|
106
|
+
react_hooks_1.act(() => {
|
|
107
|
+
jest.runOnlyPendingTimers();
|
|
108
|
+
});
|
|
109
|
+
react_hooks_1.act(() => result.current.resetStatus());
|
|
110
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
111
|
+
expect(result.current.errors.length).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
it("handles multiple in-flight requests", () => {
|
|
114
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
115
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
116
|
+
});
|
|
117
|
+
// When we trigger 2 AutoSaves and only resolve 1
|
|
118
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
119
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
120
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
121
|
+
// We expect something to still be in-flight
|
|
122
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.SAVING);
|
|
123
|
+
// And when we resolve the final one
|
|
124
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
125
|
+
// We expect it to finally settle
|
|
126
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
127
|
+
});
|
|
128
|
+
it("clears errors when a new save is triggered", () => {
|
|
129
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
130
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
131
|
+
});
|
|
132
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
133
|
+
react_hooks_1.act(() => result.current.resolveAutoSave(new Error("some error")));
|
|
134
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
135
|
+
expect(result.current.errors.length).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
it("handles calling resolve too much", () => {
|
|
138
|
+
const { result } = react_hooks_1.renderHook(() => useAutoSaveStatus_1.useAutoSaveStatus(), {
|
|
139
|
+
wrapper: ({ children }) => jsx_runtime_1.jsx(AutoSaveStatusProvider_1.AutoSaveStatusProvider, { children: children }, void 0),
|
|
140
|
+
});
|
|
141
|
+
// When save hasn't been invoked yet
|
|
142
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
143
|
+
// Then we effectively didn't run
|
|
144
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
145
|
+
// And when 1 save has triggered, and we resolve too much
|
|
146
|
+
react_hooks_1.act(() => result.current.triggerAutoSave());
|
|
147
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
148
|
+
react_hooks_1.act(() => result.current.resolveAutoSave());
|
|
149
|
+
// Then we expect it to be happily "Done"
|
|
150
|
+
expect(result.current.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/FormStateApp.js
CHANGED
|
@@ -52,8 +52,8 @@ function FormStateApp() {
|
|
|
52
52
|
jsx_runtime_1.jsx("td", { children: formState.valid.toString() }, void 0),
|
|
53
53
|
jsx_runtime_1.jsx("td", { children: formState.dirty.toString() }, void 0)] }, void 0) }, void 0)] }), void 0),
|
|
54
54
|
jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "touch", onClick: () => (formState.touched = !formState.touched) }, { children: "touch" }), void 0),
|
|
55
|
-
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "
|
|
56
|
-
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "
|
|
55
|
+
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "revertChanges", onClick: () => formState.revertChanges() }, { children: "revert" }), void 0),
|
|
56
|
+
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "commitChanges", onClick: () => formState.commitChanges() }, { children: "commit" }), void 0),
|
|
57
57
|
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "set", onClick: () => formState.set({ firstName: "a2" }) }, { children: "set" }), void 0)] }, void 0)] }, void 0)] }), void 0) }), void 0));
|
|
58
58
|
} }, void 0));
|
|
59
59
|
}
|
|
@@ -12,7 +12,7 @@ describe("FormStateApp", () => {
|
|
|
12
12
|
expect(r.firstName_dirty()).toHaveTextContent("true");
|
|
13
13
|
react_1.fireEvent.blur(r.firstName());
|
|
14
14
|
expect(r.firstName_touched()).toHaveTextContent("true");
|
|
15
|
-
rtl_utils_1.click(r.
|
|
15
|
+
rtl_utils_1.click(r.commitChanges);
|
|
16
16
|
expect(r.firstName_dirty()).toHaveTextContent("false");
|
|
17
17
|
expect(r.firstName_touched()).toHaveTextContent("false");
|
|
18
18
|
});
|
|
@@ -20,7 +20,7 @@ describe("FormStateApp", () => {
|
|
|
20
20
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
|
|
21
21
|
expect(r.firstName_original()).toHaveTextContent("a1");
|
|
22
22
|
rtl_utils_1.click(r.set);
|
|
23
|
-
rtl_utils_1.click(r.
|
|
23
|
+
rtl_utils_1.click(r.commitChanges);
|
|
24
24
|
expect(r.firstName_original()).toHaveTextContent("a2");
|
|
25
25
|
});
|
|
26
26
|
});
|
package/dist/config.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ export declare type ValueFieldConfig<T, V> = {
|
|
|
37
37
|
computed?: boolean;
|
|
38
38
|
/** Marks a field as being initially read-only, i.e. `field.readOnly = true/false` can change this default. */
|
|
39
39
|
readOnly?: boolean;
|
|
40
|
+
/** Marks an array field to be order agnostic when determining changed/dirty states */
|
|
41
|
+
strictOrder?: false;
|
|
40
42
|
};
|
|
41
43
|
/** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
|
|
42
44
|
export declare type ListFieldConfig<T, U> = {
|
package/dist/fields/listField.js
CHANGED
|
@@ -184,16 +184,14 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
184
184
|
_tick.value++;
|
|
185
185
|
maybeAutoSave();
|
|
186
186
|
},
|
|
187
|
-
|
|
187
|
+
revertChanges() {
|
|
188
188
|
if (originalCopy) {
|
|
189
189
|
this.set(originalCopy, { resetting: true });
|
|
190
|
-
this.rows.forEach((r) => r.
|
|
190
|
+
this.rows.forEach((r) => r.revertChanges());
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
|
-
|
|
194
|
-
this.rows.forEach((r) =>
|
|
195
|
-
r.save();
|
|
196
|
-
});
|
|
193
|
+
commitChanges() {
|
|
194
|
+
this.rows.forEach((r) => r.commitChanges());
|
|
197
195
|
originalCopy = [...(parentInstance[key] || [])];
|
|
198
196
|
_tick.value++;
|
|
199
197
|
},
|
|
@@ -29,11 +29,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
29
29
|
return proxy;
|
|
30
30
|
}
|
|
31
31
|
const fieldStates = Object.entries(config).map(([_key, _config]) => {
|
|
32
|
+
var _a;
|
|
32
33
|
const key = _key;
|
|
33
34
|
const config = _config;
|
|
34
35
|
let field;
|
|
35
36
|
if (config.type === "value") {
|
|
36
|
-
field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
|
|
37
|
+
field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, (_a = config.strictOrder) !== null && _a !== void 0 ? _a : true, maybeAutoSave);
|
|
37
38
|
}
|
|
38
39
|
else if (config.type === "list") {
|
|
39
40
|
field = listField_1.newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
|
|
@@ -132,12 +133,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
132
133
|
});
|
|
133
134
|
},
|
|
134
135
|
// Resets all fields back to their original values
|
|
135
|
-
|
|
136
|
-
getFields(this).forEach((f) => f.
|
|
136
|
+
revertChanges() {
|
|
137
|
+
getFields(this).forEach((f) => f.revertChanges());
|
|
137
138
|
},
|
|
138
139
|
// Saves all current values into _originalValue
|
|
139
|
-
|
|
140
|
-
getFields(this).forEach((f) => f.
|
|
140
|
+
commitChanges() {
|
|
141
|
+
getFields(this).forEach((f) => f.commitChanges());
|
|
141
142
|
},
|
|
142
143
|
// Create a result that is only populated with changed keys
|
|
143
144
|
get changedValue() {
|
|
@@ -33,9 +33,9 @@ export interface FieldState<T, V> {
|
|
|
33
33
|
maybeAutoSave(): void;
|
|
34
34
|
set(value: V, opts?: SetOpts): void;
|
|
35
35
|
/** Reverts back to the original value and resets dirty/touched. */
|
|
36
|
-
|
|
36
|
+
revertChanges(): void;
|
|
37
37
|
/** Accepts the current changed value (if any) as the original and resets dirty/touched. */
|
|
38
|
-
|
|
38
|
+
commitChanges(): void;
|
|
39
39
|
}
|
|
40
40
|
/** Public options for our `set` command. */
|
|
41
41
|
export interface SetOpts {
|
|
@@ -56,4 +56,4 @@ export interface FieldStateInternal<T, V> extends FieldState<T, V> {
|
|
|
56
56
|
_isReadOnlyKey: boolean;
|
|
57
57
|
_focused: boolean;
|
|
58
58
|
}
|
|
59
|
-
export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
|
|
59
|
+
export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
|
|
@@ -5,7 +5,7 @@ const is_plain_object_1 = require("is-plain-object");
|
|
|
5
5
|
const mobx_1 = require("mobx");
|
|
6
6
|
const rules_1 = require("../rules");
|
|
7
7
|
const utils_1 = require("../utils");
|
|
8
|
-
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
|
|
8
|
+
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
|
|
9
9
|
// keep a copy here for reference equality
|
|
10
10
|
const value = parentInstance[key];
|
|
11
11
|
let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
|
|
@@ -35,7 +35,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
35
35
|
this.set(v);
|
|
36
36
|
},
|
|
37
37
|
get dirty() {
|
|
38
|
-
return !utils_1.areEqual(this.originalValue, this.value);
|
|
38
|
+
return !utils_1.areEqual(this.originalValue, this.value, strictOrder);
|
|
39
39
|
},
|
|
40
40
|
/** Returns whether this field is readOnly, although if our parent is readOnly then it trumps. */
|
|
41
41
|
get readOnly() {
|
|
@@ -106,7 +106,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
106
106
|
const coerceEmptyList = value && value instanceof Array && value.length === 0 && utils_1.isEmpty(this.originalValue);
|
|
107
107
|
const newValue = keepNull ? null : utils_1.isEmpty(value) || coerceEmptyList ? undefined : value;
|
|
108
108
|
// Set the value on our parent object
|
|
109
|
-
const changed = !utils_1.areEqual(newValue, this.value);
|
|
109
|
+
const changed = !utils_1.areEqual(newValue, this.value, strictOrder);
|
|
110
110
|
parentInstance[key] = newValue;
|
|
111
111
|
_tick.value++;
|
|
112
112
|
if (opts.refreshing) {
|
|
@@ -118,13 +118,13 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
118
118
|
this.maybeAutoSave();
|
|
119
119
|
}
|
|
120
120
|
},
|
|
121
|
-
|
|
121
|
+
revertChanges() {
|
|
122
122
|
if (!computed) {
|
|
123
123
|
this.set(this.originalValue, { resetting: true });
|
|
124
124
|
}
|
|
125
125
|
this.touched = false;
|
|
126
126
|
},
|
|
127
|
-
|
|
127
|
+
commitChanges() {
|
|
128
128
|
if (is_plain_object_1.isPlainObject(this.originalValue)) {
|
|
129
129
|
this.originalValue = mobx_1.toJS(this.value);
|
|
130
130
|
}
|
package/dist/formState.test.js
CHANGED
|
@@ -452,7 +452,7 @@ describe("formState", () => {
|
|
|
452
452
|
a1.set({ firstName: "first" });
|
|
453
453
|
expect(maybeAutoSave).toBeCalledTimes(1);
|
|
454
454
|
// When we reset
|
|
455
|
-
a1.
|
|
455
|
+
a1.revertChanges();
|
|
456
456
|
// We don't call blur again
|
|
457
457
|
expect(maybeAutoSave).toBeCalledTimes(1);
|
|
458
458
|
});
|
|
@@ -487,6 +487,24 @@ describe("formState", () => {
|
|
|
487
487
|
a1.favoriteColors.set([]);
|
|
488
488
|
expect(a1.dirty).toBeFalsy();
|
|
489
489
|
});
|
|
490
|
+
it("can evaluate arrays to be equal even if order of options are different from original when strictOrder = false", () => {
|
|
491
|
+
// Given an array field value where we define `strictOrder` as false
|
|
492
|
+
const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value", strictOrder: false } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
|
|
493
|
+
expect(a1.favoriteColors.dirty).toBeFalsy();
|
|
494
|
+
// When changing the order of the values
|
|
495
|
+
a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
|
|
496
|
+
// Then expect the field to not be dirty
|
|
497
|
+
expect(a1.dirty).toBeFalsy();
|
|
498
|
+
});
|
|
499
|
+
it("evaluates arrays to be equal based on order by default", () => {
|
|
500
|
+
// Given an array field value
|
|
501
|
+
const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value" } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
|
|
502
|
+
expect(a1.favoriteColors.dirty).toBeFalsy();
|
|
503
|
+
// When we change the order of the values
|
|
504
|
+
a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
|
|
505
|
+
// Then the field should be dirty
|
|
506
|
+
expect(a1.dirty).toBeTruthy();
|
|
507
|
+
});
|
|
490
508
|
it("knows originally unset fields are dirty", () => {
|
|
491
509
|
// Given firstName is purposefully not set when originally initialized
|
|
492
510
|
const a1 = createAuthorInputState({});
|
|
@@ -541,7 +559,7 @@ describe("formState", () => {
|
|
|
541
559
|
a1.books.add({ title: "b3" });
|
|
542
560
|
expect(a1.books.touched).toEqual(true);
|
|
543
561
|
expect(a1.dirty).toBeTruthy();
|
|
544
|
-
a1.
|
|
562
|
+
a1.revertChanges();
|
|
545
563
|
expect(a1.firstName.value).toBe("a1");
|
|
546
564
|
expect(a1.firstName.touched).toBeFalsy();
|
|
547
565
|
expect(a1.lastName.value).toBe("aL1");
|
|
@@ -577,7 +595,7 @@ describe("formState", () => {
|
|
|
577
595
|
// And the "readOnly=true" operation has "beat" the reset operation
|
|
578
596
|
a1.readOnly = true;
|
|
579
597
|
// When we reset
|
|
580
|
-
a1.
|
|
598
|
+
a1.revertChanges();
|
|
581
599
|
// Then it still works
|
|
582
600
|
expect(a1.firstName.value).toBe("a1");
|
|
583
601
|
expect(a1.firstName.touched).toBeFalsy();
|
|
@@ -607,15 +625,15 @@ describe("formState", () => {
|
|
|
607
625
|
a1.books.rows[1].set({ title: "bb3" });
|
|
608
626
|
// verify ValueFieldState is dirty, then save, then no longer dirty.
|
|
609
627
|
expect(a1.firstName.dirty).toBeTruthy();
|
|
610
|
-
a1.firstName.
|
|
628
|
+
a1.firstName.commitChanges();
|
|
611
629
|
expect(a1.firstName.dirty).toBeFalsy();
|
|
612
630
|
// verify ListFieldState is dirty, then save, then no longer dirty.
|
|
613
631
|
expect(a1.books.dirty).toBeTruthy();
|
|
614
|
-
a1.books.
|
|
632
|
+
a1.books.commitChanges();
|
|
615
633
|
expect(a1.books.dirty).toBeFalsy();
|
|
616
634
|
// Verify the remaining form is still dirty
|
|
617
635
|
expect(a1.dirty).toBeTruthy();
|
|
618
|
-
a1.
|
|
636
|
+
a1.commitChanges();
|
|
619
637
|
// Verify after save the whole form is no longer dirty.
|
|
620
638
|
expect(a1.dirty).toBeFalsy();
|
|
621
639
|
});
|
|
@@ -839,7 +857,7 @@ describe("formState", () => {
|
|
|
839
857
|
expect(a.books.rows[1].readOnly).toBeFalsy();
|
|
840
858
|
expect(a.books.rows[1].title.readOnly).toBeFalsy();
|
|
841
859
|
// And reset does not blow up
|
|
842
|
-
a.
|
|
860
|
+
a.revertChanges();
|
|
843
861
|
});
|
|
844
862
|
it("can set nested values when original null", () => {
|
|
845
863
|
const a = createAuthorWithAddressInputState({ address: null });
|
|
@@ -894,7 +912,7 @@ describe("formState", () => {
|
|
|
894
912
|
expect(formState.fullName.value).toEqual("first last");
|
|
895
913
|
formState.firstName.value = "change";
|
|
896
914
|
expect(formState.fullName.value).toEqual("change last");
|
|
897
|
-
formState.
|
|
915
|
+
formState.revertChanges();
|
|
898
916
|
expect(formState.firstName.value).toEqual("first");
|
|
899
917
|
});
|
|
900
918
|
it("can set computeds that have setters", () => {
|
|
@@ -907,7 +925,7 @@ describe("formState", () => {
|
|
|
907
925
|
expect(formState.firstName.value).toEqual("Bob");
|
|
908
926
|
formState.fullName.set("Fred Smith");
|
|
909
927
|
expect(formState.firstName.value).toEqual("Fred");
|
|
910
|
-
formState.
|
|
928
|
+
formState.revertChanges();
|
|
911
929
|
expect(formState.firstName.value).toEqual("first");
|
|
912
930
|
});
|
|
913
931
|
it("can mark fields as read only", () => {
|
|
@@ -920,7 +938,7 @@ describe("formState", () => {
|
|
|
920
938
|
expect(formState.fullName.readOnly).toEqual(true);
|
|
921
939
|
formState.firstName.value = "change";
|
|
922
940
|
expect(formState.fullName.value).toEqual("change last");
|
|
923
|
-
formState.
|
|
941
|
+
formState.revertChanges();
|
|
924
942
|
expect(formState.firstName.value).toEqual("first");
|
|
925
943
|
});
|
|
926
944
|
it("lets rules validate against other fields", () => {
|
|
@@ -1300,7 +1318,7 @@ describe("formState", () => {
|
|
|
1300
1318
|
// Then the list state is dirty
|
|
1301
1319
|
expect(a1.books.dirty).toBeTruthy();
|
|
1302
1320
|
// When saving the form state
|
|
1303
|
-
a1.
|
|
1321
|
+
a1.commitChanges();
|
|
1304
1322
|
// Then the list is no longer dirty
|
|
1305
1323
|
expect(a1.books.dirty).toBeFalsy();
|
|
1306
1324
|
// When adding a new book
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
|
5
|
+
}) : (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
o[k2] = m[k];
|
|
8
|
+
}));
|
|
9
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
10
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
11
|
+
};
|
|
2
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
13
|
exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
|
|
4
14
|
var objectField_1 = require("./fields/objectField");
|
|
@@ -9,3 +19,4 @@ var useFormState_1 = require("./useFormState");
|
|
|
9
19
|
Object.defineProperty(exports, "useFormState", { enumerable: true, get: function () { return useFormState_1.useFormState; } });
|
|
10
20
|
var useFormStates_1 = require("./useFormStates");
|
|
11
21
|
Object.defineProperty(exports, "useFormStates", { enumerable: true, get: function () { return useFormStates_1.useFormStates; } });
|
|
22
|
+
__exportStar(require("./AutoSaveStatus/index"), exports);
|
package/dist/useFormState.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.useFormState = void 0;
|
|
|
4
4
|
const is_plain_object_1 = require("is-plain-object");
|
|
5
5
|
const react_1 = require("react");
|
|
6
6
|
const objectField_1 = require("./fields/objectField");
|
|
7
|
+
const AutoSaveStatus_1 = require("./AutoSaveStatus/index");
|
|
7
8
|
const utils_1 = require("./utils");
|
|
8
9
|
// If the user's autoSave hook makes some last-minute `.set` calls to sneak
|
|
9
10
|
// in some business logic right before their GraphQL mutation call, ignore it
|
|
@@ -17,6 +18,7 @@ let pendingAutoSave = false;
|
|
|
17
18
|
*/
|
|
18
19
|
function useFormState(opts) {
|
|
19
20
|
const { config, init, addRules, readOnly = false, loading, autoSave } = opts;
|
|
21
|
+
const autoSaveStatusContext = AutoSaveStatus_1.useAutoSaveStatus();
|
|
20
22
|
// Use a ref so our memo'ized `onBlur` always see the latest value
|
|
21
23
|
const autoSaveRef = react_1.useRef(autoSave);
|
|
22
24
|
autoSaveRef.current = autoSave;
|
|
@@ -37,6 +39,7 @@ function useFormState(opts) {
|
|
|
37
39
|
// Don't use canSave() because we don't want to set touched for all the fields
|
|
38
40
|
if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
|
|
39
41
|
isAutoSaving = "queued";
|
|
42
|
+
let maybeError;
|
|
40
43
|
// We use setTimeout as a cheap way to wait until the end of the current event listener
|
|
41
44
|
setTimeout(async () => {
|
|
42
45
|
try {
|
|
@@ -44,10 +47,16 @@ function useFormState(opts) {
|
|
|
44
47
|
// user's autoSave function itself wants to call a .set.
|
|
45
48
|
const promise = autoSaveRef.current(form);
|
|
46
49
|
isAutoSaving = "in-flight";
|
|
50
|
+
autoSaveStatusContext.triggerAutoSave();
|
|
47
51
|
await promise;
|
|
48
52
|
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
maybeError = String(e);
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
49
57
|
finally {
|
|
50
58
|
isAutoSaving = false;
|
|
59
|
+
autoSaveStatusContext.resolveAutoSave(maybeError === null || maybeError === void 0 ? void 0 : maybeError.toString());
|
|
51
60
|
if (pendingAutoSave) {
|
|
52
61
|
pendingAutoSave = false;
|
|
53
62
|
// Push out the follow-up by 1 tick to allow refreshes to happen to potentially
|
|
@@ -3,11 +3,14 @@ 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
5
|
const react_1 = require("@testing-library/react");
|
|
6
|
+
const react_hooks_1 = require("@testing-library/react-hooks");
|
|
6
7
|
const mobx_1 = require("mobx");
|
|
7
8
|
const mobx_react_1 = require("mobx-react");
|
|
8
9
|
const react_2 = require("react");
|
|
9
10
|
const FormStateApp_1 = require("./FormStateApp");
|
|
10
11
|
const rules_1 = require("./rules");
|
|
12
|
+
const AutoSaveStatus_1 = require("./AutoSaveStatus/index");
|
|
13
|
+
const AutoSaveStatusProvider_1 = require("./AutoSaveStatus/AutoSaveStatusProvider");
|
|
11
14
|
const useFormState_1 = require("./useFormState");
|
|
12
15
|
describe("useFormState", () => {
|
|
13
16
|
it("calls init.map if init.input is defined", async () => {
|
|
@@ -499,6 +502,32 @@ describe("useFormState", () => {
|
|
|
499
502
|
// Then the form is marked as not loading
|
|
500
503
|
expect(r.loading()).toHaveTextContent("false");
|
|
501
504
|
});
|
|
505
|
+
it("updates AutoSaveStatusProvider when autoSaving", async () => {
|
|
506
|
+
const config = { firstName: { type: "value" } };
|
|
507
|
+
const { result } = react_hooks_1.renderHook(() => ({
|
|
508
|
+
fs: useFormState_1.useFormState({ config, autoSave: async () => { } }),
|
|
509
|
+
autoSave: AutoSaveStatus_1.useAutoSaveStatus(),
|
|
510
|
+
}), {
|
|
511
|
+
wrapper: ({ children }) => (jsx_runtime_1.jsx(AutoSaveStatus_1.AutoSaveStatusProvider, { children: jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => jsx_runtime_1.jsx(jsx_runtime_1.Fragment, { children: children }, void 0) }, void 0) }, void 0)),
|
|
512
|
+
});
|
|
513
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
514
|
+
// When we invoke a change and autoSave fires
|
|
515
|
+
react_hooks_1.act(() => {
|
|
516
|
+
result.current.fs.set({ firstName: "test" });
|
|
517
|
+
});
|
|
518
|
+
react_hooks_1.act(() => {
|
|
519
|
+
// useFormState has a setTimeout(..., 0) that needs to run
|
|
520
|
+
jest.advanceTimersToNextTimer();
|
|
521
|
+
});
|
|
522
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.SAVING);
|
|
523
|
+
// Idk why this needs to be async but it doesn't work otherwise
|
|
524
|
+
await react_hooks_1.act(async () => {
|
|
525
|
+
jest.runAllTimers();
|
|
526
|
+
});
|
|
527
|
+
// Then we expect AutoSaveStatus to be notiied of Doneness
|
|
528
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
529
|
+
jest.clearAllTimers();
|
|
530
|
+
});
|
|
502
531
|
});
|
|
503
532
|
const authorConfig = {
|
|
504
533
|
id: { type: "value" },
|
package/dist/useFormStates.js
CHANGED
|
@@ -4,6 +4,7 @@ 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
|
+
const AutoSaveStatus_1 = require("./AutoSaveStatus/index");
|
|
7
8
|
function useFormStates(opts) {
|
|
8
9
|
const { config, autoSave, getId, map, addRules, readOnly = false } = opts;
|
|
9
10
|
const objectStateCache = react_1.useMemo(() => ({}),
|
|
@@ -18,6 +19,7 @@ function useFormStates(opts) {
|
|
|
18
19
|
// Use a ref b/c we're memod
|
|
19
20
|
const readOnlyRef = react_1.useRef(readOnly);
|
|
20
21
|
readOnlyRef.current = readOnly;
|
|
22
|
+
const autoSaveStatusContext = AutoSaveStatus_1.useAutoSaveStatus();
|
|
21
23
|
const getFormState = react_1.useCallback((input, opts = {}) => {
|
|
22
24
|
const existing = objectStateCache[getId(input)];
|
|
23
25
|
let form = existing === null || existing === void 0 ? void 0 : existing[0];
|
|
@@ -29,16 +31,23 @@ function useFormStates(opts) {
|
|
|
29
31
|
pending.add(form);
|
|
30
32
|
return;
|
|
31
33
|
}
|
|
34
|
+
let maybeError;
|
|
32
35
|
try {
|
|
33
36
|
isAutoSaving = true;
|
|
37
|
+
autoSaveStatusContext.triggerAutoSave();
|
|
34
38
|
// See if we have any reactions that want to run (i.e. added by addRules hooks)
|
|
35
39
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
36
40
|
// If a reaction re-queued our form during the ^ wait, remove it
|
|
37
41
|
pending.delete(form);
|
|
38
42
|
await autoSaveRef.current(form);
|
|
39
43
|
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
maybeError = String(e);
|
|
46
|
+
throw e;
|
|
47
|
+
}
|
|
40
48
|
finally {
|
|
41
49
|
isAutoSaving = false;
|
|
50
|
+
autoSaveStatusContext.resolveAutoSave(maybeError);
|
|
42
51
|
if (pending.size > 0) {
|
|
43
52
|
const first = pending.values().next().value;
|
|
44
53
|
pending.delete(first);
|
|
@@ -2,11 +2,15 @@
|
|
|
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_hooks_1 = require("@testing-library/react-hooks");
|
|
5
6
|
const mobx_1 = require("mobx");
|
|
7
|
+
const mobx_react_1 = require("mobx-react");
|
|
6
8
|
const react_1 = require("react");
|
|
7
9
|
const FormStateApp_1 = require("./FormStateApp");
|
|
8
10
|
const rules_1 = require("./rules");
|
|
9
11
|
const useFormStates_1 = require("./useFormStates");
|
|
12
|
+
const AutoSaveStatus_1 = require("./AutoSaveStatus/index");
|
|
13
|
+
const AutoSaveStatusProvider_1 = require("./AutoSaveStatus/AutoSaveStatusProvider");
|
|
10
14
|
describe("useFormStates", () => {
|
|
11
15
|
it("can lazily create form states", async () => {
|
|
12
16
|
const autoSave = jest.fn();
|
|
@@ -218,6 +222,28 @@ describe("useFormStates", () => {
|
|
|
218
222
|
// Then it's not read only
|
|
219
223
|
expect(r.firstName()).toHaveAttribute("data-readonly", "false");
|
|
220
224
|
});
|
|
225
|
+
it("notifies AutoSaveStatusProvider of updates", async () => {
|
|
226
|
+
const { result } = react_hooks_1.renderHook(() => ({
|
|
227
|
+
fs: useFormStates_1.useFormStates({ config, autoSave: async () => { }, getId: (o) => o.id }),
|
|
228
|
+
autoSave: AutoSaveStatus_1.useAutoSaveStatus(),
|
|
229
|
+
}), {
|
|
230
|
+
wrapper: ({ children }) => (jsx_runtime_1.jsx(AutoSaveStatus_1.AutoSaveStatusProvider, { children: jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => jsx_runtime_1.jsx(jsx_runtime_1.Fragment, { children: children }, void 0) }, void 0) }, void 0)),
|
|
231
|
+
});
|
|
232
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.IDLE);
|
|
233
|
+
react_hooks_1.act(() => {
|
|
234
|
+
result.current.fs.getFormState({ id: "a:1" }).set({ firstName: "test" });
|
|
235
|
+
});
|
|
236
|
+
react_hooks_1.act(() => {
|
|
237
|
+
// useFormStates has a setTimeout(..., 0) that needs to trigger
|
|
238
|
+
jest.advanceTimersToNextTimer();
|
|
239
|
+
});
|
|
240
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.SAVING);
|
|
241
|
+
await react_hooks_1.act(async () => {
|
|
242
|
+
jest.runAllTimers();
|
|
243
|
+
});
|
|
244
|
+
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
245
|
+
jest.clearAllTimers();
|
|
246
|
+
});
|
|
221
247
|
});
|
|
222
248
|
const config = {
|
|
223
249
|
id: { type: "value" },
|
package/dist/utils.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export declare function isEmpty(value: any): boolean;
|
|
|
37
37
|
* - objects that implement `toJSON`
|
|
38
38
|
*
|
|
39
39
|
*/
|
|
40
|
-
export declare function areEqual<T>(a?: T, b?: T): boolean;
|
|
40
|
+
export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolean;
|
|
41
41
|
export declare function hasToJSON(o?: unknown): o is {
|
|
42
42
|
toJSON(): void;
|
|
43
43
|
};
|
package/dist/utils.js
CHANGED
|
@@ -102,7 +102,7 @@ exports.isEmpty = isEmpty;
|
|
|
102
102
|
* - objects that implement `toJSON`
|
|
103
103
|
*
|
|
104
104
|
*/
|
|
105
|
-
function areEqual(a, b) {
|
|
105
|
+
function areEqual(a, b, strictOrder) {
|
|
106
106
|
if (is_plain_object_1.isPlainObject(a)) {
|
|
107
107
|
return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
|
|
108
108
|
}
|
|
@@ -112,7 +112,12 @@ function areEqual(a, b) {
|
|
|
112
112
|
return fast_deep_equal_1.default(a1, b1);
|
|
113
113
|
}
|
|
114
114
|
if (a && b && a instanceof Array && b instanceof Array) {
|
|
115
|
-
|
|
115
|
+
if (strictOrder !== false) {
|
|
116
|
+
return fast_deep_equal_1.default(a, b);
|
|
117
|
+
}
|
|
118
|
+
if (a.length !== b.length)
|
|
119
|
+
return false;
|
|
120
|
+
return a.every((a1) => b.some((b1) => fast_deep_equal_1.default(a1, b1)));
|
|
116
121
|
}
|
|
117
122
|
return a === b;
|
|
118
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.0",
|
|
4
4
|
"author": "Homebound",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"@testing-library/dom": "^8.11.3",
|
|
52
52
|
"@testing-library/jest-dom": "^5.16.2",
|
|
53
53
|
"@testing-library/react": "^12.1.2",
|
|
54
|
+
"@testing-library/react-hooks": "^8.0.0",
|
|
54
55
|
"@tsconfig/recommended": "^1.0.1",
|
|
55
56
|
"@types/jest": "^26.0.20",
|
|
56
57
|
"@types/react": "^16.14.5",
|