@homebound/form-state 2.22.0 → 2.22.2

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.
@@ -19,6 +19,31 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
19
19
  ((_a = parentCopy[key]) !== null && _a !== void 0 ? _a : []).forEach((copy, i) => {
20
20
  copyMap.set(copy, parentInstance[key][i]);
21
21
  });
22
+ // Given a child POJO (or a copy/clone of a child POJO), return its ObjectState wrapper.
23
+ function getOrCreateChildState(child, opts) {
24
+ let childState = rowMap.get(child);
25
+ // If we're being reverted to our originalValue, i.e. values is actually
26
+ // a list of copies, use the copyMap to recover the non-copy original value
27
+ if (!childState && copyMap.has(child)) {
28
+ childState = rowMap.get(copyMap.get(child));
29
+ }
30
+ // Look for an existing child (requires having an id key configured)
31
+ if (!childState) {
32
+ for (const [, otherState] of rowMap.entries()) {
33
+ if (otherState.isSameEntity(child)) {
34
+ otherState.set(child, opts);
35
+ rowMap.set(child, otherState);
36
+ return otherState;
37
+ }
38
+ }
39
+ }
40
+ // If we didn't have an existing child, just make a new object state
41
+ if (!childState) {
42
+ childState = (0, objectField_1.newObjectState)(config, parentState, list, child, undefined, maybeAutoSave);
43
+ rowMap.set(child, childState);
44
+ }
45
+ return childState;
46
+ }
22
47
  const list = {
23
48
  key: key,
24
49
  // Our fundamental state of wrapped Us
@@ -75,15 +100,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
75
100
  (0, utils_1.fail)();
76
101
  // Avoid using `this.value` to avoid registering `_childTick` as a dependency
77
102
  const value = parentInstance[key];
78
- return (value || []).map((child) => {
79
- // Because we're reading from this.value, child will be the proxy version
80
- let childState = rowMap.get(child);
81
- if (!childState) {
82
- childState = (0, objectField_1.newObjectState)(config, parentState, list, child, undefined, maybeAutoSave);
83
- rowMap.set(child, childState);
84
- }
85
- return childState;
86
- });
103
+ return (value || []).map((child) => getOrCreateChildState(child));
87
104
  },
88
105
  // TODO Should this be true when all rows are touched?
89
106
  get touched() {
@@ -144,30 +161,12 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
144
161
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
145
162
  throw new Error(`${String(key)} is currently readOnly`);
146
163
  }
147
- // We should be passed values that are non-proxies.
148
- parentInstance[key] = values.map((value) => {
149
- let childState = rowMap.get(value);
150
- // If we're being reverted to our originalValue, i.e. values is actually
151
- // a list of copies, use the copyMap to recover our child states
152
- if (!childState && copyMap.has(value)) {
153
- childState = rowMap.get(copyMap.get(value));
154
- }
155
- if (!childState) {
156
- // Look for an existing child (requires having an id key configured)
157
- for (const [, otherState] of rowMap.entries()) {
158
- if (otherState.isSameEntity(value)) {
159
- otherState.set(value, opts);
160
- rowMap.set(value, otherState);
161
- return otherState.value;
162
- }
163
- }
164
- // If we didn't have an existing child, just make a new object state
165
- childState = (0, objectField_1.createObjectState)(config, value, { maybeAutoSave });
166
- rowMap.set(value, childState);
167
- }
168
- // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
169
- return childState.value;
170
- });
164
+ // Given the values, see if we've got existing child states, and use their
165
+ // value if so. I.e. this covers revertChanges doing `set(originalValue)` and
166
+ // passing us cloned rows from the parentCopy, but `getOrCreateChildState` will
167
+ // use the `copyMap` to recover the non-cloned rows, to avoid promoting the clone
168
+ // into a real row.
169
+ parentInstance[key] = values.map((child) => getOrCreateChildState(child, opts).value);
171
170
  // Make sure to tick first so that `setOriginalValue` sees the latest `rows`
172
171
  _tick.value++;
173
172
  // Reset originalCopy so that our dirty checks have the right # of rows.
@@ -188,12 +188,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
188
188
  // An internal helper method to see if `other` is for "the same entity" as our current row
