@homebound/form-state 2.22.3 → 2.22.5

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.
@@ -31,7 +31,9 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
31
31
  if (!childState) {
32
32
  for (const [, otherState] of rowMap.entries()) {
33
33
  if (otherState.isSameEntity(child)) {
34
- otherState.set(child, opts);
34
+ // If we're being called from `list.rows`, we should be careful to not trigger mutations
35
+ if (!(opts === null || opts === void 0 ? void 0 : opts.skipSet))
36
+ otherState.set(child, opts);
35
37
  rowMap.set(child, otherState);
36
38
  return otherState;
37
39
  }
@@ -71,6 +73,9 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
71
73
  get dirty() {
72
74
  return this.rows.some((r) => r.dirty) || this.hasChanged();
73
75
  },
76
+ get focused() {
77
+ return this.rows.some((r) => r.focused);
78
+ },
74
79
  get required() {
75
80
  return this.rules.some((rule) => rule === rules_1.required);
76
81
  },
@@ -100,7 +105,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
100
105
  (0, utils_1.fail)();
101
106
  // Avoid using `this.value` to avoid registering `_childTick` as a dependency
102
107
  const value = parentInstance[key];
103
- return (value || []).map((child) => getOrCreateChildState(child));
108
+ return (value || []).map((child) => getOrCreateChildState(child, { skipSet: true }));
104
109
  },
105
110
  // TODO Should this be true when all rows are touched?
106
111
  get touched() {
@@ -97,6 +97,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
97
97
  const readOnlyField = getFields(this).find((f) => f._isReadOnlyKey);
98
98
  return !!(readOnlyField === null || readOnlyField === void 0 ? void 0 : readOnlyField.value);
99
99
  },
100
+ get focused() {
101
+ return getFields(this).some((f) => f.focused);
102
+ },
100
103
  get touched() {
101
104
  return getFields(this).some((f) => f.touched);
102
105
  },
@@ -22,6 +22,7 @@ export interface FieldState<V> {
22
22
  readonly required: boolean;
23
23
  readonly dirty: boolean;
24
24
  readonly valid: boolean;
25
+ readonly focused: boolean;
25
26
  readonly isNewEntity: boolean;
26
27
  rules: Rule<V>[];
27
28
  readonly errors: string[];
@@ -69,6 +70,5 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
69
70
  _isIdKey: boolean;
70
71
  _isDeleteKey: boolean;
71
72
  _isReadOnlyKey: boolean;
72
- _focused: boolean;
73
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>;
@@ -17,9 +17,9 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
17
17
  /** Current readOnly value. */
18
18
  _readOnly: readOnly || false,
19
19
  _loading: false,
20
- _focused: false,
21
20
  // Expose so computed can be skipped in changedValue
22
21
  _computed: computed,
22
+ _focused: false,
23
23
  _isIdKey: isIdKey,
24
24
  _isDeleteKey: isDeleteKey,
25
25
  _isReadOnlyKey: isReadOnlyKey,
@@ -35,6 +35,9 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
35
35
  set value(v) {
36
36
  this.set(v);
37
37
  },
38
+ get focused() {
39
+ return this._focused;
40
+ },
38
41
  get dirty() {
39
42
  return !(0, utils_1.areEqual)(this.originalValue, this.value, strictOrder);
40
43
  },
@@ -167,6 +170,16 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
167
170
  }
168
171
  },
169
172
  };
173
+ // If we're wrapping a mobx observer, watching for external mutations, i.e. from callers not
174
+ // going through our FieldState.set/FieldState.value setters.
175
+ if ((0, mobx_1.isObservable)(parentInstance)) {
176
+ (0, mobx_1.reaction)(() => parentInstance[key], () => {
177
+ // Don't auto-save on maybe-external mutation if our field, or another other field (potentially a computed),
178
+ // is currently focused, because then it's probably just us doing the writes through `Bound...Field` components.
179
+ if (!parentState().focused)
180
+ maybeAutoSave();
181
+ });
182
+ }
170
183
  return field;
171
184
  }
172
185
  exports.newValueFieldState = newValueFieldState;
@@ -442,6 +442,20 @@ describe("formState", () => {
442
442
  a1.books.remove(0);
443
443
  expect(a1.dirty).toBeFalsy();
444
444
  });
445
+ it("handles list fields with duplicate ids", () => {
446
+ // Given an author that incorrectly has duplicate books
447
+ const a1 = createAuthorInputState({
448
+ books: [
449
+ { id: "b:1", title: "b1" },
450
+ { id: "b:1", title: "b2" },
451
+ ],
452
+ });
453
+ // Then it didn't blow up
454
+ expect(a1.books.dirty).toBeFalsy();
455
+ // And the 1st book won
456
+ expect(a1.books.rows[0].title.value).toBe("b1");
457
+ expect(a1.books.rows[1].title.value).toBe("b1");
458
+ });
445
459
  it("calls maybeAutoSave when adding or removing to a list", () => {
446
460
  const maybeAutoSave = jest.fn();
447
461
  const a1 = createAuthorInputState({ books: [] }, maybeAutoSave);
@@ -283,6 +283,32 @@ describe("useFormState", () => {
283
283
  expect(r.booksLength.textContent).toEqual("1");
284
284
  expect(r.booksDirty.textContent).toEqual("false");
285
285
  });
286
+ it("can trigger auto-save when underlying observable is changed", async () => {
287
+ // Given a component
288
+ // And it's using a class/mobx proxy as the basis for the data
289
+ class AuthorRow {
290
+ constructor(firstName) {
291
+ this.firstName = firstName;
292
+ (0, mobx_1.makeAutoObservable)(this);
293
+ }
294
+ }
295
+ const autoSave = jest.fn();
296
+ function TestComponent() {
297
+ // And the author starts out with f1
298
+ const author = (0, react_2.useMemo)(() => new AuthorRow("f1"), []);
299
+ const config = (0, react_2.useMemo)(() => ({ firstName: { type: "value" } }), []);
300
+ const form = (0, useFormState_1.useFormState)({ config, init: { input: author, map: (a) => a }, autoSave });
301
+ return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("button", { "data-testid": "changeFirstName", onClick: () => (author.firstName = "f2") }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "clearFirstName", onClick: () => (author.firstName = undefined) })] }));
302
+ }
303
+ // When we change the underlying observable
304
+ const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
305
+ expect(autoSave).toBeCalledTimes(0);
306
+ await (0, rtl_utils_1.clickAndWait)(r.changeFirstName);
307
+ // Then auto save is triggered
308
+ expect(autoSave).toBeCalledTimes(1);
309
+ await (0, rtl_utils_1.clickAndWait)(r.clearFirstName);
310
+ expect(autoSave).toBeCalledTimes(2);
311
+ });
286
312
  it("can trigger auto save for fields in list that were initially undefined", async () => {
287
313
  const autoSave = jest.fn();
288
314
  // Given a component
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.22.3",
3
+ "version": "2.22.5",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",