@homebound/form-state 2.22.4 → 2.23.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
@@ -65,6 +65,8 @@ export type ListFieldConfig<U> = {
65
65
  };
66
66
  export type ObjectFieldConfig<U> = {
67
67
  type: "object";
68
+ /** Marks an object as a reference, which means we'll only include it's `id` in `changedValue` output. */
69
+ reference?: boolean;
68
70
  /** Config for the child's form state, i.e. each book. */
69
71
  config: ObjectConfig<U>;
70
72
  };
@@ -1,4 +1,4 @@
1
- import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ValueFieldConfig } from "./config";
1
+ import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ObjectFieldConfig, ValueFieldConfig } from "./config";
2
2
  import { Fragment, ObjectState } from "./fields/objectField";
3
3
  import { Rule } from "./rules";
4
4
  import { Builtin, OmitIf } from "./utils";
@@ -14,8 +14,12 @@ export declare const f: {
14
14
  list<U>(fields: ObjectConfigBuilderFields<U>): ListFieldConfigBuilder<U>;
15
15
  /** Creates the config DSL for a primitive value, like a first name or phone number. */
16
16
  value<V>(): ValueFieldConfigBuilder<V>;
17
+ /** A shortcut for creating a child object with a single `id` key. */
18
+ reference<V_1 extends {
19
+ id?: unknown;
20
+ }>(fields?: ObjectConfigBuilderFields<V_1> | undefined): ObjectConfigBuilder<V_1>;
17
21
  /** A shorthand for creating a computed value. */
18
- computed<V_1>(): ValueFieldConfigBuilder<V_1>;
22
+ computed<V_2>(): ValueFieldConfigBuilder<V_2>;
19
23
  };
20
24
  /**
21
25
  * Defines the fields that should be passed into `f.object({ ... })` for fhe form type `T`.
@@ -33,7 +37,8 @@ export interface FragmentFieldConfigBuilder {
33
37
  export declare class ObjectConfigBuilder<T> {
34
38
  private config;
35
39
  constructor(fields: ObjectConfigBuilderFields<T>);
36
- build(): ObjectConfig<T>;
40
+ ref(): this;
41
+ build(): ObjectFieldConfig<T>;
37
42
  }
38
43
  /** Provides a fluent DSL for building up a value's config. */
