@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 +11 -4
- package/dist/FormStateApp.js +2 -2
- package/dist/FormStateApp.test.js +2 -2
- package/dist/config.d.ts +2 -0
- package/dist/fields/listField.js +4 -6
- package/dist/fields/objectField.js +6 -5
- package/dist/fields/valueField.d.ts +3 -3
- package/dist/fields/valueField.js +5 -5
- package/dist/formState.test.js +29 -11
- package/dist/useFormState.d.ts +23 -5
- package/dist/useFormState.js +26 -9
- package/dist/useFormState.test.js +31 -1
- package/dist/utils.d.ts +4 -1
- package/dist/utils.js +27 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+

|
|
2
|
+
[](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
|
|
11
|
-
* Which
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
package/dist/FormStateApp.js
CHANGED
|
@@ -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": "
|
|
56
|
-
jsx_runtime_1.jsx("button", Object.assign({ "data-testid": "
|
|
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.
|
|
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.
|
|
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> = {
|
package/dist/fields/listField.js
CHANGED
|
@@ -184,16 +184,14 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
184
184
|
_tick.value++;
|
|
185
185
|
maybeAutoSave();
|
|
186
186
|
},
|
|
187
|
-
|
|
187
|
+
revertChanges() {
|
|
188
188
|
if (originalCopy) {
|
|
189
189
|
this.set(originalCopy, { resetting: true });
|
|
190
|
-
this.rows.forEach((r) => r.
|
|
190
|
+
this.rows.forEach((r) => r.revertChanges());
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
|
-
|
|
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
|
-
|
|
136
|
-
getFields(this).forEach((f) => f.
|
|
136
|
+
revertChanges() {
|
|
137
|
+
getFields(this).forEach((f) => f.revertChanges());
|
|
137
138
|
},
|
|
138
139
|
// Saves all current values into _originalValue
|
|
139
|
-
|
|
140
|
-
getFields(this).forEach((f) => f.
|
|
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
|
-
|
|
36
|
+
revertChanges(): void;
|
|
37
37
|
/** Accepts the current changed value (if any) as the original and resets dirty/touched. */
|
|
38
|
-
|
|
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
|
-
|
|
121
|
+
revertChanges() {
|
|
122
122
|
if (!computed) {
|
|
123
123
|
this.set(this.originalValue, { resetting: true });
|
|
124
124
|
}
|
|
125
125
|
this.touched = false;
|
|
126
126
|
},
|
|
127
|
-
|
|
127
|
+
commitChanges() {
|
|
128
128
|
if (is_plain_object_1.isPlainObject(this.originalValue)) {
|
|
129
129
|
this.originalValue = mobx_1.toJS(this.value);
|
|
130
130
|
}
|
package/dist/formState.test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/dist/useFormState.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/useFormState.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
}
|