189
189
  isSameEntity(other) {
190
190
  const idField = getFields(this).find((f) => f._isIdKey);
191
- if (!idField) {
191
+ if (!idField)
192
192
  return false;
193
- }
194
- // If the otherIdValue is undefined, it's a new entity so can't be the same as us
193
+ const ourIdValue = idField.value;
195
194
  const otherIdValue = other[idField.key];
196
- return otherIdValue !== undefined && otherIdValue === this[idField.key].value;
195
+ // If the otherIdValue is undefined, it's a new entity so can't be the same as us
196
+ return otherIdValue !== undefined && otherIdValue === ourIdValue;
197
197
  },
198
198
  };
199
199
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
@@ -965,12 +965,14 @@ describe("formState", () => {
965
965
  const formState = (0, objectField_1.createObjectState)({
966
966
  firstName: { type: "value" },
967
967
  lastName: { type: "value" },
968
- fullName: { type: "value", computed: true },
968
+ fullName: { type: "value" },
969
969
  }, new ObservableObject());
970
970
  expect(formState.firstName.value).toEqual("first");
971
971
  expect(formState.fullName.value).toEqual("first last");
972
+ expect(formState.fullName.dirty).toEqual(false);
972
973
  formState.firstName.value = "change";
973
974
  expect(formState.fullName.value).toEqual("change last");
975
+ expect(formState.fullName.dirty).toEqual(true);
974
976
  formState.revertChanges();
975
977
  expect(formState.firstName.value).toEqual("first");
976
978
  });
@@ -1524,6 +1526,25 @@ describe("formState", () => {
1524
1526
  expect(a.id._isIdKey).toBe(false);
1525
1527
  expect(a.otherId._isIdKey).toBe(true);
1526
1528
  });
1529
+ it("uses id key to recognize same entities", () => {
1530
+ // With b1 having an id
1531
+ const b1 = (0, objectField_1.createObjectState)({ id: { type: "value" } }, { id: "b:1" });
1532
+ expect(b1.isSameEntity({ id: "b:1" })).toBe(true);
1533
+ expect(b1.isSameEntity({ id: "b:2" })).toBe(false);
1534
+ expect(b1.isSameEntity({ id: undefined })).toBe(false);
1535
+ expect(b1.isSameEntity({})).toBe(false);
1536
+ // With b2 having no id
1537
+ const b2 = (0, objectField_1.createObjectState)({ id: { type: "value" } }, {});
1538
+ expect(b2.isSameEntity({ id: "b:1" })).toBe(false);
1539
+ expect(b2.isSameEntity({ id: undefined })).toBe(false);
1540
+ expect(b2.isSameEntity({})).toBe(false);
1541
+ // With b3 having no id key
1542
+ const b3 = (0, objectField_1.createObjectState)({ title: { type: "value" } }, { title: "b3" });
1543
+ expect(b3.isSameEntity({ title: "b3" })).toBe(false);
1544
+ // With b4 having a different id key
1545
+ const b4 = (0, objectField_1.createObjectState)({ title: { type: "value", isIdKey: true } }, { title: "b4" });
1546
+ expect(b4.isSameEntity({ title: "b4" })).toBe(true);
1547
+ });
1527
1548
  });
1528
1549
  class ObservableObject {
1529
1550
  constructor() {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { ObjectConfig } from "./config";
2
2
  export { f } from "./configBuilders";
3
+ export { FragmentField } from "./fields/fragmentField";
3
4
  export { ListFieldState } from "./fields/listField";
4
- export { ObjectState, createObjectState } from "./fields/objectField";
5
+ export { Fragment, ObjectState, createObjectState, fragment } from "./fields/objectField";
5
6
  export { FieldState, ValueAdapter } from "./fields/valueField";
6
7
  export { Rule, required } from "./rules";
7
8
  export { useFormState } from "./useFormState";
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = exports.f = void 0;
3
+ exports.useFormStates = exports.useFormState = exports.required = exports.fragment = exports.createObjectState = exports.f = void 0;
4
4
  var configBuilders_1 = require("./configBuilders");
5
5
  Object.defineProperty(exports, "f", { enumerable: true, get: function () { return configBuilders_1.f; } });
6
6
  var objectField_1 = require("./fields/objectField");
7
7
  Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return objectField_1.createObjectState; } });
8
+ Object.defineProperty(exports, "fragment", { enumerable: true, get: function () { return objectField_1.fragment; } });
8
9
  var rules_1 = require("./rules");
9
10
  Object.defineProperty(exports, "required", { enumerable: true, get: function () { return rules_1.required; } });