39
44
  export declare class ValueFieldConfigBuilder<V> {
@@ -8,7 +8,7 @@ const rules_1 = require("./rules");
8
8
  exports.f = {
9
9
  /** Creates the top-level config for a form, like an author or book. */
10
10
  config(fields) {
11
- return new ObjectConfigBuilder(fields).build();
11
+ return new ObjectConfigBuilder(fields).build().config;
12
12
  },
13
13
  /** Creates the config DSL for an object, like an author or book. */
14
14
  object(fields) {
@@ -22,6 +22,10 @@ exports.f = {
22
22
  value() {
23
23
  return new ValueFieldConfigBuilder();
24
24
  },
25
+ /** A shortcut for creating a child object with a single `id` key. */
26
+ reference(fields) {
27
+ return exports.f.object({ id: exports.f.value(), ...fields }).ref();
28
+ },
25
29
  /** A shorthand for creating a computed value. */
26
30
  computed() {
27
31
  return this.value().computed();
@@ -30,11 +34,15 @@ exports.f = {
30
34
  /** Provides a fluent DSL for building up an object's config. */
31
35
  class ObjectConfigBuilder {
32
36
  constructor(fields) {
33
- this.config = {};
37
+ this.config = { type: "object", config: {} };
34
38
  for (const [key, value] of Object.entries(fields)) {
35
- this.config[key] = value.build();
39
+ this.config.config[key] = value.build();
36
40
  }
37
41
  }
42
+ ref() {
43
+ this.config.reference = true;
44
+ return this;
45
+ }
38
46
  build() {
39
47
  return this.config;
40
48
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const configBuilders_1 = require("./configBuilders");
4
+ describe("config", () => {
5
+ it("supports observable objects with helper methods in the config DSL", () => {
6
+ const config = configBuilders_1.f.config({
7
+ firstName: configBuilders_1.f.value(),
8
+ lastName: configBuilders_1.f.value(),
9
+ fullName: configBuilders_1.f.computed(),
10
+ });
11
+ // Throw away assertion, test is making sure ^ line compiles
12
+ expect(config).toBeDefined();
13
+ });
14
+ it("supports nested objects", () => {
15
+ const config = configBuilders_1.f.config({
16
+ id: configBuilders_1.f.value(),
17
+ address: configBuilders_1.f.object({ id: configBuilders_1.f.value() }),
18
+ });
19
+ expect(config).toMatchInlineSnapshot(`
20
+ {
21
+ "address": {
22
+ "config": {
23
+ "id": {
24
+ "rules": [],
25
+ "type": "value",
26
+ },
27
+ },
28
+ "type": "object",
29
+ },
30
+ "id": {
31
+ "rules": [],
32
+ "type": "value",
33
+ },
34
+ }
35
+ `);
36
+ });
37
+ it("supports Reference alias", () => {
38
+ const config = configBuilders_1.f.config({
39
+ id: configBuilders_1.f.value(),
40
+ address: configBuilders_1.f.reference({ name: configBuilders_1.f.value() }),
41
+ });
42
+ expect(config).toMatchInlineSnapshot(`
43
+ {
44
+ "address": {
45
+ "config": {
46
+ "id": {
47
+ "rules": [],
48
+ "type": "value",
49
+ },
50
+ "name": {
51
+ "rules": [],
52
+ "type": "value",
53
+ },
54
+ },
55
+ "reference": true,
56
+ "type": "object",
57
+ },
58
+ "id": {
59
+ "rules": [],
60
+ "type": "value",
61
+ },
62
+ }
63
+ `);
64
+ });
65
+ });
@@ -1,4 +1,4 @@
1
- import { ListFieldConfig, ObjectConfig } from "../config";
1
+ import { ListFieldConfig, ObjectFieldConfig } from "../config";
2
2
  import { ObjectState } from "./objectField";
3
3
  import { FieldState } from "./valueField";
4
4
  import { Rule } from "../rules";
@@ -8,4 +8,4 @@ export interface ListFieldState<U> extends FieldState<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>(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>;
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: ObjectFieldConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
@@ -41,7 +41,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
41
41
  }
42
42
  // If we didn't have an existing child, just make a new object state
43
43
  if (!childState) {
44
- childState = (0, objectField_1.newObjectState)(config, parentState, list, child, undefined, maybeAutoSave);
44
+ childState = (0, objectField_1.newObjectState)(config, parentState, undefined, list, child, undefined, maybeAutoSave);
45
45
  rowMap.set(child, childState);
46
46
  }
47
47
  return childState;
@@ -73,6 +73,9 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
73
73
  get dirty() {
74
74
  return this.rows.some((r) => r.dirty) || this.hasChanged();
75
75
  },
76
+ get focused() {
77
+ return this.rows.some((r) => r.focused);
78
+ },
76
79
  get required() {
77
80
  return this.rules.some((rule) => rule === rules_1.required);
78
81
  },
@@ -178,7 +181,8 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
178
181
  },
179
182
  add(value, spliceIndex) {
180
183
  // This is called by the user, so value should be a non-proxy value we should keep
181
- const childState = (0, objectField_1.createObjectState)(config, value, { maybeAutoSave });
184
+ const childState = getOrCreateChildState(value);
185
+ // const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
182
186
  rowMap.set(value, childState);
183
187
  this.ensureSet();
184
188
  this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
@@ -1,4 +1,4 @@
1
- import { ObjectConfig } from "../config";
1
+ import { ObjectConfig, ObjectFieldConfig } from "../config";
2
2
  import { FragmentField } from "./fragmentField";
3
3
  import { ListFieldState } from "./listField";
4
4
  import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
@@ -57,5 +57,6 @@ type FieldStates<T> = {
57
57
  export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
58
58
  maybeAutoSave?: () => void;
59
59
  }): ObjectState<T>;
60
- export declare function newObjectState<T, P = any>(config: ObjectConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T>;
60
+ /** A more internal version of `createObjectState`. */
61
+ export declare function newObjectState<T, P = any>(config: ObjectFieldConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentInstance: P | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T>;
61
62
  export {};
@@ -20,10 +20,11 @@ exports.fragment = fragment;
20
20
  */
21
21
  function createObjectState(config, instance, opts = {}) {
22
22
  const noop = () => { };
23
- return newObjectState(config, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
23
+ return newObjectState({ type: "object", config }, undefined, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
24
24
  }
25
25
  exports.createObjectState = createObjectState;
26
- function newObjectState(config, parentState, parentListState, instance, key, maybeAutoSave) {
26
+ /** A more internal version of `createObjectState`. */
27
+ function newObjectState(config, parentState, parentInstance, parentListState, instance, key, maybeAutoSave) {
27
28
  // This is what we return, but we only know it's value until we call `observable`, so
28
29
  // we create a mutable variable to capture it so that we can create fields/call their
29
30
  // constructors and give them a way to access it later.
@@ -38,8 +39,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
38
39
  // If the user passed us `instance` that was a mobx store/class, this originalValue won't really
39
40
  // be a true match (i.e. pass instanceof), but it should be good enough
40
41
  const originalCopy = (0, utils_1.deepClone)(instance);
41
- const objectConfig = config;
42
- const fieldStates = Object.entries(config).map(([_key, _config]) => {
42
+ const objectConfig = config.config;
43
+ const fieldStates = Object.entries(objectConfig).map(([_key, _config]) => {
43
44
  var _a, _b, _c, _d;
44
45
  const key = _key;
45
46
  const config = _config;
@@ -53,26 +54,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
53
54
  ((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);
54
55
  }
55
56
  else if (config.type === "list") {
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);
57
+ field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, { type: "object", config: config.config }, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
57
58
  }
58
59
  else if (config.type === "object") {
59
60
  if (!instance[key]) {
60
61
  instance[key] = {};
61
62
  }
62
- field = newObjectState(config.config, getObjectState, undefined, instance[key], key, maybeAutoSave);
63
+ field = newObjectState(config, getObjectState, instance, undefined, instance[key], key, maybeAutoSave);
63
64
  }
64
65
  else if (config.type === "fragment") {
65
66
  field = (0, fragmentField_1.newFragmentField)(instance, key);
66
67
  }
67
68
  else {
68
- throw new Error("Unsupported");
69
+ throw new Error(`Invalid type value ${config.type}`);
69
70
  }
70
71
  return [key, field];
71
72
  });
72
73
  // We always return the same `instance` field from our `value` method, but
73
74
  // we want to pretend that it's observable, so use a tick to force it.
74
75
  const _tick = (0, mobx_1.observable)({ value: 1 });
75
- const fieldNames = Object.keys(config);
76
+ const fieldNames = Object.keys(objectConfig);
76
77
  function getFields(proxyThis) {
77
78
  return fieldNames.map((name) => proxyThis[name]);
78
79
  }
@@ -87,6 +88,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
87
88
  this.set(value);
88
89
  },
89
90
  // private
91
+ _kind: "object",
90
92
  _readOnly: false,
91
93
  _loading: false,
92
94
  _considerDeleted() {
@@ -97,6 +99,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
97
99
  const readOnlyField = getFields(this).find((f) => f._isReadOnlyKey);
98
100
  return !!(readOnlyField === null || readOnlyField === void 0 ? void 0 : readOnlyField.value);
99
101
  },
102
+ get focused() {
103
+ return getFields(this).some((f) => f.focused);
104
+ },
100
105
  get touched() {
101
106
  return getFields(this).some((f) => f.touched);
102
107
  },
@@ -125,7 +130,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
125
130
  return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
126
131
  },
127
132
  get dirty() {
128
- return getFields(this).some((f) => f.dirty);
133
+ return getFields(this).some((f) => f.dirty) || this.isUnset();
129
134
  },
130
135
  get isNewEntity() {
131
136
  const idField = getFields(this).find((f) => f._isIdKey);
@@ -145,6 +150,13 @@ function newObjectState(config, parentState, parentListState, instance, key, may
145
150
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
146
151
  throw new Error(`${String(key) || "formState"} is currently readOnly`);
147
152
  }
153
+ // Restore our instance if we're being reset
154
+ if (value && this.isUnset())
155
+ parentInstance[key] = instance;
156
+ // Delete our instance if we're being unset
157
+ if (!value && parentInstance)
158
+ parentInstance[key] = undefined;
159
+ // Otherwise just copy over the fields
148
160
  getFields(this).forEach((field) => {
149
161
  if (value && typeof value === "object" && field.key in value) {
150
162
  field.set(value[field.key], opts);
@@ -162,17 +174,27 @@ function newObjectState(config, parentState, parentListState, instance, key, may
162
174
  // Create a result that is only populated with changed keys
163
175
  get changedValue() {
164
176
  const result = {};
177
+ // If we're a child, like `author.address` that was unset, return null
178
+ if (this.isUnset()) {
179
+ // If we're a reference, always return `{ id: ... }` for easy binding
180
+ const idField = getFields(this).find((f) => f._isIdKey);
181
+ if (idField && config.reference)
182
+ return { id: null };
183
+ // Otherwise mark the whole child as gone
184
+ return null;
185
+ }
165
186
  getFields(this).forEach((f) => {
166
187
  if (f._computed)
167
188
  return;
189
+ // References only include the id key below
190
+ if (config.reference)
191
+ return;
168
192
  if (f.dirty ||
169
193
  // If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
170
194
  // look dirty, but if we're new we should include them anyway.
171
- (this.isNewEntity &&
172
- // Unless they're undefined anyway
173
- f.value !== undefined &&
174
- // And unless they're empty sub-objects
175
- !(f.value instanceof Object && Object.entries(f.changedValue).length === 0))) {
195
+ (this.isNewEntity && f._kind === "value" && f.value !== undefined) ||
196
+ // And unless they're empty sub-objects
197
+ (this.isNewEntity && f._kind === "object" && Object.entries(f.changedValue).length > 0)) {
176
198
  result[f.key] = f.changedValue;
177
199
  }
178
200
  });
@@ -197,6 +219,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
197
219
  // If the otherIdValue is undefined, it's a new entity so can't be the same as us
198
220
  return otherIdValue !== undefined && otherIdValue === ourIdValue;
199
221
  },
222
+ isUnset() {
223
+ return !!parentInstance && parentInstance[key] === undefined;
224
+ },
200
225
  };
201
226
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
202
227
  // Use custom equality on `value` that is _never_ equals. This sounds weird, but
@@ -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,10 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
17
17
  /** Current readOnly value. */
18
18
  _readOnly: readOnly || false,
19
19
  _loading: false,
20
- _focused: false,
20
+ _kind: "value",
21
21
  // Expose so computed can be skipped in changedValue
22
22
  _computed: computed,
23
+ _focused: false,
23
24
  _isIdKey: isIdKey,
24
25
  _isDeleteKey: isDeleteKey,
25
26
  _isReadOnlyKey: isReadOnlyKey,
@@ -35,6 +36,9 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
35
36
  set value(v) {
36
37
  this.set(v);
37
38
  },
39
+ get focused() {
40
+ return this._focused;
41
+ },
38
42
  get dirty() {
39
43
  return !(0, utils_1.areEqual)(this.originalValue, this.value, strictOrder);
40
44
  },
@@ -167,6 +171,16 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
167
171
  }
168
172
  },
169
173
  };
174
+ // If we're wrapping a mobx observer, watching for external mutations, i.e. from callers not
175
+ // going through our FieldState.set/FieldState.value setters.
176
+ if ((0, mobx_1.isObservable)(parentInstance)) {
177
+ (0, mobx_1.reaction)(() => parentInstance[key], () => {
178
+ // Don't auto-save on maybe-external mutation if our field, or another other field (potentially a computed),
179
+ // is currently focused, because then it's probably just us doing the writes through `Bound...Field` components.
180
+ if (!parentState().focused)
181
+ maybeAutoSave();
182
+ });
183
+ }
170
184
  return field;
171
185
  }
172
186
  exports.newValueFieldState = newValueFieldState;
@@ -957,24 +957,6 @@ describe("formState", () => {
957
957
  // Then the formState saw it
958
958
  expect(lastLength).toEqual(2);
959
959
  });
960
- it("supports observable objects with helper methods", () => {
961
- const config = {
962
- firstName: { type: "value" },
963
- lastName: { type: "value" },
964
- fullName: { type: "value", computed: true },
965
- };
966
- // Throw away assertion, test is making sure ^ line compiles
967
- expect(config).toBeDefined();
968
- });
969
- it("supports observable objects with helper methods in the config DSL", () => {
970
- const config = configBuilders_1.f.config({
971
- firstName: configBuilders_1.f.value(),
972
- lastName: configBuilders_1.f.value(),
973
- fullName: configBuilders_1.f.computed(),
974
- });
975
- // Throw away assertion, test is making sure ^ line compiles
976
- expect(config).toBeDefined();
977
- });
978
960
  it("can reset observable objects with computeds", () => {
979
961
  const formState = (0, objectField_1.createObjectState)({
980
962
  firstName: { type: "value" },
@@ -1087,6 +1069,89 @@ describe("formState", () => {
1087
1069
  // because this might trigger creating a throw-away entity
1088
1070
  expect(formState.changedValue).toEqual({ firstName: "f" });
1089
1071
  });
1072
+ it("changedValue includes new entity nested fields", () => {
1073
+ // Given a new author with an address FK
1074
+ const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, {
1075
+ firstName: "f",
1076
+ address: { id: "add:1" },
1077
+ });
1078
+ // Then changedValue includes the reference to the FK
1079
+ expect(formState.changedValue).toEqual({
1080
+ firstName: "f",
1081
+ address: { id: "add:1" },
1082
+ });
1083
+ });
1084
+ it("changedValue includes new entity nested fields with multiple keys", () => {
1085
+ // Given a new author with an address FK
1086
+ const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, { firstName: "f" });
1087
+ formState.address.set({ id: "add:1", street: "Main St" });
1088
+ // Then changedValue includes the reference to the FK
1089
+ expect(formState.changedValue).toEqual({
1090
+ firstName: "f",
1091
+ address: { id: "add:1", street: "Main St" },
1092
+ });
1093
+ });
1094
+ it("changedValue includes deleted nested fields", () => {
1095
+ var _a;
1096
+ // Given a new author with an address FK
1097
+ const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
1098
+ id: configBuilders_1.f.value(),
1099
+ firstName: configBuilders_1.f.value(),
1100
+ // And address is a reference
1101
+ address: configBuilders_1.f.reference(),
1102
+ }), { id: "a:1", firstName: "f", address: { id: "add:1" } });
1103
+ // When the address is unset
1104
+ formState.address.set(undefined);
1105
+ // Then the field is dirty and will be removed in changedValue
1106
+ expect(formState.address.dirty).toBe(true);
1107
+ expect(formState.dirty).toBe(true);
1108
+ expect(formState.changedValue).toEqual({
1109
+ id: "a:1",
1110
+ address: { id: null },
1111
+ });
1112
+ // So that we can do a binding like
1113
+ const addressId = (_a = formState.changedValue.address) === null || _a === void 0 ? void 0 : _a.id;
1114
+ expect(addressId).toBeNull();
1115
+ // And when we're restored to the same value
1116
+ formState.address.set({ id: "add:1" });
1117
+ // Then it's back to not being dirty
1118
+ expect(formState.address.dirty).toBe(false);
1119
+ expect(formState.dirty).toBe(false);
1120
+ // And when we're changed to a different address
1121
+ formState.address.set({ id: "add:2" });
1122
+ // Then we're dirty again
1123
+ expect(formState.changedValue).toEqual({
1124
+ id: "a:1",
1125
+ address: { id: "add:2" },
1126
+ });
1127
+ });
1128
+ it("changedValue includes deleted nested fields with a name", () => {
1129
+ // Given a new author with an address FK
1130
+ const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
1131
+ id: configBuilders_1.f.value(),
1132
+ firstName: configBuilders_1.f.value(),
1133
+ address: configBuilders_1.f.reference({ street: configBuilders_1.f.value() }),
1134
+ }), { id: "a:1", firstName: "f", address: { id: "add:1", street: "Main St" } });
1135
+ // Initially both id and street can be bound to the UI
1136
+ expect(formState.address.id.value).toBe("add:1");
1137
+ expect(formState.address.street.value).toBe("Main St");
1138
+ // When the address is unset
1139
+ formState.address.set(undefined);
1140
+ // Then the field is dirty and will be removed in changedValue
1141
+ expect(formState.address.dirty).toBe(true);
1142
+ // And our changedValue only includes the id
1143
+ expect(formState.changedValue).toEqual({
1144
+ id: "a:1",
1145
+ address: { id: null },
1146
+ });
1147
+ // And when we're changed to a new address
1148
+ formState.address.set({ id: "add:2", street: "Side St" });
1149
+ // Then we see oly the id come back in changedValue
1150
+ expect(formState.changedValue).toEqual({
1151
+ id: "a:1",
1152
+ address: { id: "add:2" },
1153
+ });
1154
+ });
1090
1155
  it("changedValue skips effectively empty nested fields", () => {
1091
1156
  // Given a new author with an address object
1092
1157
  const formState = (0, objectField_1.createObjectState)(authorWithAddressConfig, {
@@ -1620,6 +1685,19 @@ const authorWithAddressConfig = {
1620
1685
  },
1621
1686
  },
1622
1687
  };
1688
+ const authorWithAddressFkConfig = {
1689
+ id: { type: "value" },
1690
+ firstName: { type: "value" },
1691
+ lastName: { type: "value" },
1692
+ address: {
1693
+ type: "object",
1694
+ config: {
1695
+ id: { type: "value" },
1696
+ street: { type: "value", rules: [rules_1.required] },
1697
+ city: { type: "value" },
1698
+ },
1699
+ },
1700
+ };
1623
1701
  const authorWithAddressAndBooksConfig = configBuilders_1.f.config({
1624
1702
  id: configBuilders_1.f.value(),
1625
1703
  firstName: configBuilders_1.f.value(),
@@ -18,6 +18,7 @@ export interface AuthorInput {
18
18
  favoriteColors?: Color[] | null;
19
19
  }
20
20
  export interface AuthorAddress {
21
+ id?: string | null;
21
22
  street?: string | null;
22
23
  city?: string | null;
23
24
  }
@@ -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.4",
3
+ "version": "2.23.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",