@homebound/form-state 2.11.0 → 2.14.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/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ ![npm](https://img.shields.io/npm/v/@homebound/form-state)
2
+ [![CircleCI](https://circleci.com/gh/homebound-team/form-state.svg?style=svg)](https://circleci.com/gh/homebound-team/form-state)
3
+
1
4
  # form-state
2
5
 
3
6
  form-state is a headless form state management library, built on top of mobx.
@@ -7,14 +10,18 @@ It acts as a buffer between the canonical data/entity (i.e. the server-side data
7
10
  It also keeps track of low-level form UX details like:
8
11
 
9
12
  * Which form fields are dirty
10
- * Which forms fields are valid/invalid
11
- * Which forms fields are touched (i.e. don't show validation errors for untouched fields)
13
+ * Which form fields are valid/invalid
14
+ * Which form fields are touched (i.e. don't show validation errors for untouched fields)
12
15
  * Enabling/disabling buttons/form UX based on the overall form-wide state
13
16
  * Submitting the form should touch (validate) all fields
14
17
  * Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
15
- * Queue and debounce auto-saves if one is already in-flight
18
+ * Queueing auto-saves if one is already in-flight
19
+ * Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
20
+ * Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
16
21
  * Building a wire payload that has only changed fields
17
- * Handles children, i.e. a `author: { books: [...} }` will include only changed books if necessary
22
+ * `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
23
+ * Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
24
+ * Child collections can be either exhausive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
18
25
 
19
26
  # The Three Type/Shapes Mental Model
20
27
 
@@ -52,8 +52,8 @@ function FormStateApp() {
52
52
  jsx_runtime_1.jsx("td", { children: formState.valid.toString() }, void 0),
53
53
  jsx_runtime_1.jsx("td", { children: formState.dirty.toString() }, void 0)] }, void 0) }, void 0)] }), void 0),
54
54
  jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "touch", onClick: () => (formState.touched = !formState.touched) }, { children: "touch" }), void 0),
55
- jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "reset", onClick: () => formState.reset() }, { children: "reset" }), void 0),
56
- jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "save", onClick: () => formState.save() }, { children: "save" }), void 0),
55
+ jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "revertChanges", onClick: () => formState.revertChanges() }, { children: "revert" }), void 0),
56
+ jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "commitChanges", onClick: () => formState.commitChanges() }, { children: "commit" }), void 0),
57
57
  jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "set", onClick: () => formState.set({ firstName: "a2" }) }, { children: "set" }), void 0)] }, void 0)] }, void 0)] }), void 0) }), void 0));
58
58
  } }, void 0));
59
59
  }
@@ -12,7 +12,7 @@ describe("FormStateApp", () => {
12
12
  expect(r.firstName_dirty()).toHaveTextContent("true");
13
13
  react_1.fireEvent.blur(r.firstName());
14
14
  expect(r.firstName_touched()).toHaveTextContent("true");
15
- rtl_utils_1.click(r.save);
15
+ rtl_utils_1.click(r.commitChanges);
16
16
  expect(r.firstName_dirty()).toHaveTextContent("false");
17
17
  expect(r.firstName_touched()).toHaveTextContent("false");
18
18
  });
@@ -20,7 +20,7 @@ describe("FormStateApp", () => {
20
20
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
21
21
  expect(r.firstName_original()).toHaveTextContent("a1");
22
22
  rtl_utils_1.click(r.set);
23
- rtl_utils_1.click(r.save);
23
+ rtl_utils_1.click(r.commitChanges);
24
24
  expect(r.firstName_original()).toHaveTextContent("a2");
25
25
  });
26
26
  });
package/dist/config.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare type ValueFieldConfig<T, V> = {
37
37
  computed?: boolean;
38
38
  /** Marks a field as being initially read-only, i.e. `field.readOnly = true/false` can change this default. */
39
39
  readOnly?: boolean;
40
+ /** Marks an array field to be order agnostic when determining changed/dirty states */
41
+ strictOrder?: false;
40
42
  };
41
43
  /** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
42
44
  export declare type ListFieldConfig<T, U> = {
@@ -184,16 +184,14 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
184
184
  _tick.value++;
185
185
  maybeAutoSave();
186
186
  },
187
- reset() {
187
+ revertChanges() {
188
188
  if (originalCopy) {
189
189
  this.set(originalCopy, { resetting: true });
190
- this.rows.forEach((r) => r.reset());
190
+ this.rows.forEach((r) => r.revertChanges());
191
191
  }
192
192
  },
193
- save() {
194
- this.rows.forEach((r) => {
195
- r.save();
196
- });
193
+ commitChanges() {
194
+ this.rows.forEach((r) => r.commitChanges());
197
195
  originalCopy = [...(parentInstance[key] || [])];
198
196
  _tick.value++;
199
197
  },
@@ -29,11 +29,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
29
29
  return proxy;
30
30
  }
31
31
  const fieldStates = Object.entries(config).map(([_key, _config]) => {
32
+ var _a;
32
33
  const key = _key;
33
34
  const config = _config;
34
35
  let field;
35
36
  if (config.type === "value") {
36
- field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
37
+ field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, (_a = config.strictOrder) !== null && _a !== void 0 ? _a : true, maybeAutoSave);
37
38
  }
38
39
  else if (config.type === "list") {
39
40
  field = listField_1.newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
@@ -132,12 +133,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
132
133
  });
133
134
  },
134
135
  // Resets all fields back to their original values
135
- reset() {
136
- getFields(this).forEach((f) => f.reset());
136
+ revertChanges() {
137
+ getFields(this).forEach((f) => f.revertChanges());
137
138
  },
138
139
  // Saves all current values into _originalValue
139
- save() {
140
- getFields(this).forEach((f) => f.save());
140
+ commitChanges() {
141
+ getFields(this).forEach((f) => f.commitChanges());
141
142
  },
142
143
  // Create a result that is only populated with changed keys
143
144
  get changedValue() {
@@ -33,9 +33,9 @@ export interface FieldState<T, V> {
33
33
  maybeAutoSave(): void;
34
34
  set(value: V, opts?: SetOpts): void;
35
35
  /** Reverts back to the original value and resets dirty/touched. */
36
- reset(): void;
36
+ revertChanges(): void;
37
37
  /** Accepts the current changed value (if any) as the original and resets dirty/touched. */
38
- save(): void;
38
+ commitChanges(): void;
39
39
  }
40
40
  /** Public options for our `set` command. */
41
41
  export interface SetOpts {
@@ -56,4 +56,4 @@ export interface FieldStateInternal<T, V> extends FieldState<T, V> {
56
56
  _isReadOnlyKey: boolean;
57
57
  _focused: boolean;
58
58
  }
59
- export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
59
+ export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
@@ -5,7 +5,7 @@ const is_plain_object_1 = require("is-plain-object");
5
5
  const mobx_1 = require("mobx");
6
6
  const rules_1 = require("../rules");
7
7
  const utils_1 = require("../utils");
8
- function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
8
+ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
9
9
  // keep a copy here for reference equality
10
10
  const value = parentInstance[key];
11
11
  let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
@@ -35,7 +35,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
35
35
  this.set(v);
36
36
  },
37
37
  get dirty() {
38
- return !utils_1.areEqual(this.originalValue, this.value);
38
+ return !utils_1.areEqual(this.originalValue, this.value, strictOrder);
39
39
  },
40
40
  /** Returns whether this field is readOnly, although if our parent is readOnly then it trumps. */
41
41
  get readOnly() {
@@ -106,7 +106,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
106
106
  const coerceEmptyList = value && value instanceof Array && value.length === 0 && utils_1.isEmpty(this.originalValue);
107
107
  const newValue = keepNull ? null : utils_1.isEmpty(value) || coerceEmptyList ? undefined : value;
108
108
  // Set the value on our parent object
109
- const changed = !utils_1.areEqual(newValue, this.value);
109
+ const changed = !utils_1.areEqual(newValue, this.value, strictOrder);
110
110
  parentInstance[key] = newValue;
111
111
  _tick.value++;
112
112
  if (opts.refreshing) {
@@ -118,13 +118,13 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
118
118
  this.maybeAutoSave();
119
119
  }
120
120
  },
121
- reset() {
121
+ revertChanges() {
122
122
  if (!computed) {
123
123
  this.set(this.originalValue, { resetting: true });
124
124
  }
125
125
  this.touched = false;
126
126
  },
127
- save() {
127
+ commitChanges() {
128
128
  if (is_plain_object_1.isPlainObject(this.originalValue)) {
129
129
  this.originalValue = mobx_1.toJS(this.value);
130
130
  }
@@ -452,7 +452,7 @@ describe("formState", () => {
452
452
  a1.set({ firstName: "first" });
453
453
  expect(maybeAutoSave).toBeCalledTimes(1);
454
454
  // When we reset
455
- a1.reset();
455
+ a1.revertChanges();
456
456
  // We don't call blur again
457
457
  expect(maybeAutoSave).toBeCalledTimes(1);
458
458
  });
@@ -487,6 +487,24 @@ describe("formState", () => {
487
487
  a1.favoriteColors.set([]);
488
488
  expect(a1.dirty).toBeFalsy();
489
489
  });
490
+ it("can evaluate arrays to be equal even if order of options are different from original when strictOrder = false", () => {
491
+ // Given an array field value where we define `strictOrder` as false
492
+ const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value", strictOrder: false } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
493
+ expect(a1.favoriteColors.dirty).toBeFalsy();
494
+ // When changing the order of the values
495
+ a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
496
+ // Then expect the field to not be dirty
497
+ expect(a1.dirty).toBeFalsy();
498
+ });
499
+ it("evaluates arrays to be equal based on order by default", () => {
500
+ // Given an array field value
501
+ const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value" } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
502
+ expect(a1.favoriteColors.dirty).toBeFalsy();
503
+ // When we change the order of the values
504
+ a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
505
+ // Then the field should be dirty
506
+ expect(a1.dirty).toBeTruthy();
507
+ });
490
508
  it("knows originally unset fields are dirty", () => {
491
509
  // Given firstName is purposefully not set when originally initialized
492
510
  const a1 = createAuthorInputState({});
@@ -541,7 +559,7 @@ describe("formState", () => {
541
559
  a1.books.add({ title: "b3" });
542
560
  expect(a1.books.touched).toEqual(true);
543
561
  expect(a1.dirty).toBeTruthy();
544
- a1.reset();
562
+ a1.revertChanges();
545
563
  expect(a1.firstName.value).toBe("a1");
546
564
  expect(a1.firstName.touched).toBeFalsy();
547
565
  expect(a1.lastName.value).toBe("aL1");
@@ -577,7 +595,7 @@ describe("formState", () => {
577
595
  // And the "readOnly=true" operation has "beat" the reset operation
578
596
  a1.readOnly = true;
579
597
  // When we reset
580
- a1.reset();
598
+ a1.revertChanges();
581
599
  // Then it still works
582
600
  expect(a1.firstName.value).toBe("a1");
583
601
  expect(a1.firstName.touched).toBeFalsy();
@@ -607,15 +625,15 @@ describe("formState", () => {
607
625
  a1.books.rows[1].set({ title: "bb3" });
608
626
  // verify ValueFieldState is dirty, then save, then no longer dirty.
609
627
  expect(a1.firstName.dirty).toBeTruthy();
610
- a1.firstName.save();
628
+ a1.firstName.commitChanges();
611
629
  expect(a1.firstName.dirty).toBeFalsy();
612
630
  // verify ListFieldState is dirty, then save, then no longer dirty.
613
631
  expect(a1.books.dirty).toBeTruthy();
614
- a1.books.save();
632
+ a1.books.commitChanges();
615
633
  expect(a1.books.dirty).toBeFalsy();
616
634
  // Verify the remaining form is still dirty
617
635
  expect(a1.dirty).toBeTruthy();
618
- a1.save();
636
+ a1.commitChanges();
619
637
  // Verify after save the whole form is no longer dirty.
620
638
  expect(a1.dirty).toBeFalsy();
621
639
  });
@@ -839,7 +857,7 @@ describe("formState", () => {
839
857
  expect(a.books.rows[1].readOnly).toBeFalsy();
840
858
  expect(a.books.rows[1].title.readOnly).toBeFalsy();
841
859
  // And reset does not blow up
842
- a.reset();
860
+ a.revertChanges();
843
861
  });
844
862
  it("can set nested values when original null", () => {
845
863
  const a = createAuthorWithAddressInputState({ address: null });
@@ -894,7 +912,7 @@ describe("formState", () => {
894
912
  expect(formState.fullName.value).toEqual("first last");
895
913
  formState.firstName.value = "change";
896
914
  expect(formState.fullName.value).toEqual("change last");
897
- formState.reset();
915
+ formState.revertChanges();
898
916
  expect(formState.firstName.value).toEqual("first");
899
917
  });
900
918
  it("can set computeds that have setters", () => {
@@ -907,7 +925,7 @@ describe("formState", () => {
907
925
  expect(formState.firstName.value).toEqual("Bob");
908
926
  formState.fullName.set("Fred Smith");
909
927
  expect(formState.firstName.value).toEqual("Fred");
910
- formState.reset();
928
+ formState.revertChanges();
911
929
  expect(formState.firstName.value).toEqual("first");
912
930
  });
913
931
  it("can mark fields as read only", () => {
@@ -920,7 +938,7 @@ describe("formState", () => {
920
938
  expect(formState.fullName.readOnly).toEqual(true);
921
939
  formState.firstName.value = "change";
922
940
  expect(formState.fullName.value).toEqual("change last");
923
- formState.reset();
941
+ formState.revertChanges();
924
942
  expect(formState.firstName.value).toEqual("first");
925
943
  });
926
944
  it("lets rules validate against other fields", () => {
@@ -1300,7 +1318,7 @@ describe("formState", () => {
1300
1318
  // Then the list state is dirty
1301
1319
  expect(a1.books.dirty).toBeTruthy();
1302
1320
  // When saving the form state
1303
- a1.save();
1321
+ a1.commitChanges();
1304
1322
  // Then the list is no longer dirty
1305
1323
  expect(a1.books.dirty).toBeFalsy();
1306
1324
  // When adding a new book
@@ -1,5 +1,20 @@
1
1
  import { ObjectConfig } from "./config";
2
2
  import { ObjectState } from "./fields/objectField";
3
+ export declare type Query<I> = {
4
+ data: I;
5
+ loading: boolean;
6
+ error?: any;
7
+ };
8
+ export declare type InputAndMap<T, I> = {
9
+ input: I;
10
+ map: (input: Exclude<I, null | undefined>) => T;
11
+ ifUndefined?: T;
12
+ };
13
+ export declare type QueryAndMap<T, I> = {
14
+ query: Query<I>;
15
+ map: (input: Exclude<I, null | undefined>) => T;
16
+ ifUndefined?: T;
17
+ };
3
18
  export declare type UseFormStateOpts<T, I> = {
4
19
  /** The form configuration, should be a module-level const or useMemo'd. */
5
20
  config: ObjectConfig<T>;
@@ -20,11 +35,7 @@ export declare type UseFormStateOpts<T, I> = {
20
35
  * only call `init.map` if it's set, otherwise we'll use `init.ifDefined` or `{}`, saving you
21
36
  * from having to null check within your `init.map` function.
22
37
  */
23
- init?: T | {
24
- input: I;
25
- map: (input: Exclude<I, null | undefined>) => T;
26
- ifUndefined?: T;
27
- };
38
+ init?: T | InputAndMap<T, I> | QueryAndMap<T, I>;
28
39
  /**
29
40
  * A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
30
41
  *
@@ -33,6 +44,13 @@ export declare type UseFormStateOpts<T, I> = {
33
44
  addRules?: (state: ObjectState<T>) => void;
34
45
  /** Whether the form should be read only, when changed it won't re-create the whole form. */
35
46
  readOnly?: boolean;
47
+ /**
48
+ * Whether the form is loading.
49
+ *
50
+ * Note that we also will infer loading from `init.input === undefined` or `init.query.loading`,
51
+ * so you only need to set this directly if you're not using either of those conventions.
52
+ */
53
+ loading?: boolean;
36
54
  /**
37
55
  * Fired when the form should auto-save, i.e. after a) blur and b) all fields are valid.
38
56
  *
@@ -16,7 +16,7 @@ let pendingAutoSave = false;
16
16
  * Creates a formState instance for editing in a form.
17
17
  */
18
18
  function useFormState(opts) {
19
- const { config, init, addRules, readOnly = false, autoSave } = opts;
19
+ const { config, init, addRules, readOnly = false, loading, autoSave } = opts;
20
20
  // Use a ref so our memo'ized `onBlur` always see the latest value
21
21
  const autoSaveRef = react_1.useRef(autoSave);
22
22
  autoSaveRef.current = autoSave;
@@ -25,7 +25,7 @@ function useFormState(opts) {
25
25
  const [firstInitValue] = react_1.useState(() => utils_1.initValue(config, init));
26
26
  const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
27
27
  // If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
28
- const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
28
+ const dep = utils_1.isInput(init) ? makeArray(init.input) : utils_1.isQuery(init) ? [init.query.data, init.query.loading] : [];
29
29
  const form = react_1.useMemo(() => {
30
30
  function maybeAutoSave() {
31
31
  if (isAutoSaving === "in-flight") {
@@ -62,9 +62,7 @@ function useFormState(opts) {
62
62
  const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
63
63
  const form = objectField_1.createObjectState(config, value, { maybeAutoSave });
64
64
  form.readOnly = readOnly;
65
- if (init && "input" in init && !("ifUndefined" in init)) {
66
- form.loading = init.input === undefined;
67
- }
65
+ setLoading(form, opts);
68
66
  // The identity of `addRules` is not stable, but assume that it is for better UX.
69
67
  (addRules || (() => { }))(form);
70
68
  firstRunRef.current = true;
@@ -83,15 +81,34 @@ function useFormState(opts) {
83
81
  firstRunRef.current = false;
84
82
  return;
85
83
  }
86
- if (init && "input" in init && !("ifUndefined" in init)) {
87
- form.loading = init.input === undefined;
88
- }
84
+ setLoading(form, opts);
89
85
  form.set(utils_1.initValue(config, init), { refreshing: true });
90
86
  }, [form, ...dep]);
91
87
  // Use useEffect so that we don't touch the form.init proxy during a render
92
88
  react_1.useEffect(() => {
93
89
  form.readOnly = readOnly;
94
- }, [form, readOnly]);
90
+ if (loading !== undefined) {
91
+ form.loading = loading;
92
+ }
93
+ }, [form, readOnly, loading]);
95
94
  return form;
96
95
  }
97
96
  exports.useFormState = useFormState;
97
+ function setLoading(form, opts) {
98
+ const { loading, init } = opts;
99
+ if (loading !== undefined) {
100
+ // Prefer the explicit/top-level opts.loading if it's set
101
+ form.loading = loading;
102
+ }
103
+ else if (utils_1.isInput(init) && !init.ifUndefined) {
104
+ // Otherwise, check for `init.input`
105
+ form.loading = init.input === undefined;
106
+ }
107
+ else if (utils_1.isQuery(init)) {
108
+ // Or `query.loading`
109
+ form.loading = init.query.loading;
110
+ }
111
+ }
112
+ function makeArray(input) {
113
+ return Array.isArray(input) ? input : [input];
114
+ }
@@ -454,6 +454,21 @@ describe("useFormState", () => {
454
454
  expect(autoSaveStub).toBeCalledTimes(1);
455
455
  expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "first", lastName: "first" });
456
456
  });
457
+ it("sets loading if opts.loading is true", async () => {
458
+ const config = { firstName: { type: "value" } };
459
+ function TestComponent({ loading }) {
460
+ const form = useFormState_1.useFormState({ config, loading });
461
+ return jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "loading" }, { children: String(form.loading) }), void 0) }, void 0);
462
+ }
463
+ // And we initially pass in `init.query.loading: true`
464
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, { loading: true }, void 0));
465
+ // Then the form is marked as loading
466
+ expect(r.loading()).toHaveTextContent("true");
467
+ // And when the query is not loading
468
+ await r.rerender(jsx_runtime_1.jsx(TestComponent, { loading: false }, void 0));
469
+ // Then the form is marked as not loading
470
+ expect(r.loading()).toHaveTextContent("false");
471
+ });
457
472
  it("sets loading if input.data is undefined", async () => {
458
473
  const config = { firstName: { type: "value" } };
459
474
  function TestComponent({ data }) {
@@ -464,11 +479,26 @@ describe("useFormState", () => {
464
479
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, { data: undefined }, void 0));
465
480
  // Then the form is marked as loading
466
481
  expect(r.loading()).toHaveTextContent("true");
467
- // And when the data is loading
482
+ // And when the data is no longer undefined
468
483
  await r.rerender(jsx_runtime_1.jsx(TestComponent, { data: { firstName: "first" } }, void 0));
469
484
  // Then the form is marked as not loading
470
485
  expect(r.loading()).toHaveTextContent("false");
471
486
  });
487
+ it("sets loading if query.loading is true", async () => {
488
+ const config = { firstName: { type: "value" } };
489
+ function TestComponent({ loading, data }) {
490
+ const form = useFormState_1.useFormState({ config, init: { query: { data, loading, error: null }, map: (d) => d } });
491
+ return jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "loading" }, { children: String(form.loading) }), void 0) }, void 0);
492
+ }
493
+ // And we initially pass in `init.query.loading: true`
494
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, { loading: true, data: undefined }, void 0));
495
+ // Then the form is marked as loading
496
+ expect(r.loading()).toHaveTextContent("true");
497
+ // And when the query is not loading
498
+ await r.rerender(jsx_runtime_1.jsx(TestComponent, { loading: false, data: { firstName: "first" } }, void 0));
499
+ // Then the form is marked as not loading
500
+ expect(r.loading()).toHaveTextContent("false");
501
+ });
472
502
  });
473
503
  const authorConfig = {
474
504
  id: { type: "value" },
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ObjectConfig } from "./config";
2
+ import { InputAndMap, QueryAndMap, UseFormStateOpts } from "./useFormState";
2
3
  export declare type Builtin = Date | Function | Uint8Array | string | number | boolean;
3
4
  export declare type Primitive = undefined | null | boolean | string | number | Function | Date | {
4
5
  toJSON(): any;
@@ -11,6 +12,8 @@ export declare function fail(message?: string): never;
11
12
  export declare function assertNever(x: never): never;
12
13
  /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
13
14
  export declare function initValue<T>(config: ObjectConfig<T>, init: any): T;
15
+ export declare function isInput<T, I>(init: UseFormStateOpts<T, I>["init"]): init is InputAndMap<T, I>;
16
+ export declare function isQuery<T, I>(init: UseFormStateOpts<T, I>["init"]): init is QueryAndMap<T, I>;
14
17
  /**
15
18
  * Picks ony fields out of `instance` that are in the `formConfig`.
16
19
  *
@@ -34,7 +37,7 @@ export declare function isEmpty(value: any): boolean;
34
37
  * - objects that implement `toJSON`
35
38
  *
36
39
  */
37
- export declare function areEqual<T>(a?: T, b?: T): boolean;
40
+ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolean;
38
41
  export declare function hasToJSON(o?: unknown): o is {
39
42
  toJSON(): void;
40
43
  };
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.initValue = exports.assertNever = exports.fail = void 0;
6
+ exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
7
7
  const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
8
8
  const is_plain_object_1 = require("is-plain-object");
9
9
  const mobx_1 = require("mobx");
@@ -17,14 +17,27 @@ function assertNever(x) {
17
17
  exports.assertNever = assertNever;
18
18
  /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
19
19
  function initValue(config, init) {
20
- const initValue = init && "input" in init && "map" in init
21
- ? init.input
22
- ? init.map(init.input)
23
- : init.ifUndefined || {}
24
- : init || {};
25
- return pickFields(config, initValue);
20
+ let value;
21
+ if (isInput(init)) {
22
+ value = init.input ? init.map(init.input) : init.ifUndefined;
23
+ }
24
+ else if (isQuery(init)) {
25
+ value = init.query.data ? init.map(init.query.data) : init.ifUndefined;
26
+ }
27
+ else {
28
+ value = init;
29
+ }
30
+ return pickFields(config, value !== null && value !== void 0 ? value : {});
26
31
  }
27
32
  exports.initValue = initValue;
33
+ function isInput(init) {
34
+ return !!init && "input" in init && "map" in init;
35
+ }
36
+ exports.isInput = isInput;
37
+ function isQuery(init) {
38
+ return !!init && "query" in init && "map" in init;
39
+ }
40
+ exports.isQuery = isQuery;
28
41
  /**
29
42
  * Picks ony fields out of `instance` that are in the `formConfig`.
30
43
  *
@@ -89,7 +102,7 @@ exports.isEmpty = isEmpty;
89
102
  * - objects that implement `toJSON`
90
103
  *
91
104
  */
92
- function areEqual(a, b) {
105
+ function areEqual(a, b, strictOrder) {
93
106
  if (is_plain_object_1.isPlainObject(a)) {
94
107
  return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
95
108
  }
@@ -99,7 +112,12 @@ function areEqual(a, b) {
99
112
  return fast_deep_equal_1.default(a1, b1);
100
113
  }
101
114
  if (a && b && a instanceof Array && b instanceof Array) {
102
- return fast_deep_equal_1.default(a, b);
115
+ if (strictOrder !== false) {
116
+ return fast_deep_equal_1.default(a, b);
117
+ }
118
+ if (a.length !== b.length)
119
+ return false;
120
+ return a.every((a1) => b.some((b1) => fast_deep_equal_1.default(a1, b1)));
103
121
  }
104
122
  return a === b;
105
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.11.0",
3
+ "version": "2.14.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",