@homebound/form-state 2.27.2 → 2.27.3
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 +27 -11
- package/dist/formState.test.js +71 -0
- package/dist/formStateDomain.d.ts +5 -0
- package/dist/useFormState.test.js +10 -3
- package/package.json +1 -1
package/dist/fields/listField.js
CHANGED
|
@@ -85,6 +85,11 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
85
85
|
get dirty() {
|
|
86
86
|
return this.rows.some((r) => r.dirty) || this.hasChanged();
|
|
87
87
|
},
|
|
88
|
+
// Having a new entity is slightly different than being dirty, b/c merely having a placeholder-but-not-changed-yet
|
|
89
|
+
// `isNewEntity` child should not fire autosave (which would happen if we're dirty).
|
|
90
|
+
get hasNewEntity() {
|
|
91
|
+
return this.rows.some((r) => r.isNewEntity);
|
|
92
|
+
},
|
|
88
93
|
get focused() {
|
|
89
94
|
return this.rows.some((r) => r.focused);
|
|
90
95
|
},
|
|
@@ -184,7 +189,7 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
184
189
|
// passing us cloned rows from the parentCopy, but `getOrCreateChildState` will
|
|
185
190
|
// use the `copyMap` to recover the non-cloned rows, to avoid promoting the clone
|
|
186
191
|
// into a real row.
|
|
187
|
-
if (this.dirty && opts.refreshing) {
|
|
192
|
+
if ((this.dirty || this.hasNewEntity) && opts.refreshing) {
|
|
188
193
|
// When refreshing a dirty list, we need to preserve WIP values
|
|
189
194
|
// Start with current list, then merge in incoming changes
|
|
190
195
|
//
|
|
@@ -198,33 +203,44 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
198
203
|
const mergedItems = [];
|
|
199
204
|
// Index by idKey or a hash of the object, so we don't have to n^2 merging
|
|
200
205
|
const idKey = this.rows.length > 0 && this.rows[0].idKey;
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
// Look at our child config, i.e. `bookConfig` and find the non-id / non-list keys
|
|
207
|
+
const contentKeys = Object.entries(config.config)
|
|
208
|
+
.filter(([key, cfg]) => key !== idKey && cfg.type === "value")
|
|
209
|
+
.map(([key]) => key);
|
|
210
|
+
const hashByContent = (item) => (0, object_hash_1.default)(Object.fromEntries(Object.entries(item).filter(([key]) => contentKeys.includes(key))));
|
|
211
|
+
const hashById = (item) => (idKey && item[idKey]) || hashByContent(item);
|
|
212
|
+
const currentById = (0, utils_1.groupBy)(currentItems, hashById);
|
|
213
|
+
const incomingById = (0, utils_1.groupBy)(incomingItems, hashById);
|
|
205
214
|
// If our local instance doesn't assign in id, when we get results back from the server, strip
|
|
206
215
|
// their id and then see if we're the same based on data. Note that this is a hueristic, and will
|
|
207
216
|
// fail if the client-side has made additional changes to the child after submitting it to the server,
|
|
208
217
|
// or if the server acks back more/different data than the client sent.
|
|
209
218
|
//
|
|
210
219
|
// Only do this if one of our client-side objects is missing an id
|
|
211
|
-
const
|
|
220
|
+
const incomingByContent = idKey && currentItems.some((item) => !item[idKey]) && (0, utils_1.groupBy)(incomingItems, hashByContent);
|
|
212
221
|
// Watch for deletions
|
|
213
222
|
const hasOpKey = Object.keys(listConfig.config).includes("op");
|
|
214
223
|
// First, process all current items
|
|
215
224
|
for (const currentItem of currentItems) {
|
|
216
225
|
const childState = rowMap.get(currentItem); // We'll always have a child state for `currentItems`
|
|
217
226
|
// Try to find a matching item in the incoming data
|
|
218
|
-
const hash =
|
|
219
|
-
const match = ((_b = (_a = incomingById.get(hash)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b :
|
|
227
|
+
const hash = hashById(currentItem);
|
|
228
|
+
const match = ((_b = (_a = incomingById.get(hash)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b :
|
|
229
|
+
// Only if we don't have `book[id]` set, look for a match based on our content
|
|
230
|
+
(!!idKey &&
|
|
231
|
+
!currentItem[idKey] &&
|
|
232
|
+
!!incomingByContent &&
|
|
233
|
+
(
|
|
234
|
+
// If we don't have an id, our `hash` variable will already be the `hashByContent`, so we can reuse it
|
|
235
|
+
(_c = incomingByContent === null || incomingByContent === void 0 ? void 0 : incomingByContent.get(hash)) === null || _c === void 0 ? void 0 : _c[0])));
|
|
220
236
|
if (match) {
|
|
221
237
|
// Defer to set to handle not nuking WIP changes
|
|
222
238
|
childState.set(match, opts);
|
|
223
239
|
mergedItems.push(childState.value);
|
|
224
|
-
// Once matched, we don't this to be an addedRow anymore
|
|
240
|
+
// Once matched, we don't want this to be an addedRow anymore
|
|
225
241
|
addedRows.delete(currentItem);
|
|
226
242
|
}
|
|
227
|
-
else if (!childState.dirty && !addedRows.has(currentItem)) {
|
|
243
|
+
else if (!childState.dirty && !childState.isNewEntity && !addedRows.has(currentItem)) {
|
|
228
244
|
// Local is not dirty/added, and it's not upstream, so let it get removed
|
|
229
245
|
}
|
|
230
246
|
else if (hasOpKey && currentItem.op === "delete") {
|
|
@@ -243,7 +259,7 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
243
259
|
// Then, add any incoming items that don't exist in current
|
|
244
260
|
for (const incomingItem of incomingItems) {
|
|
245
261
|
// Look for both `incoming.id` and the incoming-sans-id for new entities
|
|
246
|
-
const match = ((_d = currentById.get(
|
|
262
|
+
const match = ((_d = currentById.get(hashById(incomingItem))) === null || _d === void 0 ? void 0 : _d[0]) || ((_e = currentById.get(hashByContent(incomingItem))) === null || _e === void 0 ? void 0 : _e[0]);
|
|
247
263
|
if (!match) {
|
|
248
264
|
// New item from server, add it
|
|
249
265
|
const childState = getOrCreateChildState(incomingItem, opts);
|
package/dist/formState.test.js
CHANGED
|
@@ -279,6 +279,77 @@ describe("formState", () => {
|
|
|
279
279
|
// And we're not dirty
|
|
280
280
|
expect(state.books.dirty).toBe(false);
|
|
281
281
|
});
|
|
282
|
+
it("list value can refresh and merge grand children that were added", () => {
|
|
283
|
+
// Given a list field that is currently populated with 1 child w/1 grandchild
|
|
284
|
+
const a1 = {
|
|
285
|
+
id: "a:1",
|
|
286
|
+
books: [{ id: "b:1", title: "b1", reviews: [{ id: "r:1", rating: 5 }] }],
|
|
287
|
+
};
|
|
288
|
+
const config = configBuilders_1.f.config({
|
|
289
|
+
id: configBuilders_1.f.value(),
|
|
290
|
+
books: configBuilders_1.f.list({
|
|
291
|
+
id: configBuilders_1.f.value(),
|
|
292
|
+
title: configBuilders_1.f.value().req(),
|
|
293
|
+
reviews: configBuilders_1.f.list({ id: configBuilders_1.f.value(), rating: configBuilders_1.f.value().req() }),
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
const state = (0, objectField_1.createObjectState)(config, a1);
|
|
297
|
+
// When we add a new book with two ratings
|
|
298
|
+
state.books.add({ title: "b2", reviews: [{ rating: 4 }, { rating: 3 }] });
|
|
299
|
+
// When we refresh but only the 1st review grandchild existed when we auto-saved
|
|
300
|
+
state.set({
|
|
301
|
+
books: [
|
|
302
|
+
{ id: "b:1", title: "b1" },
|
|
303
|
+
{
|
|
304
|
+
id: "b:2",
|
|
305
|
+
title: "b2",
|
|
306
|
+
reviews: [{ id: "r:2", rating: 4 }],
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
}, { refreshing: true });
|
|
310
|
+
// Then we still have two books
|
|
311
|
+
expect(state.books.value).toEqual([
|
|
312
|
+
{
|
|
313
|
+
id: "b:1",
|
|
314
|
+
title: "b1",
|
|
315
|
+
reviews: [{ id: "r:1", rating: 5 }],
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
id: "b:2",
|
|
319
|
+
title: "b2",
|
|
320
|
+
// And we stitched together the ack-d 1st review (now with an id assigned) and the 2nd WIP review
|
|
321
|
+
reviews: [{ id: "r:2", rating: 4 }, { rating: 3 }],
|
|
322
|
+
},
|
|
323
|
+
]);
|
|
324
|
+
// And we're still dirty
|
|
325
|
+
expect(state.books.dirty).toBe(true);
|
|
326
|
+
expect(state.books.rows[0].dirty).toBe(false);
|
|
327
|
+
expect(state.books.rows[1].dirty).toBe(true);
|
|
328
|
+
expect(state.books.rows[1].reviews.rows[0].dirty).toBe(false);
|
|
329
|
+
expect(state.books.rows[1].reviews.rows[1].dirty).toBe(false);
|
|
330
|
+
expect(state.books.rows[1].reviews.dirty).toBe(true);
|
|
331
|
+
// Until the 2nd auto-save completes
|
|
332
|
+
state.set({
|
|
333
|
+
books: [
|
|
334
|
+
{ id: "b:1", title: "b1" },
|
|
335
|
+
{
|
|
336
|
+
id: "b:2",
|
|
337
|
+
title: "b2",
|
|
338
|
+
reviews: [
|
|
339
|
+
{ id: "r:2", rating: 4 },
|
|
340
|
+
{ id: "r:3", rating: 3 },
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
}, { refreshing: true });
|
|
345
|
+
// Then we stitch together the 2nd review as well
|
|
346
|
+
expect(state.books.value).toMatchObject([
|
|
347
|
+
{ id: "b:1" },
|
|
348
|
+
{ id: "b:2", reviews: [{ id: "r:2" }, { id: "r:3", rating: 3 }] },
|
|
349
|
+
]);
|
|
350
|
+
// And we're no longer dirty
|
|
351
|
+
expect(state.books.dirty).toBe(false);
|
|
352
|
+
});
|
|
282
353
|
it("list value can observe changes", () => {
|
|
283
354
|
const b1 = { title: "t1" };
|
|
284
355
|
const a1 = { firstName: "a1", books: [b1] };
|
|
@@ -29,6 +29,11 @@ export interface BookInput {
|
|
|
29
29
|
delete?: boolean | null | undefined;
|
|
30
30
|
isPublished?: boolean;
|
|
31
31
|
op?: "include" | "delete" | "remove";
|
|
32
|
+
reviews?: ReviewInput[];
|
|
33
|
+
}
|
|
34
|
+
export interface ReviewInput {
|
|
35
|
+
id?: string | null | undefined;
|
|
36
|
+
rating?: number | null | undefined;
|
|
32
37
|
}
|
|
33
38
|
export interface DeweyDecimalClassification {
|
|
34
39
|
number: string;
|
|
@@ -146,11 +146,18 @@ describe("useFormState", () => {
|
|
|
146
146
|
};
|
|
147
147
|
const data2 = {
|
|
148
148
|
id: "a:1",
|
|
149
|
-
firstName: "f2",
|
|
149
|
+
firstName: "f2", // we'll have a local WIP change
|
|
150
150
|
lastName: "l2",
|
|
151
|
-
address: {
|
|
151
|
+
address: {
|
|
152
|
+
id: "address:1",
|
|
153
|
+
street: "s2", // we'll have a local WIP change
|
|
154
|
+
city: "c2",
|
|
155
|
+
},
|
|
152
156
|
books: [
|
|
153
|
-
{
|
|
157
|
+
{
|
|
158
|
+
id: "b:1",
|
|
159
|
+
title: "a2", // we'll have a local WIP change
|
|
160
|
+
},
|
|
154
161
|
{ id: "b:2", title: "b2" },
|
|
155
162
|
{ id: "b:3", title: "b3" },
|
|
156
163
|
],
|