@homebound/form-state 2.2.11 → 2.4.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.
@@ -25,7 +25,7 @@
25
25
  */
26
26
  export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<P, T> & {
27
27
  /** Sets the state of fields in `state`. */
28
- set(state: Partial<T>): void;
28
+ set(state: Partial<T>, opts?: SetOpts): void;
29
29
  /** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
30
30
  canSave(): boolean;
31
31
  };
@@ -72,14 +72,21 @@ 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
- set(value: V): void;
77
+ /** Triggers an auto-save; the caller (i.e. useFormState) should still dirty & valid check. */
78
+ maybeAutoSave(): void;
79
+ set(value: V, opts?: SetOpts): void;
78
80
  /** Reverts back to the original value and resets dirty/touched. */
79
81
  reset(): void;
80
82
  /** Accepts the current changed value (if any) as the original and resets dirty/touched. */
81
83
  save(): void;
82
84
  }
85
+ /** Public options for our `set` command. */
86
+ export interface SetOpts {
87
+ /** Whether this `set` should trigger an auto-save; defaults to true. */
88
+ autoSave?: boolean;
89
+ }
83
90
  /** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
84
91
  export interface ListFieldState<T, U> extends Omit<FieldState<T, U[]>, "originalValue"> {
85
92
  readonly rows: ReadonlyArray<ObjectState<U>>;
@@ -156,7 +163,7 @@ export declare type ObjectFieldConfig<U> = {
156
163
  * individual fields as well as the top-level form/object itself.
157
164
  */
158
165
  export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
159
- onBlur?: () => void;
166
+ maybeAutoSave?: () => void;
160
167
  }): ObjectState<T>;
161
168
  declare type Primitive = undefined | null | boolean | string | number | Function | Date | {
162
169
  toJSON(): any;
package/dist/formState.js CHANGED
@@ -5,14 +5,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createObjectState = exports.required = void 0;
7
7
  const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
8
- const is_plain_object_1 = __importDefault(require("is-plain-object"));
8
+ const is_plain_object_1 = require("is-plain-object");
9
9
  const mobx_1 = require("mobx");
10
10
  const utils_1 = require("./utils");
11
11
  /** A rule that validates `value` is not `undefined`, `null`, or empty string. */
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,10 +186,10 @@ 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
- let _originalValue = value === null ? undefined : is_plain_object_1.default(value) ? mobx_1.toJS(value) : value;
192
+ let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
192
193
  // Because we read/write the value directly back into parentInstance[key],
193
194
  // which itself is not a proxy, we use this as our "value changed" trigger.
194
195
  const _tick = mobx_1.observable({ value: 1 });
@@ -262,12 +263,15 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
262
263
  blur() {
263
264
  // Now that the user is done editing the field, we sneak in some trim logic
264
265
  if (this._focused) {
265
- this.maybeTrim();
266
+ this.maybeAutoSave();
266
267
  }
267
268
  this._focused = false;
269
+ },
270
+ maybeAutoSave() {
271
+ this.maybeTrim();
268
272
  // touched is readonly, but we're allowed to change it
269
273
  this.touched = true;
270
- onBlur();
274
+ maybeAutoSave();
271
275
  },
272
276
  set(value, opts = {}) {
273
277
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
@@ -298,8 +302,8 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
298
302
  }
299
303
  // If we're being set programmatically, i.e. we don't currently have focus,
300
304
  // call blur to trigger any auto-saves.
301
- if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed) {
302
- this.blur();
305
+ if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) {
306
+ this.maybeAutoSave();
303
307
  }
304
308
  },
305
309
  reset() {
@@ -309,7 +313,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
309
313
  this.touched = false;
310
314
  },
311
315
  save() {
312
- if (is_plain_object_1.default(this.originalValue)) {
316
+ if (is_plain_object_1.isPlainObject(this.originalValue)) {
313
317
  this.originalValue = mobx_1.toJS(this.value);
314
318
  }
315
319
  else {
@@ -339,7 +343,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
339
343
  };
340
344
  return field;
341
345
  }
342
- function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, onBlur) {
346
+ function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, maybeAutoSave) {
343
347
  // Keep a map of "item in the parent list" -> "that item's ObjectState"
344
348
  const rowMap = new Map();
345
349
  const _tick = mobx_1.observable({ value: 1 });
@@ -399,7 +403,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
399
403
  // Because we're reading from this.value, child will be the proxy version
400
404
  let childState = rowMap.get(child);
401
405
  if (!childState) {
402
- childState = newObjectState(config, parentState, child, undefined, onBlur);
406
+ childState = newObjectState(config, parentState, child, undefined, maybeAutoSave);
403
407
  rowMap.set(child, childState);
404
408
  }
405
409
  return childState;
@@ -442,8 +446,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
442
446
  },
443
447
  blur() {
444
448
  this._focused = false;
449
+ this.maybeAutoSave();
450
+ },
451
+ maybeAutoSave() {
445
452
  this.touched = true;
446
- onBlur();
453
+ maybeAutoSave();
447
454
  },
