@homebound/form-state 2.5.1 → 2.7.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 +46 -1
- package/dist/formState.d.ts +17 -0
- package/dist/formState.js +16 -4
- package/dist/formState.test.js +92 -0
- package/dist/formStateDomain.d.ts +1 -0
- package/dist/useFormStates.d.ts +9 -0
- package/dist/useFormStates.js +9 -7
- package/dist/useFormStates.test.js +21 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,9 +1,54 @@
|
|
|
1
1
|
# form-state
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
form-state is a headless form state management library, built on top of mobx.
|
|
4
|
+
|
|
5
|
+
It acts as a buffer between the canonical data/entity (i.e. the server-side data, or local Redux/GraphQL/etc. cache data) and the user's WIP data/entity that is being actively mutated in form fields.
|
|
6
|
+
|
|
7
|
+
It also keeps track of low-level form UX details like:
|
|
8
|
+
|
|
9
|
+
* 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)
|
|
12
|
+
* Enabling/disabling buttons/form UX based on the overall form-wide state
|
|
13
|
+
* Submitting the form should touch (validate) all fields
|
|
14
|
+
* 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
|
|
16
|
+
* Building a wire payload that has only changed fields
|
|
17
|
+
* Handles children, i.e. a `author: { books: [...} }` will include only changed books if necessary
|
|
18
|
+
|
|
19
|
+
# The Three Type/Shapes Mental Model
|
|
20
|
+
|
|
21
|
+
In general when working with forms (e.g. not just form-state), there are three types/shapes of data involved:
|
|
22
|
+
|
|
23
|
+
1. The input data/shape from the server (i.e. a GraphQL/REST query)
|
|
24
|
+
2. The form data/shape that is being reactively bound to form fields (i.e. used as `<TextField value=form.firstName onChange=(v) => form.firstName = v />`)
|
|
25
|
+
3. The mutation data/shape that will submit the change to the server (i.e. the GraphQL mutation/REST POST)
|
|
26
|
+
|
|
27
|
+
form-state generally refers to each of these shapes as:
|
|
28
|
+
|
|
29
|
+
* The input type
|
|
30
|
+
* (Hrm, in retrospect "input" is an unfortunate term b/c that is what GraphQL uses for its mutation types, i.e. `input SaveAuthorInput`...we should consider changing this).
|
|
31
|
+
* The form type
|
|
32
|
+
* The ...third type...
|
|
33
|
+
|
|
34
|
+
And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
Admittedly (and hopefully, b/c it makes the code simpler), the differences between each of these types can often be small, i.e.:
|
|
38
|
+
|
|
39
|
+
* The input type might have `{ author: { book: { id: "b:1" } }` but the mutation wants `{ author: { bookId: "b:1" } }`
|
|
40
|
+
* ...have other examples...
|
|
41
|
+
|
|
42
|
+
These are usually simple/mechanistic changes, but nonetheless just some boilerplate that form-state provides conventions for.
|
|
43
|
+
|
|
44
|
+
# Basic Usage
|
|
45
|
+
|
|
46
|
+
See the [sample](https://github.com/homebound-team/form-state/blob/main/src/FormStateApp.tsx).
|
|
4
47
|
|
|
5
48
|
# Todo
|
|
6
49
|
|
|
7
50
|
- Add conditional readonly logic, like `{ type: "field", readOnlyIf: i => i.isInternal.value }`
|
|
8
51
|
|
|
9
52
|
- Add `omitFromValue` so we can have two fields, `book.author.id` / `book.author.name`, where `book.author.name` is used for showing the author name, but `book.author.id` is the only field that is submitted to the server on mutation (maybe pair this with `Ref` based mutations)
|
|
53
|
+
|
|
54
|
+
- Undo/redo would in theory be neat and easy to do on top of the existing infra
|
package/dist/formState.d.ts
CHANGED
|
@@ -87,6 +87,23 @@ export interface SetOpts {
|
|
|
87
87
|
/** Whether this `set` should trigger an auto-save; defaults to true. */
|
|
88
88
|
autoSave?: boolean;
|
|
89
89
|
}
|
|
90
|
+
/** Internal `.set` opts for conditionally that form-state internally percolates. */
|
|
91
|
+
interface InternalSetOpts extends SetOpts {
|
|
92
|
+
/** `resetting` is the code calling `.reset()` to revert to original values. */
|
|
93
|
+
resetting?: boolean;
|
|
94
|
+
/** `refreshing` is when `useFormState` sees a new value. */
|
|
95
|
+
refreshing?: boolean;
|
|
96
|
+
}
|
|
97
|
+
export interface FieldStateInternal<T, V> extends FieldState<T, V> {
|
|
98
|
+
set(value: V, opts?: InternalSetOpts): void;
|
|
99
|
+
_isIdKey: boolean;
|
|
100
|
+
_isDeleteKey: boolean;
|
|
101
|
+
_isReadOnlyKey: boolean;
|
|
102
|
+
_focused: boolean;
|
|
103
|
+
}
|
|
104
|
+
export declare type ObjectStateInternal<T, P = any> = ObjectState<T, P> & {
|
|
105
|
+
set(value: P, opts?: InternalSetOpts): void;
|
|
106
|
+
};
|
|
90
107
|
/** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
|
|
91
108
|
export interface ListFieldState<T, U> extends Omit<FieldState<T, U[]>, "originalValue"> {
|
|
92
109
|
readonly rows: ReadonlyArray<ObjectState<U>>;
|
package/dist/formState.js
CHANGED
|
@@ -431,10 +431,22 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
431
431
|
},
|
|
432
432
|
get changedValue() {
|
|
433
433
|
const result = [];
|
|
434
|
-
const
|
|
434
|
+
const hasOpKey = Object.keys(listConfig.config).includes("op");
|
|
435
|
+
const hasLegacyOpKey = Object.keys(listConfig.config).some((key) => key === "delete" || key === "remove");
|
|
436
|
+
const incremental = listConfig.update === "incremental" ||
|
|
437
|
+
// Implicitly enable incremental mode if we see an op key
|
|
438
|
+
(listConfig.update === undefined && (hasOpKey || hasLegacyOpKey));
|
|
439
|
+
const exhaustive = !incremental;
|
|
435
440
|
this.rows.forEach((r) => {
|
|
436
|
-
|
|
437
|
-
|
|
441
|
+
var _a;
|
|
442
|
+
var _b;
|
|
443
|
+
if (exhaustive || r.dirty || r.isNewEntity) {
|
|
444
|
+
const changed = r.changedValue;
|
|
445
|
+
// Ensure we have an `op: include` key, following https://joist-orm.io/docs/features/partial-update-apis
|
|
446
|
+
if (incremental && hasOpKey) {
|
|
447
|
+
(_a = (_b = changed).op) !== null && _a !== void 0 ? _a : (_b.op = "include");
|
|
448
|
+
}
|
|
449
|
+
result.push(changed);
|
|
438
450
|
}
|
|
439
451
|
});
|
|
440
452
|
return result;
|
|
@@ -512,7 +524,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
512
524
|
this.rows.forEach((r) => {
|
|
513
525
|
r.save();
|
|
514
526
|
});
|
|
515
|
-
originalCopy = parentInstance[key];
|
|
527
|
+
originalCopy = [...(parentInstance[key] || [])];
|
|
516
528
|
_tick.value++;
|
|
517
529
|
},
|
|
518
530
|
ensureSet() {
|
package/dist/formState.test.js
CHANGED
|
@@ -979,6 +979,81 @@ describe("formState", () => {
|
|
|
979
979
|
books: [{ id: "b:1", title: "t1b" }],
|
|
980
980
|
});
|
|
981
981
|
});
|
|
982
|
+
it("uses the op key as a hint to use incremental list behavior", () => {
|
|
983
|
+
// Given an author
|
|
984
|
+
const formState = formState_1.createObjectState({
|
|
985
|
+
id: { type: "value" },
|
|
986
|
+
// And the books collection is not explicitly marked as incremental
|
|
987
|
+
books: {
|
|
988
|
+
type: "list",
|
|
989
|
+
config: {
|
|
990
|
+
id: { type: "value" },
|
|
991
|
+
title: { type: "value", rules: [formState_1.required] },
|
|
992
|
+
// But it does have an op key
|
|
993
|
+
op: { type: "value" },
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
}, {
|
|
997
|
+
id: "a:1",
|
|
998
|
+
firstName: "f",
|
|
999
|
+
books: [
|
|
1000
|
+
// And the books start out as included
|
|
1001
|
+
{ id: "b:1", title: "t1", op: "include" },
|
|
1002
|
+
{ id: "b:2", title: "t2", op: "include" },
|
|
1003
|
+
],
|
|
1004
|
+
});
|
|
1005
|
+
// And initially nothing is changed
|
|
1006
|
+
expect(formState.changedValue).toEqual({ id: "a:1" });
|
|
1007
|
+
// When we delete the 1st book
|
|
1008
|
+
formState.books.rows[0].op.value = "delete";
|
|
1009
|
+
// Then only the 1st book is included
|
|
1010
|
+
expect(formState.changedValue).toEqual({
|
|
1011
|
+
id: "a:1",
|
|
1012
|
+
books: [{ id: "b:1", op: "delete" }],
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
it("defaults the incremental op key to included when not set", () => {
|
|
1016
|
+
// Given an author
|
|
1017
|
+
const formState = formState_1.createObjectState({
|
|
1018
|
+
id: { type: "value" },
|
|
1019
|
+
// And the books collection is not explicitly marked as incremental
|
|
1020
|
+
books: {
|
|
1021
|
+
type: "list",
|
|
1022
|
+
config: {
|
|
1023
|
+
id: { type: "value" },
|
|
1024
|
+
title: { type: "value", rules: [formState_1.required] },
|
|
1025
|
+
// But it does have an op key
|
|
1026
|
+
op: { type: "value" },
|
|
1027
|
+
},
|
|
1028
|
+
},
|
|
1029
|
+
}, {
|
|
1030
|
+
id: "a:1",
|
|
1031
|
+
firstName: "f",
|
|
1032
|
+
books: [
|
|
1033
|
+
// And no op keys are initially set
|
|
1034
|
+
{ id: "b:1", title: "t1" },
|
|
1035
|
+
{ id: "b:2", title: "t2" },
|
|
1036
|
+
],
|
|
1037
|
+
});
|
|
1038
|
+
// And initially nothing is changed
|
|
1039
|
+
expect(formState.changedValue).toEqual({ id: "a:1" });
|
|
1040
|
+
// When we delete the 1st book
|
|
1041
|
+
formState.books.rows[0].op.value = "delete";
|
|
1042
|
+
// And add a 3rd book
|
|
1043
|
+
formState.books.add({ title: "t3" });
|
|
1044
|
+
// Then the 3rd book doesn't have an explicit op yet
|
|
1045
|
+
expect(formState.books.rows[2].op.value).toBeUndefined();
|
|
1046
|
+
// But when we create changedValue
|
|
1047
|
+
expect(formState.changedValue).toEqual({
|
|
1048
|
+
id: "a:1",
|
|
1049
|
+
books: [
|
|
1050
|
+
// Then the deleted book is included
|
|
1051
|
+
{ id: "b:1", op: "delete" },
|
|
1052
|
+
// And the 3rd book is included w/the `op: include` flag added
|
|
1053
|
+
{ title: "t3", op: "include" },
|
|
1054
|
+
],
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
982
1057
|
it("can observe value changes", () => {
|
|
983
1058
|
const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f" });
|
|
984
1059
|
let ticks = 0;
|
|
@@ -1145,6 +1220,23 @@ describe("formState", () => {
|
|
|
1145
1220
|
expect(formState.books.rows[1].isNewEntity).toBeTruthy();
|
|
1146
1221
|
expect(formState.books.isNewEntity).toBeFalsy();
|
|
1147
1222
|
});
|
|
1223
|
+
it("can properly evaluate dirty lists after calling 'save'", () => {
|
|
1224
|
+
// Given an empty list state
|
|
1225
|
+
const a1 = createAuthorInputState({ books: [] });
|
|
1226
|
+
expect(a1.books.dirty).toBeFalsy();
|
|
1227
|
+
// When adding a book
|
|
1228
|
+
a1.books.add({ title: "t2" });
|
|
1229
|
+
// Then the list state is dirty
|
|
1230
|
+
expect(a1.books.dirty).toBeTruthy();
|
|
1231
|
+
// When saving the form state
|
|
1232
|
+
a1.save();
|
|
1233
|
+
// Then the list is no longer dirty
|
|
1234
|
+
expect(a1.books.dirty).toBeFalsy();
|
|
1235
|
+
// When adding a new book
|
|
1236
|
+
a1.books.add({ title: "t3" });
|
|
1237
|
+
// The list is again dirty
|
|
1238
|
+
expect(a1.books.dirty).toBeTruthy();
|
|
1239
|
+
});
|
|
1148
1240
|
});
|
|
1149
1241
|
class ObservableObject {
|
|
1150
1242
|
constructor() {
|
package/dist/useFormStates.d.ts
CHANGED
|
@@ -2,8 +2,17 @@ import { ObjectConfig, ObjectState } from "./formState";
|
|
|
2
2
|
export declare type ObjectStateCache<T, I> = Record<string, [ObjectState<T>, I]>;
|
|
3
3
|
declare type UseFormStatesOpts<T, I> = {
|
|
4
4
|
config: ObjectConfig<T>;
|
|
5
|
+
/** Fired for each individual `ObjectState` when it's had a value change. */
|
|
5
6
|
autoSave?: (state: ObjectState<T>) => Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
|
|
9
|
+
*
|
|
10
|
+
* Does not need to be stable/useMemo'd.
|
|
11
|
+
*/
|
|
12
|
+
addRules?: (state: ObjectState<T>) => void;
|
|
13
|
+
/** Given an input to `getObjectState`, returns the identity value that we'll cache that value's form state on. */
|
|
6
14
|
getId: (v: I) => string;
|
|
15
|
+
/** Maps an input to `getObjectState` to the actual form shape `T`. */
|
|
7
16
|
map?: (input: Exclude<I, null | undefined>) => T;
|
|
8
17
|
};
|
|
9
18
|
export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
|
package/dist/useFormStates.js
CHANGED
|
@@ -5,7 +5,7 @@ const react_1 = require("react");
|
|
|
5
5
|
const formState_1 = require("./formState");
|
|
6
6
|
const utils_1 = require("./utils");
|
|
7
7
|
function useFormStates(opts) {
|
|
8
|
-
const { config, autoSave, getId, map } = opts;
|
|
8
|
+
const { config, autoSave, getId, map, addRules } = opts;
|
|
9
9
|
const objectStateCache = react_1.useMemo(() => ({}), [config]);
|
|
10
10
|
// Keep track of ObjectStates that triggered auto-save when a save was already in progress.
|
|
11
11
|
const pendingAutoSaves = react_1.useRef([]);
|
|
@@ -33,18 +33,20 @@ function useFormStates(opts) {
|
|
|
33
33
|
return {
|
|
34
34
|
getObjectState: (input) => {
|
|
35
35
|
const existing = objectStateCache[getId(input)];
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
let form = existing === null || existing === void 0 ? void 0 : existing[0];
|
|
37
|
+
// If it didn't exist, then add to the cache.
|
|
38
|
+
if (!form) {
|
|
39
|
+
form = formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
|
|
39
40
|
maybeAutoSave: () => maybeAutoSave(form),
|
|
40
41
|
});
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
if (addRules) {
|
|
43
|
+
addRules(form);
|
|
44
|
+
}
|
|
43
45
|
objectStateCache[getId(input)] = [form, input];
|
|
44
46
|
}
|
|
45
47
|
// If the source of truth changed, then update the existing state and return it.
|
|
46
48
|
if (existing && existing[1] !== input) {
|
|
47
|
-
|
|
49
|
+
form.set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
|
|
48
50
|
existing[1] = input;
|
|
49
51
|
}
|
|
50
52
|
return form;
|
|
@@ -133,4 +133,25 @@ describe("useFormStates", () => {
|
|
|
133
133
|
// Then the two form-states are no longer equal.
|
|
134
134
|
expect(r.statesEqual()).toHaveTextContent("false");
|
|
135
135
|
});
|
|
136
|
+
it("calls addRules once per form state", async () => {
|
|
137
|
+
// Given a user wants to use addRules
|
|
138
|
+
const addRules = jest.fn();
|
|
139
|
+
function TestComponent() {
|
|
140
|
+
const config = { id: { type: "value" }, firstName: { type: "value" } };
|
|
141
|
+
const { getObjectState } = useFormStates_1.useFormStates({
|
|
142
|
+
config,
|
|
143
|
+
addRules,
|
|
144
|
+
getId: (o) => o.id,
|
|
145
|
+
});
|
|
146
|
+
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0),
|
|
147
|
+
jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0)] }, void 0));
|
|
148
|
+
}
|
|
149
|
+
// When we render
|
|
150
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
151
|
+
// Then addRules was only called once
|
|
152
|
+
expect(addRules).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
136
154
|
});
|
|
155
|
+
function ChildComponent({ os }) {
|
|
156
|
+
return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
|
|
157
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"author": "Homebound",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@babel/core": "^7.13.1",
|
|
43
43
|
"@homebound/rtl-utils": "^2.59.2",
|
|
44
|
+
"@semantic-release/exec": "^5.0.0",
|
|
44
45
|
"@semantic-release/git": "^9.0.0",
|
|
45
46
|
"@storybook/addon-essentials": "^6.2.9",
|
|
46
47
|
"@storybook/addon-info": "^5.3.21",
|