@homebound/form-state 2.3.0 → 2.5.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.
@@ -20,7 +20,7 @@ function FormStateApp() {
20
20
  return state.firstName.value === state.lastName.value ? "Last name cannot equal first name" : undefined;
21
21
  });
22
22
  },
23
- autoSave() {
23
+ async autoSave() {
24
24
  console.log("saving", formState.changedValue);
25
25
  },
26
26
  });
@@ -72,8 +72,10 @@ export interface FieldState<T, V> {
72
72
  readonly changedValue: V;
73
73
  /** Focuses the field. Disables changes from `ObjectState.set` calls. */
74
74
  focus(): void;
75
- /** Blur essentially touches the field. */
75
+ /** Blur marks the field as touched and triggers an auto-save. */
76
76
  blur(): void;
77
+ /** Triggers an auto-save; the caller (i.e. useFormState) should still dirty & valid check. */
78
+ maybeAutoSave(): void;
77
79
  set(value: V, opts?: SetOpts): void;
78
80
  /** Reverts back to the original value and resets dirty/touched. */
79
81
  reset(): void;
@@ -161,7 +163,7 @@ export declare type ObjectFieldConfig<U> = {
161
163
  * individual fields as well as the top-level form/object itself.
162
164
  */
163
165
  export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
164
- onBlur?: () => void;
166
+ maybeAutoSave?: () => void;
165
167
  }): ObjectState<T>;
166
168
  declare type Primitive = undefined | null | boolean | string | number | Function | Date | {
167
169
  toJSON(): any;
package/dist/formState.js CHANGED
@@ -12,7 +12,8 @@ const utils_1 = require("./utils");
12
12
  // We pre-emptively make this a mobx action so that it's identity doesn't change when proxy-ified
13
13
  // and breaks our ability to do `rules.some(r => r === required)`.
14
14
  exports.required = mobx_1.action(({ value: v }) => {
15
- return v !== undefined && v !== null && v !== "" ? undefined : "Required";
15
+ const isEmptyString = typeof v === "string" ? v.trim() === "" : false;
16
+ return v !== undefined && v !== null && !isEmptyString ? undefined : "Required";
16
17
  });
17
18
  /**
18
19
  * Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
@@ -23,10 +24,10 @@ exports.required = mobx_1.action(({ value: v }) => {
23
24
  */
24
25
  function createObjectState(config, instance, opts = {}) {
25
26
  const noop = () => { };
26
- return newObjectState(config, undefined, instance, undefined, opts.onBlur || noop);
27
+ return newObjectState(config, undefined, instance, undefined, opts.maybeAutoSave || noop);
27
28
  }
28
29
  exports.createObjectState = createObjectState;
29
- function newObjectState(config, parentState, instance, key, onBlur) {
30
+ function newObjectState(config, parentState, instance, key, maybeAutoSave) {
30
31
  // This is what we return, but we only know it's value until we call `observable`, so
31
32
  // we create a mutable variable to capture it so that we can create fields/call their
32
33
  // constructors and give them a way to access it later.
@@ -42,16 +43,16 @@ function newObjectState(config, parentState, instance, key, onBlur) {
42
43
  const config = _config;
43
44
  let field;
44
45
  if (config.type === "value") {
45
- field = newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, onBlur);
46
+ field = newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
46
47
  }
47
48
  else if (config.type === "list") {
48
- field = newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, onBlur);
49
+ field = newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
49
50
  }
50
51
  else if (config.type === "object") {
51
52
  if (!instance[key]) {
52
53
  instance[key] = {};
53
54
  }
54
- field = newObjectState(config.config, getObjectState, instance[key], key, onBlur);
55
+ field = newObjectState(config.config, getObjectState, instance[key], key, maybeAutoSave);
55
56
  }
56
57
  else {
57
58
  throw new Error("Unsupported");
@@ -185,7 +186,7 @@ function newObjectState(config, parentState, instance, key, onBlur) {
185
186
  mobx_1.reaction(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
186
187
  return proxy;
187
188
  }
188
- function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, onBlur) {
189
+ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
189
190
  // keep a copy here for reference equality
190
191
  const value = parentInstance[key];
191
192
  let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
@@ -260,14 +261,15 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
260
261
  this._focused = true;
261
262
  },
262
263
  blur() {
263
- // Now that the user is done editing the field, we sneak in some trim logic
264
- if (this._focused) {
265
- this.maybeTrim();
266
- }
264
+ this.maybeAutoSave();
267
265
  this._focused = false;
266
+ },
267
+ maybeAutoSave() {
268
+ // Now that the user is done editing the field (note maybe w/o blurring, i.e. if they hit enter), we sneak in some trim logic
269
+ this.maybeTrim();
268
270
  // touched is readonly, but we're allowed to change it
269
271
  this.touched = true;
270
- onBlur();
272
+ maybeAutoSave();
271
273
  },
272
274
  set(value, opts = {}) {
273
275
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
@@ -299,7 +301,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
299
301
  // If we're being set programmatically, i.e. we don't currently have focus,
300
302
  // call blur to trigger any auto-saves.
301
303
  if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) {
302
- this.blur();
304
+ this.maybeAutoSave();
303
305
  }
304
306
  },
305
307
  reset() {
@@ -339,7 +341,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
339
341
  };
340
342
  return field;
341
343
  }
342
- function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, onBlur) {
344
+ function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, maybeAutoSave) {
343
345
  // Keep a map of "item in the parent list" -> "that item's ObjectState"
344
346
  const rowMap = new Map();
345
347
  const _tick = mobx_1.observable({ value: 1 });
@@ -399,7 +401,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
399
401
  // Because we're reading from this.value, child will be the proxy version
400
402
  let childState = rowMap.get(child);
401
403
  if (!childState) {
402
- childState = newObjectState(config, parentState, child, undefined, onBlur);
404
+ childState = newObjectState(config, parentState, child, undefined, maybeAutoSave);
403
405
  rowMap.set(child, childState);
404
406
  }
405
407
  return childState;
@@ -442,8 +444,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
442
444
  },