448
455
  set(values, opts = {}) {
449
456
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
@@ -462,7 +469,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
462
469
  }
463
470
  }
464
471
  // If we didn't have an existing child, just make a new object state
465
- childState = createObjectState(config, value, { onBlur });
472
+ childState = createObjectState(config, value, { maybeAutoSave });
466
473
  rowMap.set(value, childState);
467
474
  }
468
475
  // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
@@ -476,12 +483,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
476
483
  },
477
484
  add(value, spliceIndex) {
478
485
  // This is called by the user, so value should be a non-proxy value we should keep
479
- const childState = createObjectState(config, value, { onBlur });
486
+ const childState = createObjectState(config, value, { maybeAutoSave });
480
487
  rowMap.set(value, childState);
481
488
  this.ensureSet();
482
489
  this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
483
490
  _tick.value++;
484
- onBlur();
491
+ maybeAutoSave();
485
492
  },
486
493
  remove(indexOrValue) {
487
494
  this.ensureSet();
@@ -495,7 +502,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
495
502
  }
496
503
  }
497
504
  _tick.value++;
498
- onBlur();
505
+ maybeAutoSave();
499
506
  },
500
507
  reset() {
501
508
  if (originalCopy) {
@@ -538,7 +545,7 @@ function isEmpty(value) {
538
545
  *
539
546
  */
540
547
  function areEqual(a, b) {
541
- if (is_plain_object_1.default(a)) {
548
+ if (is_plain_object_1.isPlainObject(a)) {
542
549
  return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
543
550
  }
544
551
  if (hasToJSON(a) || hasToJSON(b)) {
@@ -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,69 +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("defers calling onBlur when setting a bound 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);
421
+ // When we programmatically set a field that isn't focused
422
+ a1.set({ firstName: "first" }, { autoSave: false });
423
+ // Then we don't call maybeAutoSave
424
+ expect(maybeAutoSave).toBeCalledTimes(0);
425
+ });
426
+ it("defers calling maybeAutoSave when setting a focused value", () => {
427
+ const maybeAutoSave = jest.fn();
428
+ // Given an author listening for blur
429
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
360
430
  // And the field is focused
361
431
  a1.firstName.focus();
362
432
  // When we we set the field
363
433
  a1.firstName.value = "first";
364
- // Then we don't call onBlur
365
- expect(onBlur).toBeCalledTimes(0);
434
+ // Then we don't call maybeAutoSave
435
+ expect(maybeAutoSave).toBeCalledTimes(0);
366
436
  });
367
- it("skips onBlur when refreshing", () => {
368
- const onBlur = jest.fn();
437
+ it("skips maybeAutoSave when refreshing", () => {
438
+ const maybeAutoSave = jest.fn();
369
439
  // Given an author listening for blur
370
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
440
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
371
441
  // When we programmatically set a field that isn't focused
372
442
  a1.set({ firstName: "first" }, { refreshing: true });
373
- // Then we don't call onBlur
374
- expect(onBlur).toBeCalledTimes(0);
443
+ // Then we don't call maybeAutoSave
444
+ expect(maybeAutoSave).toBeCalledTimes(0);
375
445
  });
376
- it("skips onBlur when resetting", () => {
377
- const onBlur = jest.fn();
446
+ it("skips maybeAutoSave when resetting", () => {
447
+ const maybeAutoSave = jest.fn();
378
448
  // Given an author listening for blur
379
- const a1 = createAuthorInputState({ books: [{}] }, onBlur);
380
- // And we called onBlur once
449
+ const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
450
+ // And we called maybeAutoSave once
381
451
  a1.set({ firstName: "first" });
382
- expect(onBlur).toBeCalledTimes(1);
452
+ expect(maybeAutoSave).toBeCalledTimes(1);
383
453
  // When we reset
384
454
  a1.reset();
385
455
  // We don't call blur again
386
- expect(onBlur).toBeCalledTimes(1);
456
+ expect(maybeAutoSave).toBeCalledTimes(1);
387
457
  });
388
- it("skips onBlur when not dirty", () => {
389
- const onBlur = jest.fn();
458
+ it("skips maybeAutoSave when not dirty", () => {
459
+ const maybeAutoSave = jest.fn();
390
460
  // Given an author listening for blur
391
- const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, onBlur);
461
+ const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, maybeAutoSave);
392
462
  // When we programmatically set a field to it's existing valued
393
463
  a1.firstName.value = "first";
394
- // Then we don't call onBlur
395
- expect(onBlur).toBeCalledTimes(0);
464
+ // Then we don't call maybeAutoSave
465
+ expect(maybeAutoSave).toBeCalledTimes(0);
396
466
  });
397
467
  it("knows list of primitives are dirty", () => {
398
468
  const a1 = formState_1.createObjectState({ favoriteColors: { type: "value" } }, {});
@@ -955,6 +1025,20 @@ describe("formState", () => {
955
1025
  // Then we trim it
956
1026
  expect(formState.firstName.value).toEqual("f");
957
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
+ });
958
1042
  it("trims empty values to undefined on blur", () => {
959
1043
  const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
960
1044
  // Given the user is typing with only spaces
@@ -1093,8 +1177,8 @@ const authorWithBooksConfig = {
1093
1177
  },
1094
1178
  },
1095
1179
  };
1096
- function createAuthorInputState(input, onBlur) {
1097
- return formState_1.createObjectState(authorWithBooksConfig, input, { onBlur });
1180
+ function createAuthorInputState(input, maybeAutoSave) {
1181
+ return formState_1.createObjectState(authorWithBooksConfig, input, { maybeAutoSave });
1098
1182
  }
1099
1183
  const authorWithAddressConfig = {
1100
1184
  id: { type: "value" },
@@ -1,14 +1,14 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.pickFields = exports.useFormState = void 0;
7
- const is_plain_object_1 = __importDefault(require("is-plain-object"));
4
+ const is_plain_object_1 = require("is-plain-object");
8
5
  const mobx_1 = require("mobx");
9
6
  const react_1 = require("react");
10
7
  const formState_1 = require("./formState");
11
8
  const utils_1 = require("./utils");
9
+ // If the user's autoSave hook makes some last-minute `.set` calls to sneak
10
+ // in some business logic right before their GraphQL mutation call, ignore it
11
+ // so that we don't infinite loop.
12
12
  let isAutoSaving = false;
13
13
  /**
14
14
  * Creates a formState instance for editing in a form.
@@ -21,12 +21,12 @@ function useFormState(opts) {
21
21
  const firstRunRef = react_1.useRef(true);
22
22
  // 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
23
23
  const [firstInitValue] = react_1.useState(() => initValue(config, init));
24
- const isWrappingMobxProxy = !is_plain_object_1.default(firstInitValue);
24
+ const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
25
25
  // If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
26
26
  const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
27
27
  const form = react_1.useMemo(() => {
28
- function onBlur() {
29
- // Don't use canSave() because we don't want to set touched for all of the field
28
+ function maybeAutoSave() {
29
+ // Don't use canSave() because we don't want to set touched for all the fields
30
30
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
31
31
  try {
32
32
  isAutoSaving = true;
@@ -38,7 +38,7 @@ function useFormState(opts) {
38
38
  }
39
39
  }
40
40
  const value = firstRunRef.current ? firstInitValue : initValue(config, init);
41
- const form = formState_1.createObjectState(config, value, { onBlur });
41
+ const form = formState_1.createObjectState(config, value, { maybeAutoSave });
42
42
  form.readOnly = readOnly;
43
43
  // The identity of `addRules` is not stable, but assume that it is for better UX.
44
44
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -88,7 +88,7 @@ function initValue(config, init) {
88
88
  */
89
89
  function pickFields(formConfig, instance) {
90
90
  // If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
91
- if (!is_plain_object_1.default(instance)) {
91
+ if (!is_plain_object_1.isPlainObject(instance)) {
92
92
  return instance;
93
93
  }
94
94
  return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
@@ -306,8 +306,8 @@ describe("useFormState", () => {
306
306
  });
307
307
  return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: () => setData(data2) }, void 0),
308
308
  jsx_runtime_1.jsx("button", { "data-testid": "add", onClick: () => form.books.add({ title: "New Book" }) }, void 0),
309
- jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => form.books.rows[0].title.blur() }, void 0),
310
- jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => form.books.rows[1].title.blur() }, void 0)] }, void 0)) }, void 0));
309
+ jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => focusAndBlur(form.books.rows[0].title) }, void 0),
310
+ jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => focusAndBlur(form.books.rows[1].title) }, void 0)] }, void 0)) }, void 0));
311
311
  }
312
312
  // Given a formState with `onBlur` set
313
313
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
@@ -472,3 +472,8 @@ const authorWithAddressAndBooksConfig = {
472
472
  },
473
473
  },
474
474
  };
475
+ /** Emulates a user focusing and then blurring a field. */
476
+ function focusAndBlur(state) {
477
+ state.focus();
478
+ state.blur();
479
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.2.11",
3
+ "version": "2.4.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -21,9 +21,13 @@
21
21
  "format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,md}\"",
22
22
  "semantic-release": "semantic-release"
23
23
  },
24
- "peerDependencies": {
24
+ "dependencies": {
25
+ "fast-deep-equal": "^3.1.3",
26
+ "is-plain-object": "^5.0.0",
25
27
  "mobx": "^6.3.2",
26
- "mobx-react": "^7.2.0",
28
+ "mobx-react": "^7.2.0"
29
+ },
30
+ "peerDependencies": {
27
31
  "react": ">=16"
28
32
  },
29
33
  "devDependencies": {