@homebound/form-state 2.4.0 → 2.5.2
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/FormStateApp.js +1 -1
- package/dist/formState.js +3 -5
- package/dist/formState.test.js +17 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/setupTests.js +7 -2
- package/dist/useFormState.d.ts +1 -13
- package/dist/useFormState.js +35 -69
- package/dist/useFormState.test.js +88 -95
- package/dist/useFormStates.d.ts +12 -0
- package/dist/useFormStates.js +58 -0
- package/dist/useFormStates.test.d.ts +1 -0
- package/dist/useFormStates.test.js +136 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +60 -1
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +102 -0
- package/package.json +15 -6
package/dist/FormStateApp.js
CHANGED
package/dist/formState.js
CHANGED
|
@@ -261,13 +261,11 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
261
261
|
this._focused = true;
|
|
262
262
|
},
|
|
263
263
|
blur() {
|
|
264
|
-
|
|
265
|
-
if (this._focused) {
|
|
266
|
-
this.maybeAutoSave();
|
|
267
|
-
}
|
|
264
|
+
this.maybeAutoSave();
|
|
268
265
|
this._focused = false;
|
|
269
266
|
},
|
|
270
267
|
maybeAutoSave() {
|
|
268
|
+
// Now that the user is done editing the field (note maybe w/o blurring, i.e. if they hit enter), we sneak in some trim logic
|
|
271
269
|
this.maybeTrim();
|
|
272
270
|
// touched is readonly, but we're allowed to change it
|
|
273
271
|
this.touched = true;
|
|
@@ -514,7 +512,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
514
512
|
this.rows.forEach((r) => {
|
|
515
513
|
r.save();
|
|
516
514
|
});
|
|
517
|
-
originalCopy = parentInstance[key];
|
|
515
|
+
originalCopy = [...(parentInstance[key] || [])];
|
|
518
516
|
_tick.value++;
|
|
519
517
|
},
|
|
520
518
|
ensureSet() {
|
package/dist/formState.test.js
CHANGED
|
@@ -1145,6 +1145,23 @@ describe("formState", () => {
|
|
|
1145
1145
|
expect(formState.books.rows[1].isNewEntity).toBeTruthy();
|
|
1146
1146
|
expect(formState.books.isNewEntity).toBeFalsy();
|
|
1147
1147
|
});
|
|
1148
|
+
it("can properly evaluate dirty lists after calling 'save'", () => {
|
|
1149
|
+
// Given an empty list state
|
|
1150
|
+
const a1 = createAuthorInputState({ books: [] });
|
|
1151
|
+
expect(a1.books.dirty).toBeFalsy();
|
|
1152
|
+
// When adding a book
|
|
1153
|
+
a1.books.add({ title: "t2" });
|
|
1154
|
+
// Then the list state is dirty
|
|
1155
|
+
expect(a1.books.dirty).toBeTruthy();
|
|
1156
|
+
// When saving the form state
|
|
1157
|
+
a1.save();
|
|
1158
|
+
// Then the list is no longer dirty
|
|
1159
|
+
expect(a1.books.dirty).toBeFalsy();
|
|
1160
|
+
// When adding a new book
|
|
1161
|
+
a1.books.add({ title: "t3" });
|
|
1162
|
+
// The list is again dirty
|
|
1163
|
+
expect(a1.books.dirty).toBeTruthy();
|
|
1164
|
+
});
|
|
1148
1165
|
});
|
|
1149
1166
|
class ObservableObject {
|
|
1150
1167
|
constructor() {
|
package/dist/index.d.ts
CHANGED
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; } });
|
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,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>) =>
|
|
40
|
+
autoSave?: (state: ObjectState<T>) => Promise<unknown>;
|
|
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
|
-
};
|
package/dist/useFormState.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
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,10 @@ 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;
|
|
15
|
+
let timerId;
|
|
13
16
|
/**
|
|
14
17
|
* Creates a formState instance for editing in a form.
|
|
15
18
|
*/
|
|
@@ -20,28 +23,47 @@ function useFormState(opts) {
|
|
|
20
23
|
autoSaveRef.current = autoSave;
|
|
21
24
|
const firstRunRef = react_1.useRef(true);
|
|
22
25
|
// 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));
|
|
26
|
+
const [firstInitValue] = react_1.useState(() => utils_1.initValue(config, init));
|
|
24
27
|
const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
|
|
25
28
|
// If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
|
|
26
29
|
const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
|
|
27
30
|
const form = react_1.useMemo(() => {
|
|
28
31
|
function maybeAutoSave() {
|
|
32
|
+
if (isAutoSaving === "in-flight") {
|
|
33
|
+
pendingAutoSave = true;
|
|
34
|
+
}
|
|
35
|
+
else if (isAutoSaving === "queued") {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
29
38
|
// Don't use canSave() because we don't want to set touched for all the fields
|
|
30
39
|
if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}, 0);
|
|
38
61
|
}
|
|
39
62
|
}
|
|
40
|
-
const value = firstRunRef.current ? firstInitValue : initValue(config, init);
|
|
63
|
+
const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
|
|
41
64
|
const form = formState_1.createObjectState(config, value, { maybeAutoSave });
|
|
42
65
|
form.readOnly = readOnly;
|
|
43
66
|
// 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
67
|
(addRules || (() => { }))(form);
|
|
46
68
|
firstRunRef.current = true;
|
|
47
69
|
return form;
|
|
@@ -59,7 +81,7 @@ function useFormState(opts) {
|
|
|
59
81
|
firstRunRef.current = false;
|
|
60
82
|
return;
|
|
61
83
|
}
|
|
62
|
-
form.set(initValue(config, init), { refreshing: true });
|
|
84
|
+
form.set(utils_1.initValue(config, init), { refreshing: true });
|
|
63
85
|
}, [form, ...dep]);
|
|
64
86
|
// Use useEffect so that we don't touch the form.init proxy during a render
|
|
65
87
|
react_1.useEffect(() => {
|
|
@@ -68,59 +90,3 @@ function useFormState(opts) {
|
|
|
68
90
|
return form;
|
|
69
91
|
}
|
|
70
92
|
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,9 +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
|
|
5
|
+
const react_1 = require("@testing-library/react");
|
|
6
6
|
const mobx_react_1 = require("mobx-react");
|
|
7
|
-
const
|
|
7
|
+
const react_2 = require("react");
|
|
8
8
|
const formState_1 = require("./formState");
|
|
9
9
|
const useFormState_1 = require("./useFormState");
|
|
10
10
|
describe("useFormState", () => {
|
|
@@ -27,7 +27,7 @@ describe("useFormState", () => {
|
|
|
27
27
|
it("memoizes on init.input being an array", async () => {
|
|
28
28
|
const config = { firstName: { type: "value" } };
|
|
29
29
|
function TestComponent() {
|
|
30
|
-
const [, setTick] =
|
|
30
|
+
const [, setTick] = react_2.useState(0);
|
|
31
31
|
const form = useFormState_1.useFormState({
|
|
32
32
|
config,
|
|
33
33
|
init: { input: ["a", "b"], map: ([a, b]) => ({ firstName: a + b }) },
|
|
@@ -86,7 +86,7 @@ describe("useFormState", () => {
|
|
|
86
86
|
it("uses init if set as a value", async () => {
|
|
87
87
|
const config = { firstName: { type: "value" } };
|
|
88
88
|
function TestComponent() {
|
|
89
|
-
const [, setTick] =
|
|
89
|
+
const [, setTick] = react_2.useState(0);
|
|
90
90
|
const form = useFormState_1.useFormState({
|
|
91
91
|
config,
|
|
92
92
|
// That's using a raw init value
|
|
@@ -154,7 +154,7 @@ describe("useFormState", () => {
|
|
|
154
154
|
],
|
|
155
155
|
};
|
|
156
156
|
// And we start out with data1
|
|
157
|
-
const [data, setData] =
|
|
157
|
+
const [data, setData] = react_2.useState(data1);
|
|
158
158
|
const form = useFormState_1.useFormState({ config, init: { input: data, map: (d) => d } });
|
|
159
159
|
function makeLocalChanges() {
|
|
160
160
|
form.firstName.value = "local";
|
|
@@ -230,7 +230,7 @@ describe("useFormState", () => {
|
|
|
230
230
|
books: [{ title: "a2" }],
|
|
231
231
|
};
|
|
232
232
|
// And we start out with data1
|
|
233
|
-
const [data, setData] =
|
|
233
|
+
const [data, setData] = react_2.useState(data1);
|
|
234
234
|
const form = useFormState_1.useFormState({
|
|
235
235
|
config,
|
|
236
236
|
init: { input: data, map: (d) => d },
|
|
@@ -271,9 +271,9 @@ describe("useFormState", () => {
|
|
|
271
271
|
const data1 = { firstName: "f1", lastName: "l1" };
|
|
272
272
|
const data2 = { firstName: "f2", lastName: "l2" };
|
|
273
273
|
// And we start out with data1
|
|
274
|
-
const [data, setData] =
|
|
275
|
-
const author =
|
|
276
|
-
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(() => ({
|
|
277
277
|
firstName: { type: "value" },
|
|
278
278
|
lastName: { type: "value" },
|
|
279
279
|
fullName: { type: "value", computed: true },
|
|
@@ -297,7 +297,7 @@ describe("useFormState", () => {
|
|
|
297
297
|
// Given a component
|
|
298
298
|
function TestComponent() {
|
|
299
299
|
// When the data is initially undefined
|
|
300
|
-
const [data, setData] =
|
|
300
|
+
const [data, setData] = react_2.useState();
|
|
301
301
|
const data2 = { books: [{ title: "Title 1" }] };
|
|
302
302
|
const form = useFormState_1.useFormState({
|
|
303
303
|
config: authorWithBooksConfig,
|
|
@@ -314,16 +314,16 @@ describe("useFormState", () => {
|
|
|
314
314
|
// When the data/child is now available
|
|
315
315
|
rtl_utils_1.click(r.refreshData);
|
|
316
316
|
// And the field is blurred
|
|
317
|
-
rtl_utils_1.
|
|
317
|
+
await rtl_utils_1.clickAndWait(r.blurBookOne);
|
|
318
318
|
// Then we don't auto-save because nothing has changed
|
|
319
319
|
expect(autoSave).toBeCalledTimes(0);
|
|
320
320
|
// And when adding a new book
|
|
321
|
-
rtl_utils_1.
|
|
321
|
+
await rtl_utils_1.clickAndWait(r.add);
|
|
322
322
|
// We autoSave the new row right away (because we don't have any validation rules
|
|
323
323
|
// that say the new row can't be empty)
|
|
324
324
|
expect(autoSave).toBeCalledTimes(1);
|
|
325
325
|
// And the new book is blurred
|
|
326
|
-
rtl_utils_1.
|
|
326
|
+
await rtl_utils_1.clickAndWait(r.blurBookTwo);
|
|
327
327
|
// Then we auto save again
|
|
328
328
|
expect(autoSave).toBeCalledTimes(2);
|
|
329
329
|
});
|
|
@@ -337,7 +337,7 @@ describe("useFormState", () => {
|
|
|
337
337
|
config,
|
|
338
338
|
init: data,
|
|
339
339
|
// And there is reactive business logic in the `autoSave` method
|
|
340
|
-
autoSave(state) {
|
|
340
|
+
async autoSave(state) {
|
|
341
341
|
state.lastName.set("l2");
|
|
342
342
|
autoSaves++;
|
|
343
343
|
},
|
|
@@ -346,79 +346,84 @@ describe("useFormState", () => {
|
|
|
346
346
|
}
|
|
347
347
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
348
348
|
// When we change firstName
|
|
349
|
-
rtl_utils_1.
|
|
349
|
+
await rtl_utils_1.clickAndWait(r.setFirst);
|
|
350
350
|
// When autoSave didn't infinite loop
|
|
351
351
|
expect(autoSaves).toEqual(1);
|
|
352
352
|
});
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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);
|
|
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 () => {
|
|
378
|
+
const autoSaveStub = jest.fn();
|
|
379
|
+
const config = {
|
|
380
|
+
id: { type: "value" },
|
|
381
|
+
firstName: { type: "value" },
|
|
382
|
+
lastName: { type: "value" },
|
|
383
|
+
};
|
|
384
|
+
// Given a component using `getObjectState` for lazily creating ObjectStates
|
|
385
|
+
function TestComponent() {
|
|
386
|
+
const [apiData, setApiData] = react_2.useState({ id: "a:1", firstName: "Brandon", lastName: "Dow" });
|
|
387
|
+
const state = useFormState_1.useFormState({ config, autoSave, init: { input: apiData, map: (v) => v } });
|
|
388
|
+
async function autoSave(form) {
|
|
389
|
+
autoSaveStub(form.changedValue);
|
|
390
|
+
const changed = form.changedValue;
|
|
391
|
+
// Pretend to make an API call and update the local state
|
|
392
|
+
await Promise.resolve(1);
|
|
393
|
+
setApiData((prevState) => ({ ...prevState, ...changed }));
|
|
394
|
+
}
|
|
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: () => {
|
|
397
|
+
state.firstName.focus();
|
|
398
|
+
state.firstName.set("Foo");
|
|
399
|
+
state.firstName.maybeAutoSave();
|
|
400
|
+
state.firstName.blur();
|
|
401
|
+
} }, void 0),
|
|
402
|
+
jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveFieldLastName", onClick: () => {
|
|
403
|
+
state.lastName.focus();
|
|
404
|
+
state.lastName.set("Bar");
|
|
405
|
+
state.lastName.maybeAutoSave();
|
|
406
|
+
} }, void 0)] }, void 0));
|
|
407
|
+
}
|
|
408
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
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();
|
|
421
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)
|
|
422
|
+
await rtl_utils_1.wait();
|
|
423
|
+
// Then expect the auto save to only have been called twice. Once with each changedValues.
|
|
424
|
+
expect(autoSaveStub).toBeCalledTimes(2);
|
|
425
|
+
expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
|
|
426
|
+
expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
|
|
422
427
|
});
|
|
423
428
|
});
|
|
424
429
|
const authorConfig = {
|
|
@@ -426,18 +431,6 @@ const authorConfig = {
|
|
|
426
431
|
firstName: { type: "value" },
|
|
427
432
|
lastName: { type: "value" },
|
|
428
433
|
};
|
|
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
434
|
const authorWithBooksConfig = {
|
|
442
435
|
id: { type: "value" },
|
|
443
436
|
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.
|
|
3
|
+
"version": "2.5.2",
|
|
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",
|
|
@@ -32,16 +40,16 @@
|
|
|
32
40
|
},
|
|
33
41
|
"devDependencies": {
|
|
34
42
|
"@babel/core": "^7.13.1",
|
|
35
|
-
"@homebound/rtl-utils": "^2.
|
|
43
|
+
"@homebound/rtl-utils": "^2.59.2",
|
|
36
44
|
"@semantic-release/git": "^9.0.0",
|
|
37
45
|
"@storybook/addon-essentials": "^6.2.9",
|
|
38
46
|
"@storybook/addon-info": "^5.3.21",
|
|
39
47
|
"@storybook/addon-links": "^6.2.9",
|
|
40
48
|
"@storybook/addons": "^6.2.9",
|
|
41
49
|
"@storybook/react": "^6.2.9",
|
|
42
|
-
"@testing-library/dom": "^
|
|
43
|
-
"@testing-library/jest-dom": "^5.
|
|
44
|
-
"@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",
|
|
45
53
|
"@tsconfig/recommended": "^1.0.1",
|
|
46
54
|
"@types/jest": "^26.0.20",
|
|
47
55
|
"@types/react": "^16.14.5",
|
|
@@ -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": "^
|
|
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",
|