@homebound/form-state 2.26.9 → 2.26.11

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.
@@ -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
- function newListFieldState(parentCopy, parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
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 parent list" -> "that item's ObjectState"
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,89 @@ 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
- parentInstance[key] = (values !== null && values !== void 0 ? values : []).map((child) => getOrCreateChildState(child, opts).value);
177
- // Make sure to tick first so that `setOriginalValue` sees the latest `rows`
178
- _tick.value++;
179
- // Reset originalCopy so that our dirty checks have the right # of rows.
180
- if (opts.refreshing) {
181
- this.setOriginalValue();
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
+ // Watch for deletions
212
+ const hasOpKey = Object.keys(listConfig.config).includes("op");
213
+ // First, process all current items
214
+ for (const currentItem of currentItems) {
215
+ const childState = rowMap.get(currentItem); // We'll always have a child state for `currentItems`
216
+ // Try to find a matching item in the incoming data
217
+ const hash = hashItem(currentItem);
218
+ 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])));
219
+ if (match) {
220
+ // Defer to set to handle not nuking WIP changes
221
+ childState.set(match, opts);
222
+ mergedItems.push(childState.value);
223
+ // Once matched, we don't this to be an addedRow anymore
224
+ addedRows.delete(currentItem);
225
+ }
226
+ else if (childState.dirty || addedRows.has(currentItem)) {
227
+ // If no incoming, but we're dirty (...and not deleted), keep the WIP change
228
+ if (hasOpKey && currentItem.op === "delete") {
229
+ // delete
230
+ }
231
+ else {
232
+ mergedItems.push(currentItem);
233
+ }
234
+ }
235
+ else {
236
+ // Local is not dirty, and it's not upstream, so remove it
237
+ }
238
+ }
239
+ // Then, add any incoming items that don't exist in current
240
+ for (const incomingItem of incomingItems) {
241
+ // Look for both `incoming.id` and the incoming-sans-id for new entities
242
+ 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]);
243
+ if (!match) {
244
+ // New item from server, add it
245
+ const childState = getOrCreateChildState(incomingItem, opts);
246
+ mergedItems.push(childState.value);
247
+ }
248
+ }
249
+ parentInstance[key] = mergedItems;
250
+ _tick.value++;
251
+ // Set original to not merged...
252
+ this.setOriginalValue(incomingItems);
253
+ }
254
+ else {
255
+ parentInstance[key] = (values !== null && values !== void 0 ? values : []).map((child) => getOrCreateChildState(child, opts).value);
256
+ _tick.value++;
257
+ // Reset originalCopy so that our dirty checks have the right # of rows.
258
+ if (opts.refreshing) {
259
+ this.setOriginalValue();
260
+ }
182
261
  }
183
262
  },
184
263
  add(value, spliceIndex) {
185
264
  // This is called by the user, so value should be a non-proxy value we should keep
186
265
  const childState = getOrCreateChildState(value);
187
- // const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
188
266
  rowMap.set(value, childState);
267
+ // Let `.set` know this is a new row
268
+ addedRows.add(value);
189
269
  this.ensureSet();
190
270
  this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
191
271
  _tick.value++;
@@ -223,9 +303,9 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
223
303
  return value !== null && value !== void 0 ? value : [];
224
304
  },
225
305
  // This should only be called when value === originalValue
