@homebound/form-state 2.15.1 → 2.15.4
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 +13 -0
- package/dist/AutoSaveStatus/AutoSaveStatusProvider.js +8 -1
- package/dist/fields/listField.js +10 -3
- package/dist/fields/valueField.js +4 -2
- package/dist/formState.test.js +12 -0
- package/dist/useFormState.js +6 -0
- package/dist/useFormState.test.js +35 -0
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,3 +59,16 @@ See the [sample](https://github.com/homebound-team/form-state/blob/main/src/Form
|
|
|
59
59
|
- Add `omitFromValue` so we can have two fields, `book.author.id` / `book.author.name`, where `book.author.name` is used for showing the author name, but `book.author.id` is the only field that is submitted to the server on mutation (maybe pair this with `Ref` based mutations)
|
|
60
60
|
|
|
61
61
|
- Undo/redo would in theory be neat and easy to do on top of the existing infra
|
|
62
|
+
|
|
63
|
+
# Internal Implementation
|
|
64
|
+
|
|
65
|
+
form-state keeps the "actual data" (basically a POJO of your form data) separate from the "mobx proxies that track reactivity" (the `ObjectState` interface with `.get` / `.set` / `.errors` other methods).
|
|
66
|
+
|
|
67
|
+
This works well b/c the "actual data" returned from `ObjectState.value` or `FieldState.value` is always a non-proxy POJO that can be dropped on the wire without causing serialization issues.
|
|
68
|
+
|
|
69
|
+
However, it does mean that form-state internally uses a few "that looks odd" tricks like `_tick.value++` to ensure code like `formState.value.firstName` will be reactive, even though the `.firstName` is not actually a proxy access (but doing `formState.firstName.value` would be).
|
|
70
|
+
|
|
71
|
+
(To be clear, both `formState.firstName.value` and `formState.value.firstName` return the same value, and also have the same reactivity semantics, this is just noting that form-state's internals need to do a few extra tricks to get the latter to be reactive.)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
@@ -79,6 +79,13 @@ function AutoSaveStatusProvider({ children, resetToIdleTimeout = 6000 }) {
|
|
|
79
79
|
resetToIdleTimeoutRef.current = null;
|
|
80
80
|
}, resetToIdleTimeout);
|
|
81
81
|
}, [resetStatus, resetToIdleTimeout, status]);
|
|
82
|
-
|
|
82
|
+
const value = react_1.useMemo(() => ({ status, resetStatus, errors, triggerAutoSave, resolveAutoSave }), [
|
|
83
|
+
errors,
|
|
84
|
+
resetStatus,
|
|
85
|
+
resolveAutoSave,
|
|
86
|
+
status,
|
|
87
|
+
triggerAutoSave,
|
|
88
|
+
]);
|
|
89
|
+
return jsx_runtime_1.jsx(exports.AutoSaveStatusContext.Provider, Object.assign({ value: value }, { children: children }), void 0);
|
|
83
90
|
}
|
|
84
91
|
exports.AutoSaveStatusProvider = AutoSaveStatusProvider;
|
package/dist/fields/listField.js
CHANGED
|
@@ -9,13 +9,14 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
9
9
|
// Keep a map of "item in the parent list" -> "that item's ObjectState"
|
|
10
10
|
const rowMap = new Map();
|
|
11
11
|
const _tick = mobx_1.observable({ value: 1 });
|
|
12
|
+
const _childTick = mobx_1.observable({ value: 1 });
|
|
12
13
|
// this is for dirty checking, not object identity
|
|
13
14
|
let originalCopy = [...(parentInstance[key] || [])];
|
|
14
15
|
const list = {
|
|
15
16
|
key: key,
|
|
16
17
|
// Our fundamental state of wrapped Us
|
|
17
18
|
get value() {
|
|
18
|
-
return _tick.value > 0 ? parentInstance[key] : utils_1.fail();
|
|
19
|
+
return _tick.value > 0 && _childTick.value > 0 ? parentInstance[key] : utils_1.fail();
|
|
19
20
|
},
|
|
20
21
|
_focused: false,
|
|
21
22
|
_readOnly: false,
|
|
@@ -67,7 +68,9 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
67
68
|
// transitively register us as a dependency on it
|
|
68
69
|
if (_tick.value < 0)
|
|
69
70
|
utils_1.fail();
|
|
70
|
-
|
|
71
|
+
// Avoid using `this.value` to avoid registering `_childTick` as a dependency
|
|
72
|
+
const value = parentInstance[key];
|
|
73
|
+
return (value || []).map((child) => {
|
|
71
74
|
// Because we're reading from this.value, child will be the proxy version
|
|
72
75
|
let childState = rowMap.get(child);
|
|
73
76
|
if (!childState) {
|
|
@@ -202,9 +205,13 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
202
205
|
_tick.value++;
|
|
203
206
|
},
|
|
204
207
|
};
|
|
205
|
-
|
|
208
|
+
const proxy = mobx_1.makeAutoObservable(list, {
|
|
206
209
|
// See other makeAutoObservable comment
|
|
207
210
|
value: mobx_1.computed({ equals: () => false }),
|
|
208
211
|
});
|
|
212
|
+
// Any time a row's value changes, percolate that to our `.value` (so the callers to our
|
|
213
|
+
// `.value` will rerun given the value they saw has deeply changed.)
|
|
214
|
+
mobx_1.reaction(() => proxy.rows.map((r) => r.value), () => _childTick.value++);
|
|
215
|
+
return proxy;
|
|
209
216
|
}
|
|
210
217
|
exports.newListFieldState = newListFieldState;
|
|
@@ -101,7 +101,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
101
101
|
}
|
|
102
102
|
// If the user has deleted/emptied a value that was originally set, keep it as `null`
|
|
103
103
|
// so that our partial update to the backend correctly unsets it.
|
|
104
|
-
const keepNull = !utils_1.isEmpty(this.originalValue) && utils_1.isEmpty(value);
|
|
104
|
+
const keepNull = !utils_1.isEmpty(this.originalValue) && utils_1.isEmpty(value) && !opts.refreshing;
|
|
105
105
|
// If a list of primitives was originally undefined, coerce `[]` to `undefined`
|
|
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;
|
|
@@ -135,7 +135,9 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
135
135
|
},
|
|
136
136
|
get originalValue() {
|
|
137
137
|
// A dummy check to for reactivity around our non-proxy value
|
|
138
|
-
|
|
138
|
+
const value = _originalValueTick.value > -1 ? _originalValue : _originalValue;
|
|
139
|
+
// Re-create the `keepNull` logic so that `.value` === `.originalValue`
|
|
140
|
+
return value === null ? undefined : value;
|
|
139
141
|
},
|
|
140
142
|
set originalValue(v) {
|
|
141
143
|
_originalValue = v;
|
package/dist/formState.test.js
CHANGED
|
@@ -1153,6 +1153,18 @@ describe("formState", () => {
|
|
|
1153
1153
|
formState.firstName.value = "f2";
|
|
1154
1154
|
expect(ticks).toEqual(1);
|
|
1155
1155
|
});
|
|
1156
|
+
it("can observe value changes on lists", () => {
|
|
1157
|
+
const formState = objectField_1.createObjectState(authorWithBooksConfig, { books: [{ title: "b1" }] });
|
|
1158
|
+
let ticks = 0;
|
|
1159
|
+
mobx_1.reaction(() => formState.books.value, () => ticks++, { equals: () => false });
|
|
1160
|
+
expect(ticks).toEqual(0);
|
|
1161
|
+
formState.books.rows[0].title.value = "b1";
|
|
1162
|
+
expect(ticks).toEqual(0);
|
|
1163
|
+
formState.books.rows[0].title.value = "b1...";
|
|
1164
|
+
expect(ticks).toEqual(1);
|
|
1165
|
+
formState.books.add({ title: "b2" });
|
|
1166
|
+
expect(ticks).toEqual(2);
|
|
1167
|
+
});
|
|
1156
1168
|
it("does not override a focused, changed field", () => {
|
|
1157
1169
|
const formState = objectField_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
|
|
1158
1170
|
// Given first name is focused
|
package/dist/useFormState.js
CHANGED
|
@@ -91,6 +91,12 @@ function useFormState(opts) {
|
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
93
|
setLoading(form, opts);
|
|
94
|
+
// Note: maybe someday we want to watch the `id` value and notice if it's changing (i.e. 2 -> 3),
|
|
95
|
+
// and treat this more as a reset than a refresh, b/c the id changing means it's probably
|
|
96
|
+
// not "the cache refreshed after a mutation save" but "the cache changed b/c of changing
|
|
97
|
+
// rows in the table that our side panel's form has open/is focused on" (which ideally would
|
|
98
|
+
// be treated as a full component remount by having an `key` field somewhere in the parent
|
|
99
|
+
// component, but it's unlikely the user will always remember to do this).
|
|
94
100
|
form.set(utils_1.initValue(config, init), { refreshing: true });
|
|
95
101
|
}, [form, ...dep]);
|
|
96
102
|
// Use useEffect so that we don't touch the form.init proxy during a render
|
|
@@ -528,6 +528,41 @@ describe("useFormState", () => {
|
|
|
528
528
|
expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
|
|
529
529
|
jest.clearAllTimers();
|
|
530
530
|
});
|
|
531
|
+
it("treats the id changing as a whole new entity instead of a delete", async () => {
|
|
532
|
+
const config = { firstName: { type: "value" } };
|
|
533
|
+
function TestComponent() {
|
|
534
|
+
// Given an initial author a1
|
|
535
|
+
const [author, setAuthor] = react_2.useState({ id: "a:1", firstName: "a1" });
|
|
536
|
+
const form = useFormState_1.useFormState({ config, init: { input: author, map: (a) => a } });
|
|
537
|
+
return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "value" }, { children: String(form.firstName.value) }), void 0),
|
|
538
|
+
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "dirty" }, { children: String(form.firstName.dirty) }), void 0),
|
|
539
|
+
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "originalValue" }, { children: String(form.firstName.originalValue) }), void 0),
|
|
540
|
+
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "objectValue" }, { children: String(form.value.firstName) }), void 0),
|
|
541
|
+
jsx_runtime_1.jsx("div", { "data-testid": "a1", onClick: () => setAuthor({ id: "a:1", firstName: "a1" }) }, void 0),
|
|
542
|
+
jsx_runtime_1.jsx("div", { "data-testid": "a2", onClick: () => setAuthor({ id: "a:2", firstName: undefined }) }, void 0)] }, void 0)) }, void 0));
|
|
543
|
+
}
|
|
544
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
545
|
+
// And the value is initially a1/and not dirty
|
|
546
|
+
expect(r.value()).toHaveTextContent("a1");
|
|
547
|
+
expect(r.dirty()).toHaveTextContent("false");
|
|
548
|
+
expect(r.originalValue()).toHaveTextContent("a1");
|
|
549
|
+
expect(r.objectValue()).toHaveTextContent("a1");
|
|
550
|
+
// When we switch to a completely separate author
|
|
551
|
+
rtl_utils_1.click(r.a2);
|
|
552
|
+
// Then it switches to the next author
|
|
553
|
+
expect(r.value()).toHaveTextContent("undefined");
|
|
554
|
+
// And the field is not dirty (which had been the case before this bug fix)
|
|
555
|
+
expect(r.dirty()).toHaveTextContent("false");
|
|
556
|
+
expect(r.originalValue()).toHaveTextContent("undefined");
|
|
557
|
+
expect(r.objectValue()).toHaveTextContent("undefined");
|
|
558
|
+
// And when we switch back to the original author
|
|
559
|
+
rtl_utils_1.click(r.a1);
|
|
560
|
+
// It again restores the value and does not think an edit was WIP
|
|
561
|
+
expect(r.value()).toHaveTextContent("a1");
|
|
562
|
+
expect(r.dirty()).toHaveTextContent("false");
|
|
563
|
+
expect(r.originalValue()).toHaveTextContent("a1");
|
|
564
|
+
expect(r.objectValue()).toHaveTextContent("a1");
|
|
565
|
+
});
|
|
531
566
|
});
|
|
532
567
|
const authorConfig = {
|
|
533
568
|
id: { type: "value" },
|
package/dist/utils.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export declare function isEmpty(value: any): boolean;
|
|
|
35
35
|
*
|
|
36
36
|
* - "plain" objects that have no custom prototype/i.e. are object literals
|
|
37
37
|
* - objects that implement `toJSON`
|
|
38
|
-
*
|
|
38
|
+
* - arrays
|
|
39
39
|
*/
|
|
40
40
|
export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolean;
|
|
41
41
|
export declare function hasToJSON(o?: unknown): o is {
|
package/dist/utils.js
CHANGED
|
@@ -100,7 +100,7 @@ exports.isEmpty = isEmpty;
|
|
|
100
100
|
*
|
|
101
101
|
* - "plain" objects that have no custom prototype/i.e. are object literals
|
|
102
102
|
* - objects that implement `toJSON`
|
|
103
|
-
*
|
|
103
|
+
* - arrays
|
|
104
104
|
*/
|
|
105
105
|
function areEqual(a, b, strictOrder) {
|
|
106
106
|
if (is_plain_object_1.isPlainObject(a)) {
|
|
@@ -117,7 +117,7 @@ function areEqual(a, b, strictOrder) {
|
|
|
117
117
|
}
|
|
118
118
|
if (a.length !== b.length)
|
|
119
119
|
return false;
|
|
120
|
-
return a.every((a1) => b.some((b1) =>
|
|
120
|
+
return a.every((a1) => b.some((b1) => areEqual(a1, b1)));
|
|
121
121
|
}
|
|
122
122
|
return a === b;
|
|
123
123
|
}
|