@homebound/form-state 2.27.2 → 2.28.0

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/config.d.ts CHANGED
@@ -28,6 +28,8 @@ export type ValueFieldConfig<V> = {
28
28
  isDeleteKey?: boolean;
29
29
  /** If true, the entity that contains this value will be treated as read only. */
30
30
  isReadOnlyKey?: boolean;
31
+ /** If true, we ignore field in dirty checks. */
32
+ isLocalOnly?: boolean;
31
33
  /**
32
34
  * Marks a field as being backed by a mobx class computed field.
33
35
  *
@@ -47,6 +47,8 @@ export declare class ValueFieldConfigBuilder<V> {
47
47
  req(): this;
48
48
  /** Marks the field as read only. */
49
49
  readOnly(): this;
50
+ /** Marks the field as local only. */
51
+ localOnly(): this;
50
52
  /** Appends `rules` to the field's validation rules. */
51
53
  rules(rules: Rule<V | null | undefined>[]): this;
52
54
  /** Marks the field as computed. */
@@ -63,6 +63,11 @@ class ValueFieldConfigBuilder {
63
63
  this.config.readOnly = true;
64
64
  return this;
65
65
  }
66
+ /** Marks the field as local only. */
67
+ localOnly() {
68
+ this.config.isLocalOnly = true;
69
+ return this;
70
+ }
66
71
  /** Appends `rules` to the field's validation rules. */
67
72
  rules(rules) {
68
73
  var _a;
@@ -16,6 +16,7 @@ function newFragmentField(parentInstance, key) {
16
16
  _isIdKey: false,
17
17
  _isDeleteKey: false,
18
18
  _isReadOnlyKey: false,
19
+ _isLocalOnly: false,
19
20
  touched: false,
20
21
  valid: true,
21
22
  readOnly: true,
@@ -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
- const hashItem = (item) => (idKey && item[idKey]) || (0, object_hash_1.default)(item);
202
- const hashWithoutId = (item) => (0, object_hash_1.default)(Object.fromEntries(Object.entries(item).filter(([key]) => key !== idKey)));
203
- const currentById = (0, utils_1.groupBy)(currentItems, hashItem);
204
- const incomingById = (0, utils_1.groupBy)(incomingItems, hashItem);
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 incomingByHash = idKey && currentItems.some((item) => !item[idKey]) && (0, utils_1.groupBy)(incomingItems, hashWithoutId);
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 = hashItem(currentItem);
219
- 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])));
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(hashItem(incomingItem))) === null || _d === void 0 ? void 0 : _d[0]) || ((_e = currentById.get(hashWithoutId(incomingItem))) === null || _e === void 0 ? void 0 : _e[0]);
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);
@@ -50,7 +50,7 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
50
50
  // Default the id key to "id" unless some other field has isIdKey set
51
51
  (key === "id" &&
52
52
  !("isIdKey" in config) && // Allow setting `isIdKey=false`
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
+ !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, config.isLocalOnly || false, (_a = config.computed) !== null && _a !== void 0 ? _a :
54
54
  // If instance is a mobx class, we can detect computeds as they won't be enumerable
55
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);
56
56
  }
@@ -138,7 +138,7 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
138
138
  return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
139
139
  },
140
140
  get dirty() {
141
- return (getFields(this).some((f) => f.dirty) ||
141
+ return (getFields(this).some((f) => f.dirty && !f._isLocalOnly) ||
142
142
  // `isUnset` checks if our `parent[key] === undefined`, which can mean "surely we're dirty",
143
143
  // but as long as we've got some keys actually set
144
144
  (this.isUnset() && !(0, utils_1.areEqual)(this.value, {})));
@@ -212,6 +212,9 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
212
212
  // References only include the id key below
213
213
  if (config.reference)
214
214
  return;
215
+ // Ignore local-only fields
216
+ if (f._isLocalOnly)
217
+ return;
215
218
  if (f.dirty ||
216
219
  // If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
217
220
  // look dirty, but if we're new we should include them anyway.
@@ -70,5 +70,6 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
70
70
  _isIdKey: boolean;
71
71
  _isDeleteKey: boolean;
72
72
  _isReadOnlyKey: boolean;
73
+ _isLocalOnly: boolean;
73
74
  }
74
- export declare function newValueFieldState<T, K extends keyof T>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T[K] | null | undefined>;
75
+ export declare function newValueFieldState<T, K extends keyof T>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, isLocalOnly: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T[K] | null | undefined>;
@@ -6,7 +6,7 @@ const mobx_1 = require("mobx");
6
6
  const proxies_1 = require("../proxies");
7
7
  const rules_1 = require("../rules");
8
8
  const utils_1 = require("../utils");
9
- function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
9
+ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, isLocalOnly, computed, readOnly, strictOrder, maybeAutoSave) {
10
10
  // Because we read/write the value directly back into parentInstance[key],
11
11
  // which itself is not a proxy, we use this as our "value changed" trigger.
12
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
@@ -31,6 +31,7 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
31
31
  _isIdKey: isIdKey,
32
32
  _isDeleteKey: isDeleteKey,
33
33
  _isReadOnlyKey: isReadOnlyKey,
34
+ _isLocalOnly: isLocalOnly,
34
35
  rules,
35
36
  get value() {
36
37
  // If we're wrapping a mobx store, then we'll get reactivity from parentInstance[key]
@@ -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] };
@@ -569,6 +640,33 @@ describe("formState", () => {
569
640
  a1.books.remove(0);
570
641
  expect(a1.dirty).toBeFalsy();
571
642
  });
643
+ it("can ignore isLocalOnly fields as not dirty", () => {
644
+ // Given we have an `isPublished` field that is marked as local only
645
+ const config = {
646
+ id: { type: "value" },
647
+ books: {
648
+ type: "list",
649
+ config: {
650
+ id: { type: "value" },
651
+ isPublished: { type: "value", isLocalOnly: true },
652
+ },
653
+ },
654
+ };
655
+ // And it starts out true
656
+ const a1 = (0, objectField_1.createObjectState)(config, {
657
+ id: "a:1",
658
+ books: [{ id: "b:1", isPublished: true }],
659
+ });
660
+ // When we change the value to false
661
+ a1.books.rows[0].isPublished.value = false;
662
+ // Then the field itself is dirty
663
+ expect(a1.books.rows[0].isPublished.dirty).toBe(true);
664
+ // But object is not
665
+ expect(a1.dirty).toBe(false);
666
+ expect(a1.books.dirty).toBe(false);
667
+ // And we don't include it in changedValue
668
+ expect(a1.changedValue).toEqual({ id: "a:1" });
669
+ });
572
670
  it("handles list fields with duplicate ids", () => {
573
671
  // Given an author that incorrectly has duplicate books
574
672
  const a1 = createAuthorInputState({
@@ -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: { id: "address:1", street: "s2", city: "c2" },
151
+ address: {
152
+ id: "address:1",
153
+ street: "s2", // we'll have a local WIP change
154
+ city: "c2",
155
+ },
152
156
  books: [
153
- { id: "b:1", title: "a2" },
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
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.27.2",
3
+ "version": "2.28.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",