@homebound/form-state 2.26.9 → 2.26.10
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/fields/listField.js +84 -11
- package/dist/fields/objectField.d.ts +2 -1
- package/dist/fields/objectField.js +5 -0
- package/dist/formState.test.js +41 -0
- package/dist/useFormState.test.js +40 -2
- package/dist/utils.d.ts +1 -0
- package/dist/utils.js +12 -0
- package/package.json +4 -2
package/dist/fields/listField.js
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.newListFieldState = newListFieldState;
|
|
4
7
|
const mobx_1 = require("mobx");
|
|
5
8
|
const objectField_1 = require("./objectField");
|
|
6
9
|
const rules_1 = require("../rules");
|
|
7
10
|
const utils_1 = require("../utils");
|
|
8
|
-
|
|
11
|
+
const object_hash_1 = __importDefault(require("object-hash"));
|
|
12
|
+
function newListFieldState(
|
|
13
|
+
// parentCopy is objectField's deepClone-d `originalCopy` that it/we use for `dirty` checking
|
|
14
|
+
parentCopy,
|
|
15
|
+
// parentInstance is objectField's `instance`, that we're currently mutating
|
|
16
|
+
parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
|
|
9
17
|
var _a;
|
|
10
|
-
// Keep a map of "item in the
|
|
18
|
+
// Keep a map of "item in the parentInstance list" -> "that item's ObjectState"
|
|
11
19
|
const rowMap = new Map();
|
|
20
|
+
const addedRows = new Set();
|
|
12
21
|
const _tick = (0, mobx_1.observable)({ value: 1 });
|
|
13
22
|
const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
|
|
14
23
|
const _childTick = (0, mobx_1.observable)({ value: 1 });
|
|
@@ -165,6 +174,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
165
174
|
maybeAutoSave();
|
|
166
175
|
},
|
|
167
176
|
set(values, opts = {}) {
|
|
177
|
+
var _a, _b, _c, _d, _e;
|
|
168
178
|
if (this.readOnly && !opts.resetting && !opts.refreshing) {
|
|
169
179
|
throw new Error(`${String(key)} is currently readOnly`);
|
|
170
180
|
}
|
|
@@ -173,19 +183,82 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
173
183
|
// passing us cloned rows from the parentCopy, but `getOrCreateChildState` will
|
|
174
184
|
// use the `copyMap` to recover the non-cloned rows, to avoid promoting the clone
|
|
175
185
|
// into a real row.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
186
|
+
if (this.dirty && opts.refreshing) {
|
|
187
|
+
// When refreshing a dirty list, we need to preserve WIP values
|
|
188
|
+
// Start with current list, then merge in incoming changes
|
|
189
|
+
//
|
|
190
|
+
// These will all be POJOs, where `currentItems` existing in our `parentInstance`, and
|
|
191
|
+
// `incomingItems` could really be any of:
|
|
192
|
+
// - an instance from `parentInstance[key]` that user code is passing back in
|
|
193
|
+
// - an instance from `parentCopy[key]` that a cache refresh is passing in
|
|
194
|
+
// - a new instance, either from a cache refresh or user code, that we haven't seen yet
|
|
195
|
+
const currentItems = this.value || [];
|
|
196
|
+
const incomingItems = values || [];
|
|
197
|
+
const mergedItems = [];
|
|
198
|
+
// Index by idKey or a hash of the object, so we don't have to n^2 merging
|
|
199
|
+
const idKey = this.rows.length > 0 && this.rows[0].idKey;
|
|
200
|
+
const hashItem = (item) => (idKey && item[idKey]) || (0, object_hash_1.default)(item);
|
|
201
|
+
const hashWithoutId = (item) => (0, object_hash_1.default)(Object.fromEntries(Object.entries(item).filter(([key]) => key !== idKey)));
|
|
202
|
+
const currentById = (0, utils_1.groupBy)(currentItems, hashItem);
|
|
203
|
+
const incomingById = (0, utils_1.groupBy)(incomingItems, hashItem);
|
|
204
|
+
// If our local instance doesn't assign in id, when we get results back from the server, strip
|
|
205
|
+
// their id and then see if we're the same based on data. Note that this is a hueristic, and will
|
|
206
|
+
// fail if the client-side has made additional changes to the child after submitting it to the server,
|
|
207
|
+
// or if the server acks back more/different data than the client sent.
|
|
208
|
+
//
|
|
209
|
+
// Only do this if one of our client-side objects is missing an id
|
|
210
|
+
const incomingByHash = idKey && currentItems.some((item) => !item[idKey]) && (0, utils_1.groupBy)(incomingItems, hashWithoutId);
|
|
211
|
+
// First, process all current items
|
|
212
|
+
for (const currentItem of currentItems) {
|
|
213
|
+
const childState = rowMap.get(currentItem); // We'll always have a child state for `currentItems`
|
|
214
|
+
// Try to find a matching item in the incoming data
|
|
215
|
+
const hash = hashItem(currentItem);
|
|
216
|
+
const match = ((_b = (_a = incomingById.get(hash)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : (!!incomingByHash && ((_c = incomingByHash === null || incomingByHash === void 0 ? void 0 : incomingByHash.get(hash)) === null || _c === void 0 ? void 0 : _c[0])));
|
|
217
|
+
if (match) {
|
|
218
|
+
// Defer to set to handle not nuking WIP changes
|
|
219
|
+
childState.set(match, opts);
|
|
220
|
+
mergedItems.push(childState.value);
|
|
221
|
+
// Once matched, we don't this to be an addedRow anymore
|
|
222
|
+
addedRows.delete(currentItem);
|
|
223
|
+
}
|
|
224
|
+
else if (childState.dirty || addedRows.has(currentItem)) {
|
|
225
|
+
// If no incoming, but we're dirty, keep the WIP change
|
|
226
|
+
mergedItems.push(currentItem);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
// Local is not dirty, and it's not upstream, so remove it
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Then, add any incoming items that don't exist in current
|
|
233
|
+
for (const incomingItem of incomingItems) {
|
|
234
|
+
// Look for both `incoming.id` and the incoming-sans-id for new entities
|
|
235
|
+
const match = ((_d = currentById.get(hashItem(incomingItem))) === null || _d === void 0 ? void 0 : _d[0]) || ((_e = currentById.get(hashWithoutId(incomingItem))) === null || _e === void 0 ? void 0 : _e[0]);
|
|
236
|
+
if (!match) {
|
|
237
|
+
// New item from server, add it
|
|
238
|
+
const childState = getOrCreateChildState(incomingItem, opts);
|
|
239
|
+
mergedItems.push(childState.value);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
parentInstance[key] = mergedItems;
|
|
243
|
+
_tick.value++;
|
|
244
|
+
// Set original to not merged...
|
|
245
|
+
this.setOriginalValue(incomingItems);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
parentInstance[key] = (values !== null && values !== void 0 ? values : []).map((child) => getOrCreateChildState(child, opts).value);
|
|
249
|
+
_tick.value++;
|
|
250
|
+
// Reset originalCopy so that our dirty checks have the right # of rows.
|
|
251
|
+
if (opts.refreshing) {
|
|
252
|
+
this.setOriginalValue();
|
|
253
|
+
}
|
|
182
254
|
}
|
|
183
255
|
},
|
|
184
256
|
add(value, spliceIndex) {
|
|
185
257
|
// This is called by the user, so value should be a non-proxy value we should keep
|
|
186
258
|
const childState = getOrCreateChildState(value);
|
|
187
|
-
// const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
|
|
188
259
|
rowMap.set(value, childState);
|
|
260
|
+
// Let `.set` know this is a new row
|
|
261
|
+
addedRows.add(value);
|
|
189
262
|
this.ensureSet();
|
|
190
263
|
this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
|
|
191
264
|
_tick.value++;
|
|
@@ -223,9 +296,9 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
223
296
|
return value !== null && value !== void 0 ? value : [];
|
|
224
297
|
},
|
|
225
298
|
// This should only be called when value === originalValue
|
|
226
|
-
setOriginalValue() {
|
|
299
|
+
setOriginalValue(incomingItems) {
|
|
227
300
|
// Use the rows' originalValues to update the parentCopy
|
|
228
|
-
parentCopy[key] = this.rows.map((r) => r.originalValue);
|
|
301
|
+
parentCopy[key] = incomingItems !== null && incomingItems !== void 0 ? incomingItems : this.rows.map((r) => r.originalValue);
|
|
229
302
|
parentCopy[key].forEach((copy, i) => {
|
|
230
303
|
copyMap.set(copy, parentInstance[key][i]);
|
|
231
304
|
});
|
|
@@ -34,9 +34,10 @@ export type ObjectState<T> = FieldStates<T> & FieldState<T> & {
|
|
|
34
34
|
/** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
|
|
35
35
|
canSave(): boolean;
|
|
36
36
|
};
|
|
37
|
-
export type ObjectStateInternal<T> = ObjectState<T> & {
|
|
37
|
+
export type ObjectStateInternal<T = unknown> = ObjectState<T> & {
|
|
38
38
|
set(value: T, opts?: InternalSetOpts): void;
|
|
39
39
|
isSameEntity(other: T): boolean;
|
|
40
|
+
idKey: string | undefined;
|
|
40
41
|
};
|
|
41
42
|
declare const fragmentSym: unique symbol;
|
|
42
43
|
export type Fragment<V> = V & {
|
|
@@ -49,6 +49,7 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
|
|
|
49
49
|
field = (0, valueField_1.newValueFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config.isIdKey ||
|
|
50
50
|
// Default the id key to "id" unless some other field has isIdKey set
|
|
51
51
|
(key === "id" &&
|
|
52
|
+
!("isIdKey" in config) && // Allow setting `isIdKey=false`
|
|
52
53
|
!Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, (_a = config.computed) !== null && _a !== void 0 ? _a :
|
|
53
54
|
// If instance is a mobx class, we can detect computeds as they won't be enumerable
|
|
54
55
|
((0, mobx_1.isObservable)(instance) ? !(key in originalCopy) : false), (_b = config.readOnly) !== null && _b !== void 0 ? _b : false, (_c = config.strictOrder) !== null && _c !== void 0 ? _c : true, maybeAutoSave);
|
|
@@ -250,6 +251,10 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
|
|
|
250
251
|
isUnset() {
|
|
251
252
|
return !!parentInstance && parentInstance[key] === undefined;
|
|
252
253
|
},
|
|
254
|
+
get idKey() {
|
|
255
|
+
var _a;
|
|
256
|
+
return (_a = getFields(this).find((f) => f._isIdKey)) === null || _a === void 0 ? void 0 : _a.key;
|
|
257
|
+
},
|
|
253
258
|
};
|
|
254
259
|
proxy = (0, mobx_1.makeAutoObservable)(obj, {
|
|
255
260
|
// Use custom equality on `value` that is _never_ equals. This sounds weird, but
|
package/dist/formState.test.js
CHANGED
|
@@ -165,6 +165,46 @@ describe("formState", () => {
|
|
|
165
165
|
expect(state.books.dirty).toBe(false);
|
|
166
166
|
expect(state.books.originalValue).toEqual([]);
|
|
167
167
|
});
|
|
168
|
+
it("list value can refresh and not drop new children w/id", () => {
|
|
169
|
+
// Given a list field that is currently []
|
|
170
|
+
const a1 = { firstName: "a1", books: [] };
|
|
171
|
+
const state = createAuthorInputState(a1);
|
|
172
|
+
// And we add a new child on the client-side
|
|
173
|
+
state.books.add({ id: "b:1", title: "b1" });
|
|
174
|
+
// When we refresh it with the original list undefined
|
|
175
|
+
state.set({ books: [] }, { refreshing: true });
|
|
176
|
+
// Then it keeps the new child (and it somehow already has the id set)
|
|
177
|
+
expect(state.books.value).toEqual([{ id: "b:1", title: "b1" }]);
|
|
178
|
+
// And it's still considered changed
|
|
179
|
+
expect(state.books.dirty).toBe(true);
|
|
180
|
+
expect(state.books.originalValue).toEqual([]);
|
|
181
|
+
// And later when we refresh with the value acked from the server
|
|
182
|
+
state.set({ books: [{ id: "b:1", title: "b1", isPublished: true }] }, { refreshing: true });
|
|
183
|
+
// Then we have only 1 value
|
|
184
|
+
expect(state.books.value).toEqual([{ id: "b:1", title: "b1", isPublished: true }]);
|
|
185
|
+
// And we're not longer dirty
|
|
186
|
+
expect(state.books.dirty).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
it("list value can refresh and not drop new children w/o an id", () => {
|
|
189
|
+
// Given a list field that is currently []
|
|
190
|
+
const a1 = { firstName: "a1", books: [] };
|
|
191
|
+
const state = createAuthorInputState(a1);
|
|
192
|
+
// And we add a new child on the client-side (and it doesn't have an id)
|
|
193
|
+
state.books.add({ title: "b1" });
|
|
194
|
+
// When we refresh it with the original list undefined
|
|
195
|
+
state.set({ books: [] }, { refreshing: true });
|
|
196
|
+
// Then it keeps the new child
|
|
197
|
+
expect(state.books.value).toEqual([{ title: "b1" }]);
|
|
198
|
+
// And it's still considered changed
|
|
199
|
+
expect(state.books.dirty).toBe(true);
|
|
200
|
+
expect(state.books.originalValue).toEqual([]);
|
|
201
|
+
// And later when we refresh with the value acked from the server
|
|
202
|
+
state.set({ books: [{ id: "b:1", title: "b1" }] }, { refreshing: true });
|
|
203
|
+
// Then we have only 1 value
|
|
204
|
+
expect(state.books.value).toEqual([{ id: "b:1", title: "b1" }]);
|
|
205
|
+
// And we're not longer dirty
|
|
206
|
+
expect(state.books.dirty).toBe(false);
|
|
207
|
+
});
|
|
168
208
|
it("list value can observe changes", () => {
|
|
169
209
|
const b1 = { title: "t1" };
|
|
170
210
|
const a1 = { firstName: "a1", books: [b1] };
|
|
@@ -1743,6 +1783,7 @@ const authorWithBooksConfig = configBuilders_1.f.config({
|
|
|
1743
1783
|
id: configBuilders_1.f.value(),
|
|
1744
1784
|
title: configBuilders_1.f.value().req(),
|
|
1745
1785
|
classification: configBuilders_1.f.value(),
|
|
1786
|
+
isPublished: configBuilders_1.f.value(),
|
|
1746
1787
|
}),
|
|
1747
1788
|
});
|
|
1748
1789
|
function createAuthorInputState(input, maybeAutoSave) {
|
|
@@ -218,7 +218,7 @@ describe("useFormState", () => {
|
|
|
218
218
|
id: "a:1",
|
|
219
219
|
});
|
|
220
220
|
});
|
|
221
|
-
it("
|
|
221
|
+
it("can accept new data while read only", async () => {
|
|
222
222
|
// Given a component
|
|
223
223
|
function TestComponent() {
|
|
224
224
|
const config = authorWithAddressAndBooksConfig;
|
|
@@ -255,7 +255,7 @@ describe("useFormState", () => {
|
|
|
255
255
|
expect(r.street.textContent).toEqual("s2");
|
|
256
256
|
expect(r.title1.textContent).toEqual("a2");
|
|
257
257
|
});
|
|
258
|
-
it("
|
|
258
|
+
it("can accept new data with computed fields", async () => {
|
|
259
259
|
// Given a component
|
|
260
260
|
// And it's using a class/mobx proxy as the basis for the data
|
|
261
261
|
class AuthorRow {
|
|
@@ -356,6 +356,44 @@ describe("useFormState", () => {
|
|
|
356
356
|
// Then we auto save again
|
|
357
357
|
expect(autoSave).toBeCalledTimes(2);
|
|
358
358
|
});
|
|
359
|
+
it("can revert to last-saved-state after a failed auto save", async () => {
|
|
360
|
+
// Given a component
|
|
361
|
+
function TestComponent() {
|
|
362
|
+
// And the firstName is initially "f1"
|
|
363
|
+
const [data, setData] = (0, react_2.useState)({ firstName: "f1" });
|
|
364
|
+
const query = (0, react_2.useMemo)(() => {
|
|
365
|
+
return { data, loading: false, error: undefined };
|
|
366
|
+
}, [data]);
|
|
367
|
+
const form = (0, useFormState_1.useFormState)({
|
|
368
|
+
config: authorConfig,
|
|
369
|
+
init: { query, map: (d) => d },
|
|
370
|
+
async autoSave(fs) {
|
|
371
|
+
// If the user types "f2", we'll pretend the backend accepted the update, and acks it in the mutation response
|
|
372
|
+
if (fs.firstName.value === "f2") {
|
|
373
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
374
|
+
setData(fs.value);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
// If the user types anything else, pretend the backend rejected it, and revert
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
379
|
+
fs.revertChanges();
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "firstNameOriginal", children: form.firstName.originalValue }), (0, jsx_runtime_1.jsx)(FormStateApp_1.TextField, { field: form.firstName }), ";"] })) }));
|
|
384
|
+
}
|
|
385
|
+
// So we start out with f1
|
|
386
|
+
const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
|
|
387
|
+
expect(r.firstName).toHaveValue("f1");
|
|
388
|
+
// When we type f2, it should be accepted & stay
|
|
389
|
+
await (0, rtl_utils_1.typeAndWait)(r.firstName, "f2");
|
|
390
|
+
expect(r.firstName).toHaveValue("f2");
|
|
391
|
+
expect(r.firstNameOriginal).toHaveTextContent("f2");
|
|
392
|
+
// But if we type f3, we want it reverted, back to f2 (the last good value)
|
|
393
|
+
await (0, rtl_utils_1.typeAndWait)(r.firstName, "f3");
|
|
394
|
+
expect(r.firstName).toHaveValue("f2");
|
|
395
|
+
expect(r.firstNameOriginal).toHaveTextContent("f2");
|
|
396
|
+
});
|
|
359
397
|
it("returns empty lists even when inputs are undefined", async () => {
|
|
360
398
|
const autoSave = jest.fn();
|
|
361
399
|
// Given a component
|
package/dist/utils.d.ts
CHANGED
|
@@ -46,3 +46,4 @@ export declare function hasToJSON(o?: unknown): o is {
|
|
|
46
46
|
};
|
|
47
47
|
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
48
48
|
export declare function deepClone<T>(obj: T, map?: WeakMap<WeakKey, any>): T;
|
|
49
|
+
export declare function groupBy<T, Y = T, K = string>(list: readonly T[], fn: (x: T) => K, valueFn?: (x: T) => Y): Map<K, Y[]>;
|
package/dist/utils.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.isEmpty = isEmpty;
|
|
|
11
11
|
exports.areEqual = areEqual;
|
|
12
12
|
exports.hasToJSON = hasToJSON;
|
|
13
13
|
exports.deepClone = deepClone;
|
|
14
|
+
exports.groupBy = groupBy;
|
|
14
15
|
const is_plain_object_1 = require("is-plain-object");
|
|
15
16
|
const mobx_1 = require("mobx");
|
|
16
17
|
const deepEquals_1 = require("./fields/deepEquals");
|
|
@@ -144,6 +145,17 @@ function deepClone(obj, map = new WeakMap()) {
|
|
|
144
145
|
return obj;
|
|
145
146
|
}
|
|
146
147
|
}
|
|
148
|
+
function groupBy(list, fn, valueFn) {
|
|
149
|
+
const result = new Map();
|
|
150
|
+
list.forEach((o) => {
|
|
151
|
+
const group = fn(o);
|
|
152
|
+
if (!result.has(group)) {
|
|
153
|
+
result.set(group, []);
|
|
154
|
+
}
|
|
155
|
+
result.get(group).push(valueFn === undefined ? o : valueFn(o));
|
|
156
|
+
});
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
147
159
|
/** Returns all property names, including mobx computeds (non-enumerable) & inherited. */
|
|
148
160
|
function getAllPropertyNames(obj) {
|
|
149
161
|
const proto = Object.getPrototypeOf(obj);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.26.
|
|
3
|
+
"version": "2.26.10",
|
|
4
4
|
"author": "Homebound",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"src/**/*.{ts,tsx,css,md}": "prettier --write"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"
|
|
33
|
+
"@types/object-hash": "^3.0.6",
|
|
34
|
+
"is-plain-object": "^5.0.0",
|
|
35
|
+
"object-hash": "^3.0.0"
|
|
34
36
|
},
|
|
35
37
|
"peerDependencies": {
|
|
36
38
|
"mobx": ">=6",
|