@homebound/form-state 2.21.0 → 2.22.1

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.
@@ -3,9 +3,9 @@ import { ObjectState } from "./objectField";
3
3
  import { FieldState } from "./valueField";
4
4
  import { Rule } from "../rules";
5
5
  /** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
6
- export interface ListFieldState<U> extends Omit<FieldState<U[]>, "originalValue"> {
6
+ export interface ListFieldState<U> extends FieldState<U[]> {
7
7
  readonly rows: ReadonlyArray<ObjectState<U>>;
8
8
  add(value: U, index?: number): void;
9
9
  remove(indexOrValue: number | U): void;
10
10
  }
11
- export declare function newListFieldState<T, K extends keyof T, U>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
11
+ export declare function newListFieldState<T, K extends keyof T, U>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
@@ -5,13 +5,20 @@ const mobx_1 = require("mobx");
5
5
  const objectField_1 = require("./objectField");
6
6
  const rules_1 = require("../rules");
7
7
  const utils_1 = require("../utils");
8
- function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
8
+ function newListFieldState(parentCopy, parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
9
+ var _a;
9
10
  // Keep a map of "item in the parent list" -> "that item's ObjectState"
10
11
  const rowMap = new Map();
11
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
13
+ const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
12
14
  const _childTick = (0, mobx_1.observable)({ value: 1 });
13
- // this is for dirty checking, not object identity
14
- let originalCopy = [...(parentInstance[key] || [])];
15
+ // When child rows don't have ids (i.e. for new rows that aren't saved yet), we need an id-less
16
+ // "clone <-> current" map to tell if we're dirty or not, i.e. whether `parentInstance[key]` has
17
+ // drifted from `parentCopy[key]`
18
+ const copyMap = new Map();
19
+ ((_a = parentCopy[key]) !== null && _a !== void 0 ? _a : []).forEach((copy, i) => {
20
+ copyMap.set(copy, parentInstance[key][i]);
21
+ });
15
22
  const list = {
16
23
  key: key,
17
24
  // Our fundamental state of wrapped Us
@@ -46,22 +53,19 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
46
53
  return parentState().isNewEntity;
47
54
  },
48
55
  // private
56
+ /** Returns whether a row has been added/removed. */
49
57
  hasChanged() {
50
- const [current, original] = [this.value || [], originalCopy || []];
51
- if (current.length !== original.length) {
58
+ const [current, original] = [this.value || [], this.originalValue];
59
+ if (current.length !== original.length)
52
60
  return true;
53
- }
54
61
  if (strictOrder) {
55
- return original.some((e, idx) => {
56
- const originalState = rowMap.get(e);
57
- const currentState = rowMap.get(current[idx]);
58
- return originalState !== currentState;
59
- });
62
+ // With strict order, every copy[i] === original[i] must be true
63
+ return original.some((e, idx) => current[idx] !== copyMap.get(e));
64
+ }
65
+ else {
66
+ // With loose order, we just want every copy to still have its original somewhere
67
+ return !original.every((e) => current.includes(copyMap.get(e)));
60
68
  }
61
- return !original.every((e) => {
62
- const state = rowMap.get(e);
63
- return current.some((e) => rowMap.get(e) === state);
64
- });
65
69
  },
66
70
  // And we can derive each value's ObjectState wrapper as needed from the rowMap cache
