@homebound/form-state 2.22.5 → 2.23.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.
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;
@@ -181,7 +181,8 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
181
181
  },
182
182
  add(value, spliceIndex) {
183
183
  // This is called by the user, so value should be a non-proxy value we should keep
184
- const childState = (0, objectField_1.createObjectState)(config, value, { maybeAutoSave });
184
+ const childState = getOrCreateChildState(value);
185
+ // const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
185
186
  rowMap.set(value, childState);
186
187
  this.ensureSet();
187
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,8 +88,10 @@ 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,
94
+ _isAutoSaving: false,
92
95
  _considerDeleted() {
93
96
  const deleteField = getFields(this).find((f) => f._isDeleteKey);
94
97
  return !!(deleteField === null || deleteField === void 0 ? void 0 : deleteField.value);
@@ -128,7 +131,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
128
131
  return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
129
132
  },
130
133
  get dirty() {
131
- return getFields(this).some((f) => f.dirty);
134
+ return getFields(this).some((f) => f.dirty) || this.isUnset();
132
135
  },
133
136
  get isNewEntity() {
134
137
  const idField = getFields(this).find((f) => f._isIdKey);
@@ -148,6 +151,13 @@ function newObjectState(config, parentState, parentListState, instance, key, may
148
151
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
149
152
  throw new Error(`${String(key) || "formState"} is currently readOnly`);
150
153
  }
154
+ // Restore our instance if we're being reset
155
+ if (value && this.isUnset())
156
+ parentInstance[key] = instance;
157
+ // Delete our instance if we're being unset
158
+ if (!value && parentInstance)
159
+ parentInstance[key] = undefined;
160
+ // Otherwise just copy over the fields
151
161
  getFields(this).forEach((field) => {
152
162
  if (value && typeof value === "object" && field.key in value) {
153
163
  field.set(value[field.key], opts);
@@ -160,22 +170,43 @@ function newObjectState(config, parentState, parentListState, instance, key, may
160
170
  },
161
171
  // Saves all current values into _originalValue
162
172
  commitChanges() {
173
+ if (this._isAutoSaving) {
174
+ // `commitChanges` will mark all WIP changes as committed, which might drop changes if there
175
+ // is an auto-save in-flight, and the user made more changes, that are waiting for their turn
176
+ // on the next auto-save request.
177
+ //
178
+ // Instead of doing a form-wide "all changes are committed", instead autosave forms should use
179
+ // init.map/input to have the server's latest results updated within the form, which will then
180
+ // selectively "un-dirty"/commit the just-saved data, but keep the "still-dirty" WIP data alone.
181
+ throw new Error("When using autoSave, you should not manually call commitChanges, instead have init.map/input update the form state");
182
+ }
183
+ // asdf
163
184
  getFields(this).forEach((f) => f.commitChanges());
164
185
  },
165
186
  // Create a result that is only populated with changed keys
166
187
  get changedValue() {
167
188
  const result = {};
189
+ // If we're a child, like `author.address` that was unset, return null
190
+ if (this.isUnset()) {
191
+ // If we're a reference, always return `{ id: ... }` for easy binding
192
+ const idField = getFields(this).find((f) => f._isIdKey);
193
+ if (idField && config.reference)
194
+ return { id: null };
195
+ // Otherwise mark the whole child as gone
196
+ return null;
197
+ }
168
198
  getFields(this).forEach((f) => {
169
199
  if (f._computed)
170
200
  return;
201
+ // References only include the id key below
202
+ if (config.reference)
203
+ return;
171
204
  if (f.dirty ||
172
205
  // If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
173
206
  // look dirty, but if we're new we should include them anyway.
174
- (this.isNewEntity &&
175
- // Unless they're undefined anyway
176
- f.value !== undefined &&
177
- // And unless they're empty sub-objects
178
- !(f.value instanceof Object && Object.entries(f.changedValue).length === 0))) {
207
+ (this.isNewEntity && f._kind === "value" && f.value !== undefined) ||
208
+ // And unless they're empty sub-objects
209
+ (this.isNewEntity && f._kind === "object" && Object.entries(f.changedValue).length > 0)) {
179
210
  result[f.key] = f.changedValue;
180
211
  }
181
212
  });
@@ -200,6 +231,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
200
231
  // If the otherIdValue is undefined, it's a new entity so can't be the same as us
201
232
  return otherIdValue !== undefined && otherIdValue === ourIdValue;
202
233
  },
234
+ isUnset() {
235
+ return !!parentInstance && parentInstance[key] === undefined;
236
+ },
203
237
  };
204
238
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
205
239
  // Use custom equality on `value` that is _never_ equals. This sounds weird, but
@@ -17,6 +17,7 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
17
17
  /** Current readOnly value. */
18
18
  _readOnly: readOnly || false,
19
19
  _loading: false,
20
+ _kind: "value",
20
21
  // Expose so computed can be skipped in changedValue
21
22
  _computed: computed,
22
23
  _focused: false,
@@ -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
  }
@@ -32,6 +32,10 @@ function useFormState(opts) {
32
32
  pendingAutoSave = true;
33
33
  }
34
34
  else if (isAutoSaving === "queued") {
35
+ // If we've queued up an auto-save, that means we haven't called the `autoSave(...)` function yet, so
36
+ // we assume that the scheduled-but-not-yet-invoked invocation will find both it's original change, and
37
+ // also this potentially new change, and put them both onto the wire as a single operation.
38
+ // So we can just ignore this call.
35
39
  return;
36
40
  }
37
41
  // Don't use canSave() because we don't want to set touched for all the fields
@@ -41,8 +45,12 @@ function useFormState(opts) {
41
45
  // We use setTimeout as a cheap way to wait until the end of the current event listener
42
46
  setTimeout(async () => {
43
47
  try {
48
+ // Tell commitChanges to blow up.
49
+ form._isAutoSaving = true;
44
50
  // We technically don't flip to in-flight until after the call in case the
45
- // user's autoSave function itself wants to call a .set.
51
+ // user's autoSave function itself wants to call a .set (which would call `maybeAutoSave`,
52
+ // and we don't want it to see `isAutoSaving=in-flight` and think it "missed the boat",
53
+ // and so schedule an unnecessary follow-up autosave.)
46
54
  const promise = autoSaveRef.current(form);
47
55
  isAutoSaving = "in-flight";
48
56
  await promise;
@@ -53,6 +61,7 @@ function useFormState(opts) {
53
61
  }
54
62
  finally {
55
63
  isAutoSaving = false;
64
+ form._isAutoSaving = false;
56
65
  if (pendingAutoSave) {
57
66
  pendingAutoSave = false;
58
67
  // Push out the follow-up by 1 tick to allow refreshes to happen to potentially
@@ -539,6 +539,35 @@ describe("useFormState", () => {
539
539
  expect(r.originalValue).toHaveTextContent("a1");
540
540
  expect(r.objectValue).toHaveTextContent("a1");
541
541
  });
542
+ it("doesn't allow calling commitChanges", async () => {
543
+ // Given a component
544
+ const autoSave = jest.fn();
545
+ function TestComponent() {
546
+ const config = authorConfig;
547
+ const form = (0, useFormState_1.useFormState)({
548
+ config,
549
+ init: { firstName: "f1", lastName: "f1" },
550
+ autoSave: async (form) => {
551
+ autoSave(form.changedValue);
552
+ // And the autoSave functions erroneously calls commitChanges
553
+ form.commitChanges();
554
+ },
555
+ });
556
+ return (0, jsx_runtime_1.jsx)("button", { "data-testid": "set", onClick: () => form.firstName.set("f2") });
557
+ }
558
+ const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
559
+ // When we change a field, and try to commit, it blows up
560
+ // await expect(clickAndWait(r.set)).rejects.toThrow(
561
+ // "When using autoSave, you should not manually call commitChanges",
562
+ // );
563
+ //
564
+ // ...for some reason, I think maybe due to rtl-utils calling runOnlyPendingTimers
565
+ // instead of runOnlyPendingTimers async, the `clickAndWait` actually resolves
566
+ // successfully (it's not a rejected promise), and our test technically keeps running,
567
+ // except then somewhere in the guts of Jest, the fake timer infra realizes that a tick
568
+ // failed (our autosave is actually fired from a setTimout tick), and so the Jest test
569
+ // fails anyway, but without anyway for us to catch and do a `.toThrow` assertion.
570
+ });
542
571
  });
543
572
  const authorConfig = {
544
573
  id: { type: "value" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.22.5",
3
+ "version": "2.23.1",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",