10
11
  var useFormState_1 = require("./useFormState");
@@ -244,9 +244,11 @@ describe("useFormState", () => {
244
244
  // Given a component
245
245
  // And it's using a class/mobx proxy as the basis for the data
246
246
  class AuthorRow {
247
- constructor(firstName, lastName) {
247
+ constructor(firstName, lastName, books) {
248
248
  this.firstName = firstName;
249
249
  this.lastName = lastName;
250
+ this.books = books;
251
+ (0, mobx_1.makeAutoObservable)(this);
250
252
  }
251
253
  get fullName() {
252
254
  return this.firstName + " " + this.lastName;
@@ -254,27 +256,32 @@ describe("useFormState", () => {
254
256
  }
255
257
  function TestComponent() {
256
258
  // And we have two sets of data
257
- const data1 = { firstName: "f1", lastName: "l1" };
258
- const data2 = { firstName: "f2", lastName: "l2" };
259
+ const data1 = { firstName: "f1", lastName: "l1", books: [] };
260
+ const data2 = { firstName: "f2", lastName: "l2", books: [{ title: "t1" }] };
259
261
  // And we start out with data1
260
262
  const [data, setData] = (0, react_2.useState)(data1);
261
- const author = (0, react_2.useMemo)(() => new AuthorRow(data.firstName, data.lastName), [data]);
263
+ const author = (0, react_2.useMemo)(() => new AuthorRow(data.firstName, data.lastName, data.books), [data]);
262
264
  const config = (0, react_2.useMemo)(() => ({
263
265
  firstName: { type: "value" },
264
266
  lastName: { type: "value" },
265
267
  fullName: { type: "value", computed: true },
268
+ books: { type: "list", config: { title: { type: "value" } } },
266
269
  }), []);
267
270
  const form = (0, useFormState_1.useFormState)({ config, init: { input: author, map: (a) => a } });
268
- return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "firstName", children: form.firstName.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "fullName", children: form.fullName.value }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => setData(data2) })] })) }));
271
+ return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "firstName", children: form.firstName.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "fullName", children: form.fullName.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "booksLength", children: form.books.rows.length }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "booksDirty", children: String(form.books.touched) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => setData(data2) })] })) }));
269
272
  }
270
273
  // And we start out with the initial query data
271
274
  const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
272
275
  expect(r.firstName.textContent).toEqual("f1");
273
276
  expect(r.fullName.textContent).toEqual("f1 l1");
277
+ expect(r.booksLength.textContent).toEqual("0");
278
+ expect(r.booksDirty.textContent).toEqual("false");
274
279
  // When the new query is ran i.e. due to a cache refresh
275
280
  (0, rtl_utils_1.click)(r.refreshData);
276
281
  expect(r.firstName.textContent).toEqual("f2");
277
282
  expect(r.fullName.textContent).toEqual("f2 l2");
283
+ expect(r.booksLength.textContent).toEqual("1");
284
+ expect(r.booksDirty.textContent).toEqual("false");
278
285
  });
279
286
  it("can trigger auto save for fields in list that were initially undefined", async () => {
280
287
  const autoSave = jest.fn();
package/dist/utils.js CHANGED
@@ -133,7 +133,7 @@ function deepClone(obj, map = new WeakMap()) {
133
133
  return map.get(obj);
134
134
  const result = Array.isArray(obj) ? [] : {};
135
135
  map.set(obj, result);
136
- Object.assign(result, ...Object.keys(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
136
+ Object.assign(result, ...getAllPropertyNames(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
137
137
  return result;
138
138
  }
139
139
  else {
@@ -141,3 +141,10 @@ function deepClone(obj, map = new WeakMap()) {
141
141
  }
142
142
  }
143
143
  exports.deepClone = deepClone;
144
+ /** Returns all property names, including mobx computeds (non-enumerable) & inherited. */
145
+ function getAllPropertyNames(obj) {
146
+ const proto = Object.getPrototypeOf(obj);
147
+ // Don't crawl up into Object.prototype, or into arrays/observable arrays
148
+ const inherited = proto && proto !== Object.prototype && !Array.isArray(obj) ? getAllPropertyNames(proto) : [];
149
+ return [...new Set(Object.getOwnPropertyNames(obj).concat(inherited))];
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.22.0",
3
+ "version": "2.22.2",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",