67
71
  get rows() {
@@ -92,7 +96,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
92
96
  get valid() {
93
97
  const value = this.rows;
94
98
  // TODO Passing `originalCopy || []` is probably not 100% right
95
- const opts = { value, key: key, originalValue: originalCopy || [], object: parentState() };
99
+ const opts = { value, key: key, originalValue: this.originalValue, object: parentState() };
96
100
  const collectionValid = this.rules.every((r) => r(opts) === undefined);
97
101
  const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
98
102
  return collectionValid && entriesValid;
@@ -100,7 +104,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
100
104
  get errors() {
101
105
  if (_tick.value < 0)
102
106
  (0, utils_1.fail)();
103
- const opts = { value: this.rows, key: key, originalValue: originalCopy || [], object: parentState() };
107
+ const opts = { value: this.rows, key: key, originalValue: this.originalValue, object: parentState() };
104
108
  return this.rules.map((r) => r(opts)).filter(utils_1.isNotUndefined);
105
109
  },
106
110
  get changedValue() {
@@ -143,6 +147,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
143
147
  // We should be passed values that are non-proxies.
144
148
  parentInstance[key] = values.map((value) => {
145
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
+ }
146
155
  if (!childState) {
147
156
  // Look for an existing child (requires having an id key configured)
148
157
  for (const [, otherState] of rowMap.entries()) {
@@ -159,11 +168,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
159
168
  // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
160
169
  return childState.value;
161
170
  });
171
+ // Make sure to tick first so that `setOriginalValue` sees the latest `rows`
172
+ _tick.value++;
162
173
  // Reset originalCopy so that our dirty checks have the right # of rows.
163
174
  if (opts.refreshing) {
164
- originalCopy = [...(parentInstance[key] || [])];
175
+ this.setOriginalValue();
165
176
  }
166
- _tick.value++;
167
177
  },
168
178
  add(value, spliceIndex) {
169
179
  // This is called by the user, so value should be a non-proxy value we should keep
@@ -189,16 +199,31 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
189
199
  maybeAutoSave();
190
200
  },
191
201
  revertChanges() {
192
- if (originalCopy) {
193
- this.set(originalCopy, { resetting: true });
194
- this.rows.forEach((r) => r.revertChanges());
195
- }
202
+ this.set(this.originalValue, { resetting: true });
203
+ this.rows.forEach((r) => r.revertChanges());
204
+ this.touched = false;
196
205
  },
197
206
  commitChanges() {
207
+ // Tell each child ObjectState to have its fields commit into its parentCopy
198
208
  this.rows.forEach((r) => r.commitChanges());
199
- originalCopy = [...(parentInstance[key] || [])];
209
+ this.setOriginalValue();
210
+ this.touched = false;
200
211
  _tick.value++;
201
212
  },
213
+ get originalValue() {
214
+ // A dummy check to for reactivity around our non-proxy value
215
+ const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
216
+ return value !== null && value !== void 0 ? value : [];
217
+ },
218
+ // This should only be called when value === originalValue
219
+ setOriginalValue() {
220
+ // Use the rows' originalValues to update the parentCopy
221
+ parentCopy[key] = this.rows.map((r) => r.originalValue);
222
+ parentCopy[key].forEach((copy, i) => {
223
+ copyMap.set(copy, parentInstance[key][i]);
224
+ });
225
+ _originalValueTick.value++;
226
+ },
202
227
  ensureSet() {
203
228
  if (!parentInstance[key]) {
204
229
  parentInstance[key] = [];
@@ -36,6 +36,7 @@ export type ObjectState<T> = FieldStates<T> & FieldState<T> & {
36
36
  };
37
37
  export type ObjectStateInternal<T> = ObjectState<T> & {
38
38
  set(value: T, opts?: InternalSetOpts): void;
39
+ isSameEntity(other: T): boolean;
39
40
  };
40
41
  declare const fragmentSym: unique symbol;
41
42
  export type Fragment<V> = V & {
@@ -34,20 +34,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
34
34
  }
35
35
  return proxy;
36
36
  }
37
+ // We directly mutate `instance` as the user edits the form, so keep a deep copy of the POJO.
38
+ // If the user passed us `instance` that was a mobx store/class, this originalValue won't really
39
+ // be a true match (i.e. pass instanceof), but it should be good enough
40
+ const originalCopy = (0, utils_1.deepClone)(instance);
37
41
  const objectConfig = config;
38
42
  const fieldStates = Object.entries(config).map(([_key, _config]) => {
39
- var _a, _b;
43
+ var _a, _b, _c, _d;
40
44
  const key = _key;
41
45
  const config = _config;
42
46
  let field;
43
47
  if (config.type === "value") {
44
- field = (0, valueField_1.newValueFieldState)(instance, getObjectState, key, config.rules || [], config.isIdKey ||
48
+ field = (0, valueField_1.newValueFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config.isIdKey ||
45
49
  // Default the id key to "id" unless some other field has isIdKey set
46
50
  (key === "id" &&
47
- !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, (_a = config.strictOrder) !== null && _a !== void 0 ? _a : true, maybeAutoSave);
51
+ !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, (_a = config.computed) !== null && _a !== void 0 ? _a :
52
+ // If instance is a mobx class, we can detect computeds as they won't be enumerable
53
+ ((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);
48
54
  }
49
55
  else if (config.type === "list") {
50
- field = (0, listField_1.newListFieldState)(instance, getObjectState, key, config.rules || [], config, config.config, (_b = config.strictOrder) !== null && _b !== void 0 ? _b : true, maybeAutoSave);
56
+ field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, config.config, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
51
57
  }
52
58
  else if (config.type === "object") {
53
59
  if (!instance[key]) {
@@ -176,7 +182,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
176
182
  return result;
177
183
  },
178
184
  get originalValue() {
179
- return instance;
185
+ getFields(proxy).map((f) => f.originalValue);
186
+ return originalCopy;
180
187
  },
181
188
  // An internal helper method to see if `other` is for "the same entity" as our current row
182
189
  isSameEntity(other) {
@@ -184,7 +191,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
184
191
  if (!idField) {
185
192
  return false;
186
193
  }
187
- return this[idField.key].value === other[idField.key];
194
+ // If the otherIdValue is undefined, it's a new entity so can't be the same as us
195
+ const otherIdValue = other[idField.key];
196
+ return otherIdValue !== undefined && otherIdValue === this[idField.key].value;
188
197
  },
189
198
  };
190
199
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
@@ -193,6 +202,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
193
202
  // to mobx this looks like the value never changes, and it will never invoke observers
194
203
  // even with our tick-based hacks.
195
204
  value: (0, mobx_1.computed)({ equals: () => false }),
205
+ originalValue: (0, mobx_1.computed)({ equals: () => false }),
196
206
  });
197
207
  // Any time a field changes, percolate that change up to us
198
208
  (0, mobx_1.reaction)(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
@@ -71,4 +71,4 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
71
71
  _isReadOnlyKey: boolean;
72
72
  _focused: boolean;
73
73
  }
74
- export declare function newValueFieldState<T, K extends keyof 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>;
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>;
@@ -6,10 +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(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
10
- // keep a copy here for reference equality
11
- const value = parentInstance[key];
12
- let _originalValue = value === null ? undefined : (0, is_plain_object_1.isPlainObject)(value) ? (0, mobx_1.toJS)(value) : value;
9
+ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
13
10
  // Because we read/write the value directly back into parentInstance[key],
14
11
  // which itself is not a proxy, we use this as our "value changed" trigger.
15
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
@@ -31,7 +28,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
31
28
  // Re-create the `keepNull` logic on sets but for our initial read where our
32
29
  // originalValue is null (empty) but we want to expose it as undefined for
33
30
  // consistency of "empty-ness" to our UI components.
34
- return value === null && (0, utils_1.isEmpty)(_originalValue) ? undefined : value;
31
+ return value === null && (0, utils_1.isEmpty)(parentCopy[key]) ? undefined : value;
35
32
  },
36
33
  set value(v) {
37
34
  this.set(v);
@@ -94,7 +91,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
94
91
  if (opts.refreshing && this.dirty && this.value !== value) {
95
92
  // Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
96
93
  // matches the incoming value (value), b/c if it does we should accept it and reset originalValue
97
- // so that we're not longer dirty.
94
+ // so that we're no longer dirty.
98
95
  return;
99
96
  }
100
97
  else if (computed && (opts.resetting || opts.refreshing)) {
@@ -140,12 +137,14 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
140
137
  },
141
138
  get originalValue() {
142
139
  // A dummy check to for reactivity around our non-proxy value
143
- const value = _originalValueTick.value > -1 ? _originalValue : _originalValue;
140
+ const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
144
141
  // Re-create the `keepNull` logic so that `.value` === `.originalValue`
145
142
  return value === null ? undefined : value;
146
143
  },
147
144
  set originalValue(v) {
148
- _originalValue = v;
145
+ const canSkip = v === undefined && !(key in parentCopy);
146
+ if (!canSkip)
147
+ parentCopy[key] = v;
149
148
  _originalValueTick.value++;
150
149
  },
151
150
  maybeTrim() {
@@ -112,7 +112,8 @@ describe("formState", () => {
112
112
  const a1 = { firstName: "a1" };
113
113
  const state = createAuthorInputState(a1);
114
114
  state.firstName.set("a2");
115
- expect(state.originalValue === a1).toEqual(true);
115
+ expect(state.value === a1).toEqual(true);
116
+ expect(state.originalValue !== a1).toEqual(true);
116
117
  expect(a1.firstName).toEqual("a2");
117
118
  });
118
119
  it("maintains object identity of lists", () => {
@@ -122,11 +123,11 @@ describe("formState", () => {
122
123
  const state = createAuthorInputState(a1);
123
124
  const b2 = { title: "t2" };
124
125
  state.books.add(b2);
125
- expect(state.originalValue.books === a1.books).toEqual(true);
126
+ expect(state.value.books === a1.books).toEqual(true);
126
127
  expect(state.books.value.length).toEqual(2);
127
128
  expect((_a = a1.books) === null || _a === void 0 ? void 0 : _a.length).toEqual(2);
128
- expect(state.books.rows[0].originalValue === b1).toEqual(true);
129
- expect(state.books.rows[1].originalValue === b2).toEqual(true);
129
+ expect(state.books.rows[0].value === b1).toEqual(true);
130
+ expect(state.books.rows[1].value === b2).toEqual(true);
130
131
  expect(a1.books[1] === b2).toEqual(true);
131
132
  });
132
133
  it("can add items anywhere within a list", () => {
@@ -141,14 +142,14 @@ describe("formState", () => {
141
142
  });
142
143
  const b2 = { title: "t2" };
143
144
  state.books.add(b2, 0);
144
- expect(state.books.rows[0].originalValue === b2).toEqual(true);
145
- expect(state.books.rows[1].originalValue === b1).toEqual(true);
145
+ expect(state.books.rows[0].value === b2).toEqual(true);
146
+ expect(state.books.rows[1].value === b1).toEqual(true);
146
147
  expect(ticks).toEqual(2);
147
148
  const b3 = { title: "t3" };
148
149
  state.books.add(b3, 1);
149
- expect(state.books.rows[0].originalValue === b2).toEqual(true);
150
- expect(state.books.rows[1].originalValue === b3).toEqual(true);
151
- expect(state.books.rows[2].originalValue === b1).toEqual(true);
150
+ expect(state.books.rows[0].value === b2).toEqual(true);
151
+ expect(state.books.rows[1].value === b3).toEqual(true);
152
+ expect(state.books.rows[2].value === b1).toEqual(true);
152
153
  expect(ticks).toEqual(3);
153
154
  });
154
155
  it("list value can observe changes", () => {
@@ -164,6 +165,34 @@ describe("formState", () => {
164
165
  state.books.add({ title: "t2" }, 0);
165
166
  expect(ticks).toEqual(2);
166
167
  });
168
+ it("list originalValue can observe changes", () => {
169
+ const b1 = { title: "t1" };
170
+ const a1 = { firstName: "a1", books: [b1] };
171
+ const state = createAuthorInputState(a1);
172
+ let books;
173
+ let ticks = 0;
174
+ (0, mobx_1.autorun)(() => {
175
+ books = state.books.originalValue;
176
+ ticks++;
177
+ });
178
+ state.books.add({ title: "t2" }, 0);
179
+ expect(ticks).toEqual(1);
180
+ state.commitChanges();
181
+ expect(ticks).toEqual(2);
182
+ });
183
+ it("state originalValue can observe changes", () => {
184
+ const a1 = { firstName: "a1", books: [] };
185
+ const state = createAuthorInputState(a1);
186
+ let ticks = 0;
187
+ (0, mobx_1.autorun)(() => {
188
+ noop(state.originalValue);
189
+ ticks++;
190
+ });
191
+ state.books.add({ title: "t2" }, 0);
192
+ expect(ticks).toEqual(1);
193
+ state.commitChanges();
194
+ expect(ticks).toEqual(2);
195
+ });
167
196
  it("maintains unknown fields", () => {
168
197
  // Given the form is not directly editing id fields
169
198
  const config = {
@@ -177,9 +206,8 @@ describe("formState", () => {
177
206
  state.firstName.set("a2");
178
207
  state.books.add({ title: "t2" });
179
208
  // When we get back the originalValue
180
- const a2 = state.originalValue;
181
209
  // Then it has the ids and the new values
182
- expect(a2).toMatchObject({
210
+ expect(state.value).toMatchObject({
183
211
  id: "1",
184
212
  books: [{ id: "2", title: "t1" }, { title: "t2" }],
185
213
  });
@@ -600,8 +628,8 @@ describe("formState", () => {
600
628
  firstName: "a1",
601
629
  lastName: "aL1",
602
630
  books: [
603
- { title: "b1", classification: formStateDomain_1.dd100 },
604
- { title: "b2", classification: formStateDomain_1.dd100 },
631
+ { id: "b:1", title: "b1", classification: formStateDomain_1.dd100 },
632
+ { id: "b:2", title: "b2", classification: formStateDomain_1.dd100 },
605
633
  ],
606
634
  });
607
635
  // And some values have been changed
@@ -678,7 +706,7 @@ describe("formState", () => {
678
706
  a1.firstName.value = "";
679
707
  // Then we keep it as null
680
708
  expect(a1.firstName.value).toBeNull();
681
- expect(a1.originalValue.firstName).toBeNull();
709
+ expect(a1.originalValue.firstName).toBe("asdf");
682
710
  expect(a1.firstName.dirty).toBeTruthy();
683
711
  });
684
712
  it("initializes null values to be undefined", () => {
@@ -937,12 +965,14 @@ describe("formState", () => {
937
965
  const formState = (0, objectField_1.createObjectState)({
938
966
  firstName: { type: "value" },
939
967
  lastName: { type: "value" },
940
- fullName: { type: "value", computed: true },
968
+ fullName: { type: "value" },
941
969
  }, new ObservableObject());
942
970
  expect(formState.firstName.value).toEqual("first");
943
971
  expect(formState.fullName.value).toEqual("first last");
972
+ expect(formState.fullName.dirty).toEqual(false);
944
973
  formState.firstName.value = "change";
945
974
  expect(formState.fullName.value).toEqual("change last");
975
+ expect(formState.fullName.dirty).toEqual(true);
946
976
  formState.revertChanges();
947
977
  expect(formState.firstName.value).toEqual("first");
948
978
  });
@@ -1076,6 +1106,45 @@ describe("formState", () => {
1076
1106
  id: "a:1",
1077
1107
  books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }],
1078
1108
  });
1109
+ // And add a 3rd new book
1110
+ formState.books.add({ title: "t3" });
1111
+ // Then we get the author id (for updates) and all 3 books
1112
+ expect(formState.changedValue).toEqual({
1113
+ id: "a:1",
1114
+ books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }, { title: "t3" }],
1115
+ });
1116
+ // And we can still get the original value
1117
+ expect(formState.originalValue).toEqual({
1118
+ id: "a:1",
1119
+ firstName: "f",
1120
+ books: [
1121
+ { id: "b:1", title: "t1" },
1122
+ { id: "b:2", title: "t2" },
1123
+ ],
1124
+ });
1125
+ // And the books.originalValue as well
1126
+ expect(formState.books.originalValue).toEqual([
1127
+ { id: "b:1", title: "t1" },
1128
+ { id: "b:2", title: "t2" },
1129
+ ]);
1130
+ // And when we commit changes
1131
+ formState.commitChanges();
1132
+ // Then our originalValue reflects the commit
1133
+ expect(formState.originalValue).toEqual({
1134
+ id: "a:1",
1135
+ firstName: "f",
1136
+ books: [
1137
+ { id: "b:1", title: "t1b" },
1138
+ { id: "b:2", title: "t2" },
1139
+ { id: undefined, title: "t3" },
1140
+ ],
1141
+ });
1142
+ // And the books.originalValue as well
1143
+ expect(formState.books.originalValue).toEqual([
1144
+ { id: "b:1", title: "t1b" },
1145
+ { id: "b:2", title: "t2" },
1146
+ { title: "t3" },
1147
+ ]);
1079
1148
  });
1080
1149
  it("can return only changed but incremental list fields", () => {
1081
1150
  // Given an author
@@ -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();
@@ -64,7 +64,9 @@ function useFormStates(opts) {
64
64
  }
65
65
  // If the source of truth changed, then update the existing state and return it.
66
66
  if (existing && existing[1] !== input) {
67
- form.set((0, utils_1.initValue)(config, map ? { map, input } : input), { refreshing: true });
67
+ form.set((0, utils_1.initValue)(config, map ? { map, input } : input), {
68
+ refreshing: true,
69
+ });
68
70
  existing[1] = input;
69
71
  }
70
72
  form.readOnly = readOnlyRef.current || !!opts.readOnly;
package/dist/utils.d.ts CHANGED
@@ -44,3 +44,5 @@ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolea
44
44
  export declare function hasToJSON(o?: unknown): o is {
45
45
  toJSON(): void;
46
46
  };
47
+ /** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
48
+ export declare function deepClone<T>(obj: T, map?: WeakMap<WeakKey, any>): T;
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
6
+ exports.deepClone = exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
7
7
  const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
8
8
  const is_plain_object_1 = require("is-plain-object");
9
9
  const mobx_1 = require("mobx");
@@ -126,3 +126,25 @@ function hasToJSON(o) {
126
126
  return !!(o && typeof o === "object" && "toJSON" in o);
127
127
  }
128
128
  exports.hasToJSON = hasToJSON;
129
+ /** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
130
+ function deepClone(obj, map = new WeakMap()) {
131
+ if (obj && typeof obj === "object" && ((0, is_plain_object_1.isPlainObject)(obj) || Array.isArray(obj) || (0, mobx_1.isObservable)(obj))) {
132
+ if (map.has(obj))
133
+ return map.get(obj);
134
+ const result = Array.isArray(obj) ? [] : {};
135
+ map.set(obj, result);
136
+ Object.assign(result, ...getAllPropertyNames(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
137
+ return result;
138
+ }
139
+ else {
140
+ return obj;
141
+ }
142
+ }
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.21.0",
3
+ "version": "2.22.1",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",