443
445
  blur() {
444
446
  this._focused = false;
447
+ this.maybeAutoSave();
448
+ },
449
+ maybeAutoSave() {
445
450
  this.touched = true;
446
- onBlur();
451
+ maybeAutoSave();
447
452
  },
448
453
  set(values, opts = {}) {
449
454
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
@@ -462,7 +467,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
462
467
  }
463
468
  }
464
469
  // If we didn't have an existing child, just make a new object state
465
- childState = createObjectState(config, value, { onBlur });
470
+ childState = createObjectState(config, value, { maybeAutoSave });
466
471
  rowMap.set(value, childState);
467
472
  }
468
473
  // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
@@ -476,12 +481,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
476
481
  },
477
482
  add(value, spliceIndex) {
478
483
  // This is called by the user, so value should be a non-proxy value we should keep
479
- const childState = createObjectState(config, value, { onBlur });
484
+ const childState = createObjectState(config, value, { maybeAutoSave });
480
485
  rowMap.set(value, childState);
481
486
  this.ensureSet();
482
487
  this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
483
488
  _tick.value++;
484
- onBlur();
489
+ maybeAutoSave();
485
490
  },
486
491
  remove(indexOrValue) {
487
492
  this.ensureSet();
@@ -495,7 +500,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
495
500
  }
496
501
  }
497
502
  _tick.value++;
498
- onBlur();
503
+ maybeAutoSave();
499
504
  },