226
- setOriginalValue() {
306
+ setOriginalValue(incomingItems) {
227
307
  // Use the rows' originalValues to update the parentCopy
228
- parentCopy[key] = this.rows.map((r) => r.originalValue);
308
+ parentCopy[key] = incomingItems !== null && incomingItems !== void 0 ? incomingItems : this.rows.map((r) => r.originalValue);
229
309
  parentCopy[key].forEach((copy, i) => {
230
310
  copyMap.set(copy, parentInstance[key][i]);
231
311
  });
@@ -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
@@ -165,6 +165,99 @@ 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
+ });
208
+ it("list value can refresh and not drop new children that are just data", () => {
209
+ const config = configBuilders_1.f.config({
210
+ firstName: configBuilders_1.f.value(),
211
+ tags: configBuilders_1.f.list({ combo: configBuilders_1.f.value() }),
212
+ });
213
+ const state = (0, objectField_1.createObjectState)(config, { firstName: "a1", tags: [] });
214
+ // And we add a new child on the client-side (and it doesn't have an id)
215
+ state.tags.add({ combo: ["b1", "b2"] });
216
+ // When we refresh it with the original list undefined
217
+ state.set({ tags: [] }, { refreshing: true });
218
+ // Then it keeps the new child
219
+ expect(state.tags.value).toEqual([{ combo: ["b1", "b2"] }]);
220
+ // And it's still considered changed
221
+ expect(state.tags.dirty).toBe(true);
222
+ expect(state.tags.originalValue).toEqual([]);
223
+ // And we add an additional child
224
+ state.tags.add({ combo: ["c1", "c2"] });
225
+ // And a refresh brings back the 1st child acked from the server
226
+ state.set({ tags: [{ combo: ["b1", "b2"] }] }, { refreshing: true });
227
+ // Then we still have 2 children
228
+ expect(state.tags.value).toEqual([{ combo: ["b1", "b2"] }, { combo: ["c1", "c2"] }]);
229
+ // And we're still dirty
230
+ expect(state.tags.dirty).toBe(true);
231
+ // When the server finally acks both children
232
+ state.set({ tags: [{ combo: ["b1", "b2"] }, { combo: ["c1", "c2"] }] }, { refreshing: true });
233
+ // Then we're finally clean
234
+ expect(state.tags.dirty).toBe(false);
235
+ });
236
+ it("list value can refresh and drop children that were deleted", () => {
237
+ // Given a list field that is currently populated
238
+ const a1 = {
239
+ id: "a:1",
240
+ firstName: "a1",
241
+ books: [
242
+ { id: "b:1", title: "b1", op: "include" },
243
+ { id: "b:2", title: "b2", op: "include" },
244
+ ],
245
+ };
246
+ // And incremental deletion is enabled
247
+ const config = configBuilders_1.f.config({
248
+ id: configBuilders_1.f.value(),
249
+ books: configBuilders_1.f.list({ id: configBuilders_1.f.value(), title: configBuilders_1.f.value(), op: configBuilders_1.f.value() }),
250
+ });
251
+ const state = (0, objectField_1.createObjectState)(config, a1);
252
+ // When we delete the 1st book
253
+ state.books.rows[0].op.set("delete");
254
+ // When we refresh it only the 2nd book
255
+ state.set({ books: [{ id: "b:2", title: "b2" }] }, { refreshing: true });
256
+ // Then we only have 1 book
257
+ expect(state.books.value).toEqual([{ id: "b:2", title: "b2", op: "include" }]);
258
+ // And we're not dirty
259
+ expect(state.books.dirty).toBe(false);
260
+ });
168
261
  it("list value can observe changes", () => {
169
262
  const b1 = { title: "t1" };
170
263
  const a1 = { firstName: "a1", books: [b1] };
@@ -1743,6 +1836,7 @@ const authorWithBooksConfig = configBuilders_1.f.config({
1743
1836
  id: configBuilders_1.f.value(),
1744
1837
  title: configBuilders_1.f.value().req(),
1745
1838
  classification: configBuilders_1.f.value(),
1839
+ isPublished: configBuilders_1.f.value(),
1746
1840
  }),
1747
1841
  });
1748
1842
  function createAuthorInputState(input, maybeAutoSave) {
@@ -218,7 +218,7 @@ describe("useFormState", () => {
218
218
  id: "a:1",
219
219
  });
220
220
  });
221
- it("useFormState can accept new data while read only", async () => {
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("useFormState can accept new data with computed fields", async () => {
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.9",
3
+ "version": "2.26.11",
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
- "is-plain-object": "^5.0.0"
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",