500
505
  reset() {
501
506
  if (originalCopy) {
@@ -39,6 +39,21 @@ describe("formState", () => {
39
39
  expect(a.title.required).toBeTruthy();
40
40
  expect(a.isPublished.required).toBeFalsy();
41
41
  });
42
+ it("trims whitespace within the required rule", () => {
43
+ const a = formState_1.createObjectState({ title: { type: "value", rules: [formState_1.required] } }, { title: "initial valid title" });
44
+ let numErrors = 0;
45
+ mobx_1.autorun(() => {
46
+ numErrors = a.title.errors.length;
47
+ });
48
+ expect(a.valid).toBeTruthy();
49
+ expect(numErrors).toEqual(0);
50
+ // When a value contains only whitespace
51
+ a.title.value = " ";
52
+ expect(a.title.valid).toBeFalsy();
53
+ expect(numErrors).toEqual(1);
54
+ expect(a.title.errors).toEqual(["Required"]);
55
+ expect(a.errors).toEqual(["title: Required"]);
56
+ });
42
57
  it("can set values", () => {
43
58
  const a = formState_1.createObjectState({ title: { type: "value" } }, { title: "b1" });
44
59
  expect(a.title.value).toEqual("b1");
@@ -285,6 +300,52 @@ describe("formState", () => {
285
300
  expect(a.firstName.valid).toBeTruthy();
286
301
  expect(a.lastName.errors).toEqual(["Last name cannot be first name"]);
287
302
  });
303
+ it("passes sanitized values to validation rules", () => {
304
+ // Given a field with a validation rule
305
+ const rule = jest.fn().mockReturnValue("Required");
306
+ const a = formState_1.createObjectState({ firstName: { type: "value", rules: [rule] } }, {});
307
+ expect(rule).toHaveBeenCalledTimes(0);
308
+ // When the field is set to empty string
309
+ a.firstName.value = "";
310
+ expect(a.firstName.errors).toEqual(["Required"]);
311
+ expect(rule).toHaveBeenCalledTimes(1);
312
+ // Then the validation rule was passed undefined
313
+ expect(rule).toHaveBeenCalledWith({
314
+ value: undefined,
315
+ originalValue: undefined,
316
+ key: "firstName",
317
+ object: expect.anything(),
318
+ });
319
+ });
320
+ it("passes trimmed values to validation rules on maybeAutoSave", () => {
321
+ // Given we have a required rule
322
+ const rule = jest.fn().mockReturnValue("Required");
323
+ const a = formState_1.createObjectState({ firstName: { type: "value", rules: [rule] } }, {});
324
+ expect(rule).toHaveBeenCalledTimes(0);
325
+ // When the user initially sets an empty string value
326
+ a.firstName.focus();
327
+ a.firstName.value = " ";
328
+ expect(a.firstName.errors).toEqual(["Required"]);
329
+ // Then initially the rule is called with the empty string
330
+ expect(rule).toHaveBeenCalledTimes(1);
331
+ expect(rule).toHaveBeenCalledWith({
332
+ value: " ",
333
+ originalValue: undefined,
334
+ key: "firstName",
335
+ object: expect.anything(),
336
+ });
337
+ // But then when the field maybeAutoSaves (i.e. via blur)
338
+ a.firstName.maybeAutoSave();
339
+ expect(a.firstName.errors).toEqual(["Required"]);
340
+ // Then the rule is called with the undefined
341
+ expect(rule).toHaveBeenCalledTimes(2);
342
+ expect(rule).toHaveBeenCalledWith({
343
+ value: undefined,
344
+ originalValue: undefined,
345
+ key: "firstName",
346
+ object: expect.anything(),
347
+ });
348
+ });
288
349
  it("simple value changes trigger observers", () => {
289
350
  const a = formState_1.createObjectState({
290
351
  title: { type: "value", rules: [formState_1.required] },
@@ -330,78 +391,78 @@ describe("formState", () => {
330
391
  a1.books.remove(0);
331
392
  expect(a1.dirty).toBeFalsy();
332
393
  });
333
- it("calls onBlur when adding or removing to a list", () => {
334
- const onBlur = jest.fn();
335
- const a1 = createAuthorInputState({ books: [] }, onBlur);
394
+ it("calls maybeAutoSave when adding or removing to a list", () => {
395
+ const maybeAutoSave = jest.fn();
396
+ const a1 = createAuthorInputState({ books: [] }, maybeAutoSave);
336
397
  expect(a1.books.dirty).toBeFalsy();
337
398
  a1.books.add({ title: "t2" });
338
399
  expect(a1.books.dirty).toBeTruthy();
339
400
  a1.books.remove(0);
340
401
  expect(a1.dirty).toBeFalsy();
341
- expect(onBlur).toBeCalledTimes(2);
402
+ expect(maybeAutoSave).toBeCalledTimes(2);
342
403
  });
343
- it("calls onBlur when programmatically setting a value", () => {
344
- const onBlur = jest.fn();
404
+ it("calls maybeAutoSave when programmatically setting a value", () => {
405
+ const maybeAutoSave = jest.fn();
345
406
  // Given an author listening for blur
346
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
407
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
347
408
  // When we programmatically set a field that isn't focused
348
409
  a1.firstName.value = "first";
349
- // Then we call onBlur
350
- expect(onBlur).toBeCalledTimes(1);
410
+ // Then we call maybeAutoSave
411
+ expect(maybeAutoSave).toBeCalledTimes(1);
351
412
  // And when we set a nested value
352
413
  a1.books.rows[0].title.value = "title";
353
- // Then we called onBlur again
354
- expect(onBlur).toBeCalledTimes(2);
414
+ // Then we called maybeAutoSave again
415
+ expect(maybeAutoSave).toBeCalledTimes(2);
355
416
  });
356
- it("can skip onBlur when programmatically setting a value", () => {
357
- const onBlur = jest.fn();
417
+ it("can skip maybeAutoSave when programmatically setting a value", () => {
418
+ const maybeAutoSave = jest.fn();
358
419
  // Given an author listening for blur
359
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
420
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
360
421
  // When we programmatically set a field that isn't focused
361
422
  a1.set({ firstName: "first" }, { autoSave: false });
362
- // Then we don't call onBlur
363
- expect(onBlur).toBeCalledTimes(0);
423
+ // Then we don't call maybeAutoSave
424
+ expect(maybeAutoSave).toBeCalledTimes(0);
364
425
  });
365
- it("defers calling onBlur when setting a bound value", () => {
366
- const onBlur = jest.fn();
426
+ it("defers calling maybeAutoSave when setting a focused value", () => {
427
+ const maybeAutoSave = jest.fn();
367
428
  // Given an author listening for blur
368
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
429
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
369
430
  // And the field is focused
370
431
  a1.firstName.focus();
371
432
  // When we we set the field
372
433
  a1.firstName.value = "first";
373
- // Then we don't call onBlur
374
- expect(onBlur).toBeCalledTimes(0);
434
+ // Then we don't call maybeAutoSave
435
+ expect(maybeAutoSave).toBeCalledTimes(0);
375
436
  });
376
- it("skips onBlur when refreshing", () => {
377
- const onBlur = jest.fn();
437
+ it("skips maybeAutoSave when refreshing", () => {
438
+ const maybeAutoSave = jest.fn();
378
439
  // Given an author listening for blur
379
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
440
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
380
441
  // When we programmatically set a field that isn't focused
381
442
  a1.set({ firstName: "first" }, { refreshing: true });
382
- // Then we don't call onBlur
383
- expect(onBlur).toBeCalledTimes(0);
443
+ // Then we don't call maybeAutoSave
444
+ expect(maybeAutoSave).toBeCalledTimes(0);
384
445
  });
385
- it("skips onBlur when resetting", () => {
386
- const onBlur = jest.fn();
446
+ it("skips maybeAutoSave when resetting", () => {
447
+ const maybeAutoSave = jest.fn();
387
448
  // Given an author listening for blur
388
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
389
- // And we called onBlur once
449
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
450
+ // And we called maybeAutoSave once
390
451
  a1.set({ firstName: "first" });
391
- expect(onBlur).toBeCalledTimes(1);
452
+ expect(maybeAutoSave).toBeCalledTimes(1);
392
453
  // When we reset
393
454
  a1.reset();
394
455
  // We don't call blur again
395
- expect(onBlur).toBeCalledTimes(1);
456
+ expect(maybeAutoSave).toBeCalledTimes(1);
396
457
  });
397
- it("skips onBlur when not dirty", () => {
398
- const onBlur = jest.fn();
458
+ it("skips maybeAutoSave when not dirty", () => {
459
+ const maybeAutoSave = jest.fn();
399
460
  // Given an author listening for blur
400
- const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, onBlur);
461
+ const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, maybeAutoSave);
401
462
  // When we programmatically set a field to it's existing valued
402
463
  a1.firstName.value = "first";
403
- // Then we don't call onBlur
404
- expect(onBlur).toBeCalledTimes(0);
464
+ // Then we don't call maybeAutoSave
465
+ expect(maybeAutoSave).toBeCalledTimes(0);
405
466
  });
406
467
  it("knows list of primitives are dirty", () => {
407
468
  const a1 = formState_1.createObjectState({ favoriteColors: { type: "value" } }, {});
@@ -964,6 +1025,20 @@ describe("formState", () => {
964
1025
  // Then we trim it
965
1026
  expect(formState.firstName.value).toEqual("f");
966
1027
  });
1028
+ it("trims string values on maybeAutoSave", () => {
1029
+ const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
1030
+ // Given the user is typing with spaces
1031
+ formState.firstName.focus();
1032
+ formState.firstName.set("f ");
1033
+ // And we initially keep the space
1034
+ expect(formState.firstName.value).toEqual("f ");
1035
+ // When the user hits Enter (which will call maybeAutoSave)
1036
+ formState.firstName.maybeAutoSave();
1037
+ // Then we trim it
1038
+ expect(formState.firstName.value).toEqual("f");
1039
+ // And the field is considered touched
1040
+ expect(formState.firstName.touched).toEqual(true);
1041
+ });
967
1042
  it("trims empty values to undefined on blur", () => {
968
1043
  const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
969
1044
  // Given the user is typing with only spaces
@@ -1102,8 +1177,8 @@ const authorWithBooksConfig = {
1102
1177
  },
1103
1178
  },
1104
1179
  };
1105
- function createAuthorInputState(input, onBlur) {
1106
- return formState_1.createObjectState(authorWithBooksConfig, input, { onBlur });
1180
+ function createAuthorInputState(input, maybeAutoSave) {
1181
+ return formState_1.createObjectState(authorWithBooksConfig, input, { maybeAutoSave });
1107
1182
  }
1108
1183
  const authorWithAddressConfig = {
1109
1184
  id: { type: "value" },
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { createObjectState, FieldState, ListFieldState, ObjectConfig, ObjectState, required, Rule, } from "./formState";
2
2
  export { useFormState } from "./useFormState";
3
+ export { useFormStates } from "./useFormStates";
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useFormState = exports.required = exports.createObjectState = void 0;
3
+ exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
4
4
  var formState_1 = require("./formState");
5
5
  Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return formState_1.createObjectState; } });
6
6
  Object.defineProperty(exports, "required", { enumerable: true, get: function () { return formState_1.required; } });
7
7
  var useFormState_1 = require("./useFormState");
8
8
  Object.defineProperty(exports, "useFormState", { enumerable: true, get: function () { return useFormState_1.useFormState; } });
9
+ var useFormStates_1 = require("./useFormStates");
10
+ Object.defineProperty(exports, "useFormStates", { enumerable: true, get: function () { return useFormStates_1.useFormStates; } });
@@ -4,5 +4,10 @@ require("@testing-library/jest-dom");
4
4
  const mobx_1 = require("mobx");
5
5
  // formState doesn't use actions
6
6
  mobx_1.configure({ enforceActions: "never" });
7
- beforeAll(() => jest.useFakeTimers("modern"));
8
- afterAll(() => jest.useRealTimers());
7
+ beforeEach(() => {
8
+ jest.useFakeTimers("modern");
9
+ });
10
+ afterEach(() => {
11
+ jest.runOnlyPendingTimers();
12
+ jest.useRealTimers();
13
+ });
@@ -37,21 +37,9 @@ export declare type UseFormStateOpts<T, I> = {
37
37
  *
38
38
  * Does not need to be stable/useMemo'd.
39
39
  */
40
- autoSave?: (state: ObjectState<T>) => void;
40
+ autoSave?: (state: ObjectState<T>) => Promise<unknown>;
41
41
  };
42
42
  /**
43
43
  * Creates a formState instance for editing in a form.
44
44
  */
45
45
  export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): ObjectState<T>;
46
- /**
47
- * Picks ony fields out of `instance` that are in the `formConfig`.
48
- *
49
- * This is useful for creating a form input from a GraphQL query result,
50
- * but ignoring extra data in the query that we don't want/need in the form.
51
- *
52
- * (Especially because the form.value will become our mutation's input type
53
- * that goes on the wire, we don't want superfluous data in it.)
54
- */
55
- export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
56
- [K in keyof T]: K extends keyof I ? I[K] : never;
57
- };
@@ -1,12 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.pickFields = exports.useFormState = void 0;
3
+ exports.useFormState = void 0;
4
4
  const is_plain_object_1 = require("is-plain-object");
5
- const mobx_1 = require("mobx");
6
5
  const react_1 = require("react");
7
6
  const formState_1 = require("./formState");
8
7
  const utils_1 = require("./utils");
8
+ // If the user's autoSave hook makes some last-minute `.set` calls to sneak
9
+ // in some business logic right before their GraphQL mutation call, ignore it
10
+ // so that we don't infinite loop.
9
11
  let isAutoSaving = false;
12
+ // `pendingAutoSave` is a flag for determining if we need to immediately call `maybeAutoSave` again after the initial Promise finishes
13
+ // This could happen if a field triggers auto-save while another field's auto-save is already in progress.
14
+ let pendingAutoSave = false;
15
+ let timerId;
10
16
  /**
11
17
  * Creates a formState instance for editing in a form.
12
18
  */
@@ -17,28 +23,47 @@ function useFormState(opts) {
17
23
  autoSaveRef.current = autoSave;
18
24
  const firstRunRef = react_1.useRef(true);
19
25
  // This is a little weird, but we need to know ahead of time, before the form useMemo, if we're working with classes/mobx proxies
20
- const [firstInitValue] = react_1.useState(() => initValue(config, init));
26
+ const [firstInitValue] = react_1.useState(() => utils_1.initValue(config, init));
21
27
  const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
22
28
  // If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
23
29
  const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
24
30
  const form = react_1.useMemo(() => {
25
- function onBlur() {
26
- // Don't use canSave() because we don't want to set touched for all of the field
31
+ function maybeAutoSave() {
32
+ if (isAutoSaving === "in-flight") {
33
+ pendingAutoSave = true;
34
+ }
35
+ else if (isAutoSaving === "queued") {
36
+ return;
37
+ }
38
+ // Don't use canSave() because we don't want to set touched for all the fields
27
39
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
28
- try {
29
- isAutoSaving = true;
30
- autoSaveRef.current(form);
31
- }
32
- finally {
33
- isAutoSaving = false;
34
- }
40
+ isAutoSaving = "queued";
41
+ // We use setTimeout as a cheap way to wait until the end of the current event listener
42
+ timerId = setTimeout(async () => {
43
+ try {
44
+ // 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.
46
+ const promise = autoSaveRef.current(form);
47
+ isAutoSaving = "in-flight";
48
+ await promise;
49
+ }
50
+ finally {
51
+ isAutoSaving = false;
52
+ if (pendingAutoSave) {
53
+ pendingAutoSave = false;
54
+ // Push out the follow-up by 1 tick to allow refreshes to happen to potentially
55
+ // un-dirty the just-saved data (e.g. if we run right away, the caller's maybeAutoSave
56
+ // will see a form.changedValue that thinks the just-saved data is still dirty).
57
+ setTimeout(maybeAutoSave, 0);
58
+ }
59
+ }
60
+ }, 0);
35
61
  }
36
62
  }
37
- const value = firstRunRef.current ? firstInitValue : initValue(config, init);
38
- const form = formState_1.createObjectState(config, value, { onBlur });
63
+ const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
64
+ const form = formState_1.createObjectState(config, value, { maybeAutoSave });
39
65
  form.readOnly = readOnly;
40
66
  // The identity of `addRules` is not stable, but assume that it is for better UX.
41
- // eslint-disable-next-line react-hooks/exhaustive-deps
42
67
  (addRules || (() => { }))(form);
43
68
  firstRunRef.current = true;
44
69
  return form;
@@ -56,7 +81,7 @@ function useFormState(opts) {
56
81
  firstRunRef.current = false;
57
82
  return;
58
83
  }
59
- form.set(initValue(config, init), { refreshing: true });
84
+ form.set(utils_1.initValue(config, init), { refreshing: true });
60
85
  }, [form, ...dep]);
61
86
  // Use useEffect so that we don't touch the form.init proxy during a render
62
87
  react_1.useEffect(() => {
@@ -65,59 +90,3 @@ function useFormState(opts) {
65
90
  return form;
66
91
  }
67
92
  exports.useFormState = useFormState;
68
- /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
69
- function initValue(config, init) {
70
- const initValue = init && "input" in init && "map" in init
71
- ? init.input
72
- ? init.map(init.input)
73
- : init.ifUndefined || {}
74
- : init || {};
75
- return pickFields(config, initValue);
76
- }
77
- /**
78
- * Picks ony fields out of `instance` that are in the `formConfig`.
79
- *
80
- * This is useful for creating a form input from a GraphQL query result,
81
- * but ignoring extra data in the query that we don't want/need in the form.
82
- *
83
- * (Especially because the form.value will become our mutation's input type
84
- * that goes on the wire, we don't want superfluous data in it.)
85
- */
86
- function pickFields(formConfig, instance) {
87
- // If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
88
- if (!is_plain_object_1.isPlainObject(instance)) {
89
- return instance;
90
- }
91
- return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
92
- const keyConfig = _keyConfig;
93
- const value = instance[key];
94
- if (keyConfig.type === "object") {
95
- if (value) {
96
- return [key, pickFields(keyConfig.config, value)];
97
- }
98
- else {
99
- return [key, value];
100
- }
101
- }
102
- else if (keyConfig.type === "list") {
103
- if (mobx_1.isObservable(value)) {
104
- // If we hit an observable array, leave it as the existing proxy so the our
105
- // ListFieldState will react to changes in the original array.
106
- return [key, value];
107
- }
108
- else if (value) {
109
- return [key, value.map((u) => pickFields(keyConfig.config, u))];
110
- }
111
- else {
112
- return [key, value];
113
- }
114
- }
115
- else if (keyConfig.type === "value") {
116
- return [key, value];
117
- }
118
- else {
119
- return utils_1.assertNever(keyConfig);
120
- }
121
- }));
122
- }
123
- exports.pickFields = pickFields;