@homebound/form-state 2.2.10 → 2.3.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/dist/FormStateApp.js +5 -5
- package/dist/formState.d.ts +10 -61
- package/dist/formState.js +7 -115
- package/dist/formState.test.js +9 -391
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/useFormState.d.ts +57 -0
- package/dist/useFormState.js +123 -0
- package/dist/useFormState.test.d.ts +1 -0
- package/dist/useFormState.test.js +474 -0
- package/package.json +7 -3
package/dist/FormStateApp.js
CHANGED
|
@@ -3,9 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.FormStateApp = void 0;
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
5
|
const mobx_react_1 = require("mobx-react");
|
|
6
|
-
const
|
|
6
|
+
const index_1 = require("./index");
|
|
7
7
|
function FormStateApp() {
|
|
8
|
-
const formState =
|
|
8
|
+
const formState = index_1.useFormState({
|
|
9
9
|
config: formConfig,
|
|
10
10
|
// Simulate getting the initial form state back from a server call
|
|
11
11
|
init: {
|
|
@@ -60,13 +60,13 @@ function FormStateApp() {
|
|
|
60
60
|
exports.FormStateApp = FormStateApp;
|
|
61
61
|
// Configure the fields/behavior for AuthorInput's fields
|
|
62
62
|
const formConfig = {
|
|
63
|
-
firstName: { type: "value", rules: [
|
|
64
|
-
lastName: { type: "value", rules: [
|
|
63
|
+
firstName: { type: "value", rules: [index_1.required] },
|
|
64
|
+
lastName: { type: "value", rules: [index_1.required], readOnly: true },
|
|
65
65
|
books: {
|
|
66
66
|
type: "list",
|
|
67
67
|
rules: [({ value: list }) => ((list || []).length === 0 ? "Empty" : undefined)],
|
|
68
68
|
config: {
|
|
69
|
-
title: { type: "value", rules: [
|
|
69
|
+
title: { type: "value", rules: [index_1.required] },
|
|
70
70
|
},
|
|
71
71
|
},
|
|
72
72
|
};
|
package/dist/formState.d.ts
CHANGED
|
@@ -1,47 +1,3 @@
|
|
|
1
|
-
export declare type UseFormStateOpts<T, I> = {
|
|
2
|
-
/** The form configuration, should be a module-level const or useMemo'd. */
|
|
3
|
-
config: ObjectConfig<T>;
|
|
4
|
-
/**
|
|
5
|
-
* Provides the form's initial value.
|
|
6
|
-
*
|
|
7
|
-
* User's can either:
|
|
8
|
-
*
|
|
9
|
-
* - Provide no initial value (don't set `init` at all)
|
|
10
|
-
* - Provide an initial value that already matches the form type `T` (i.e. set `init: data`)
|
|
11
|
-
* - Provide an initial value from an object that _almost_ matches the form type `T`,
|
|
12
|
-
* but needs to be mapped from it's input type `I` to the form type
|
|
13
|
-
* (i.e. set `init: { input: data, map: (data) => ...}`).
|
|
14
|
-
*
|
|
15
|
-
* The value of using the 3rd option is that: a) we internally `useMemo` on the identity of the
|
|
16
|
-
* `init.input` (i.e. a response from an Apollo hook) and don't require the `map` function
|
|
17
|
-
* to have a stable identity, and also b) we will null-check/undefined-check `init.input` and
|
|
18
|
-
* only call `init.map` if it's set, otherwise we'll use `init.ifDefined` or `{}`, saving you
|
|
19
|
-
* from having to null check within your `init.map` function.
|
|
20
|
-
*/
|
|
21
|
-
init?: T | {
|
|
22
|
-
input: I;
|
|
23
|
-
map: (input: Exclude<I, null | undefined>) => T;
|
|
24
|
-
ifUndefined?: T;
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
|
|
28
|
-
*
|
|
29
|
-
* Does not need to be stable/useMemo'd.
|
|
30
|
-
*/
|
|
31
|
-
addRules?: (state: ObjectState<T>) => void;
|
|
32
|
-
/** Whether the form should be read only, when changed it won't re-create the whole form. */
|
|
33
|
-
readOnly?: boolean;
|
|
34
|
-
/**
|
|
35
|
-
* Fired when the form should auto-save, i.e. after a) blur and b) all fields are valid.
|
|
36
|
-
*
|
|
37
|
-
* Does not need to be stable/useMemo'd.
|
|
38
|
-
*/
|
|
39
|
-
autoSave?: (state: ObjectState<T>) => void;
|
|
40
|
-
};
|
|
41
|
-
/**
|
|
42
|
-
* Creates a formState instance for editing in a form.
|
|
43
|
-
*/
|
|
44
|
-
export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): ObjectState<T>;
|
|
45
1
|
/**
|
|
46
2
|
* Wraps a given input/on-the-wire type `T` for editing in a form.
|
|
47
3
|
*
|
|
@@ -69,7 +25,7 @@ export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): Object
|
|
|
69
25
|
*/
|
|
70
26
|
export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<P, T> & {
|
|
71
27
|
/** Sets the state of fields in `state`. */
|
|
72
|
-
set(state: Partial<T
|
|
28
|
+
set(state: Partial<T>, opts?: SetOpts): void;
|
|
73
29
|
/** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
|
|
74
30
|
canSave(): boolean;
|
|
75
31
|
};
|
|
@@ -118,12 +74,17 @@ export interface FieldState<T, V> {
|
|
|
118
74
|
focus(): void;
|
|
119
75
|
/** Blur essentially touches the field. */
|
|
120
76
|
blur(): void;
|
|
121
|
-
set(value: V): void;
|
|
77
|
+
set(value: V, opts?: SetOpts): void;
|
|
122
78
|
/** Reverts back to the original value and resets dirty/touched. */
|
|
123
79
|
reset(): void;
|
|
124
80
|
/** Accepts the current changed value (if any) as the original and resets dirty/touched. */
|
|
125
81
|
save(): void;
|
|
126
82
|
}
|
|
83
|
+
/** Public options for our `set` command. */
|
|
84
|
+
export interface SetOpts {
|
|
85
|
+
/** Whether this `set` should trigger an auto-save; defaults to true. */
|
|
86
|
+
autoSave?: boolean;
|
|
87
|
+
}
|
|
127
88
|
/** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
|
|
128
89
|
export interface ListFieldState<T, U> extends Omit<FieldState<T, U[]>, "originalValue"> {
|
|
129
90
|
readonly rows: ReadonlyArray<ObjectState<U>>;
|
|
@@ -143,7 +104,7 @@ declare type OmitIf<Base, Condition> = Pick<Base, {
|
|
|
143
104
|
[Key in keyof Base]: Base[Key] extends Condition ? never : Key;
|
|
144
105
|
}[keyof Base]>;
|
|
145
106
|
/** Field configuration for primitive values, i.e. strings/numbers/Dates/user-defined types. */
|
|
146
|
-
declare type ValueFieldConfig<T, V> = {
|
|
107
|
+
export declare type ValueFieldConfig<T, V> = {
|
|
147
108
|
type: "value";
|
|
148
109
|
rules?: Rule<T, V | null | undefined>[];
|
|
149
110
|
/**
|
|
@@ -168,7 +129,7 @@ declare type ValueFieldConfig<T, V> = {
|
|
|
168
129
|
readOnly?: boolean;
|
|
169
130
|
};
|
|
170
131
|
/** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
|
|
171
|
-
declare type ListFieldConfig<T, U> = {
|
|
132
|
+
export declare type ListFieldConfig<T, U> = {
|
|
172
133
|
type: "list";
|
|
173
134
|
/** Rules that can run on the full list of children. */
|
|
174
135
|
rules?: Rule<T, readonly ObjectState<U>[]>[];
|
|
@@ -187,7 +148,7 @@ declare type ListFieldConfig<T, U> = {
|
|
|
187
148
|
*/
|
|
188
149
|
update?: "exhaustive" | "incremental";
|
|
189
150
|
};
|
|
190
|
-
declare type ObjectFieldConfig<U> = {
|
|
151
|
+
export declare type ObjectFieldConfig<U> = {
|
|
191
152
|
type: "object";
|
|
192
153
|
/** Config for the child's form state, i.e. each book. */
|
|
193
154
|
config: ObjectConfig<U>;
|
|
@@ -202,18 +163,6 @@ declare type ObjectFieldConfig<U> = {
|
|
|
202
163
|
export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
|
|
203
164
|
onBlur?: () => void;
|
|
204
165
|
}): ObjectState<T>;
|
|
205
|
-
/**
|
|
206
|
-
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
207
|
-
*
|
|
208
|
-
* This is useful for creating a form input from a GraphQL query result,
|
|
209
|
-
* but ignoring extra data in the query that we don't want/need in the form.
|
|
210
|
-
*
|
|
211
|
-
* (Especially because the form.value will become our mutation's input type
|
|
212
|
-
* that goes on the wire, we don't want superfluous data in it.)
|
|
213
|
-
*/
|
|
214
|
-
export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
|
|
215
|
-
[K in keyof T]: K extends keyof I ? I[K] : never;
|
|
216
|
-
};
|
|
217
166
|
declare type Primitive = undefined | null | boolean | string | number | Function | Date | {
|
|
218
167
|
toJSON(): any;
|
|
219
168
|
};
|
package/dist/formState.js
CHANGED
|
@@ -3,64 +3,11 @@ 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.
|
|
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 =
|
|
8
|
+
const is_plain_object_1 = require("is-plain-object");
|
|
9
9
|
const mobx_1 = require("mobx");
|
|
10
|
-
const react_1 = require("react");
|
|
11
10
|
const utils_1 = require("./utils");
|
|
12
|
-
/**
|
|
13
|
-
* Creates a formState instance for editing in a form.
|
|
14
|
-
*/
|
|
15
|
-
function useFormState(opts) {
|
|
16
|
-
const { config, init, addRules, readOnly = false, autoSave } = opts;
|
|
17
|
-
// Use a ref so our memo'ized `onBlur` always see the latest value
|
|
18
|
-
const autoSaveRef = react_1.useRef(autoSave);
|
|
19
|
-
autoSaveRef.current = autoSave;
|
|
20
|
-
const firstRunRef = react_1.useRef(true);
|
|
21
|
-
// 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
|
|
22
|
-
const [firstInitValue] = react_1.useState(() => initValue(config, init));
|
|
23
|
-
const isWrappingMobxProxy = !is_plain_object_1.default(firstInitValue);
|
|
24
|
-
// If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
|
|
25
|
-
const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
|
|
26
|
-
const form = react_1.useMemo(() => {
|
|
27
|
-
function onBlur() {
|
|
28
|
-
// Don't use canSave() because we don't want to set touched for all of the field
|
|
29
|
-
if (autoSaveRef.current && form.dirty && form.valid) {
|
|
30
|
-
autoSaveRef.current(form);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const value = firstRunRef.current ? firstInitValue : initValue(config, init);
|
|
34
|
-
const form = createObjectState(config, value, { onBlur });
|
|
35
|
-
form.readOnly = readOnly;
|
|
36
|
-
// The identity of `addRules` is not stable, but assume that it is for better UX.
|
|
37
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
38
|
-
(addRules || (() => { }))(form);
|
|
39
|
-
firstRunRef.current = true;
|
|
40
|
-
return form;
|
|
41
|
-
},
|
|
42
|
-
// For this useMemo, we (almost) never re-run so that we can have a stable `form` identity across query refreshes.
|
|
43
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
-
[config, ...(isWrappingMobxProxy ? dep : [])]);
|
|
45
|
-
// We use useEffect so that any mutations to the proxies, which will call `setState`s on any observers to
|
|
46
|
-
// queue their components' render), don't happen during our render, per https://fb.me/setstate-in-render.
|
|
47
|
-
react_1.useEffect(() => {
|
|
48
|
-
// Ignore the 1st run b/c our 1st useMemo already initialized `form` with the current `init` value.
|
|
49
|
-
// Also for mobx proxies, we recreate a new form-state every time init changes, so that our
|
|
50
|
-
// fields fundamentally pointing to the right proxy. So just defer to the ^ useMemo.
|
|
51
|
-
if (firstRunRef.current || isWrappingMobxProxy) {
|
|
52
|
-
firstRunRef.current = false;
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
form.set(initValue(config, init), { refreshing: true });
|
|
56
|
-
}, [form, ...dep]);
|
|
57
|
-
// Use useEffect so that we don't touch the form.init proxy during a render
|
|
58
|
-
react_1.useEffect(() => {
|
|
59
|
-
form.readOnly = readOnly;
|
|
60
|
-
}, [form, readOnly]);
|
|
61
|
-
return form;
|
|
62
|
-
}
|
|
63
|
-
exports.useFormState = useFormState;
|
|
64
11
|
/** A rule that validates `value` is not `undefined`, `null`, or empty string. */
|
|
65
12
|
// We pre-emptively make this a mobx action so that it's identity doesn't change when proxy-ified
|
|
66
13
|
// and breaks our ability to do `rules.some(r => r === required)`.
|
|
@@ -241,7 +188,7 @@ function newObjectState(config, parentState, instance, key, onBlur) {
|
|
|
241
188
|
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, onBlur) {
|
|
242
189
|
// keep a copy here for reference equality
|
|
243
190
|
const value = parentInstance[key];
|
|
244
|
-
let _originalValue = value === null ? undefined : is_plain_object_1.
|
|
191
|
+
let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
|
|
245
192
|
// Because we read/write the value directly back into parentInstance[key],
|
|
246
193
|
// which itself is not a proxy, we use this as our "value changed" trigger.
|
|
247
194
|
const _tick = mobx_1.observable({ value: 1 });
|
|
@@ -343,6 +290,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
343
290
|
const coerceEmptyList = value && value instanceof Array && value.length === 0 && isEmpty(this.originalValue);
|
|
344
291
|
const newValue = keepNull ? null : isEmpty(value) || coerceEmptyList ? undefined : value;
|
|
345
292
|
// Set the value on our parent object
|
|
293
|
+
const changed = !areEqual(newValue, this.value);
|
|
346
294
|
parentInstance[key] = newValue;
|
|
347
295
|
_tick.value++;
|
|
348
296
|
if (opts.refreshing) {
|
|
@@ -350,7 +298,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
350
298
|
}
|
|
351
299
|
// If we're being set programmatically, i.e. we don't currently have focus,
|
|
352
300
|
// call blur to trigger any auto-saves.
|
|
353
|
-
if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty) {
|
|
301
|
+
if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) {
|
|
354
302
|
this.blur();
|
|
355
303
|
}
|
|
356
304
|
},
|
|
@@ -361,7 +309,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
361
309
|
this.touched = false;
|
|
362
310
|
},
|
|
363
311
|
save() {
|
|
364
|
-
if (is_plain_object_1.
|
|
312
|
+
if (is_plain_object_1.isPlainObject(this.originalValue)) {
|
|
365
313
|
this.originalValue = mobx_1.toJS(this.value);
|
|
366
314
|
}
|
|
367
315
|
else {
|
|
@@ -590,7 +538,7 @@ function isEmpty(value) {
|
|
|
590
538
|
*
|
|
591
539
|
*/
|
|
592
540
|
function areEqual(a, b) {
|
|
593
|
-
if (is_plain_object_1.
|
|
541
|
+
if (is_plain_object_1.isPlainObject(a)) {
|
|
594
542
|
return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
|
|
595
543
|
}
|
|
596
544
|
if (hasToJSON(a) || hasToJSON(b)) {
|
|
@@ -606,59 +554,3 @@ function areEqual(a, b) {
|
|
|
606
554
|
function hasToJSON(o) {
|
|
607
555
|
return !!(o && typeof o === "object" && "toJSON" in o);
|
|
608
556
|
}
|
|
609
|
-
/**
|
|
610
|
-
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
611
|
-
*
|
|
612
|
-
* This is useful for creating a form input from a GraphQL query result,
|
|
613
|
-
* but ignoring extra data in the query that we don't want/need in the form.
|
|
614
|
-
*
|
|
615
|
-
* (Especially because the form.value will become our mutation's input type
|
|
616
|
-
* that goes on the wire, we don't want superfluous data in it.)
|
|
617
|
-
*/
|
|
618
|
-
function pickFields(formConfig, instance) {
|
|
619
|
-
// If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
|
|
620
|
-
if (!is_plain_object_1.default(instance)) {
|
|
621
|
-
return instance;
|
|
622
|
-
}
|
|
623
|
-
return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
|
|
624
|
-
const keyConfig = _keyConfig;
|
|
625
|
-
const value = instance[key];
|
|
626
|
-
if (keyConfig.type === "object") {
|
|
627
|
-
if (value) {
|
|
628
|
-
return [key, pickFields(keyConfig.config, value)];
|
|
629
|
-
}
|
|
630
|
-
else {
|
|
631
|
-
return [key, value];
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
else if (keyConfig.type === "list") {
|
|
635
|
-
if (mobx_1.isObservable(value)) {
|
|
636
|
-
// If we hit an observable array, leave it as the existing proxy so the our
|
|
637
|
-
// ListFieldState will react to changes in the original array.
|
|
638
|
-
return [key, value];
|
|
639
|
-
}
|
|
640
|
-
else if (value) {
|
|
641
|
-
return [key, value.map((u) => pickFields(keyConfig.config, u))];
|
|
642
|
-
}
|
|
643
|
-
else {
|
|
644
|
-
return [key, value];
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
else if (keyConfig.type === "value") {
|
|
648
|
-
return [key, value];
|
|
649
|
-
}
|
|
650
|
-
else {
|
|
651
|
-
return utils_1.assertNever(keyConfig);
|
|
652
|
-
}
|
|
653
|
-
}));
|
|
654
|
-
}
|
|
655
|
-
exports.pickFields = pickFields;
|
|
656
|
-
/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
|
|
657
|
-
function initValue(config, init) {
|
|
658
|
-
const initValue = init && "input" in init && "map" in init
|
|
659
|
-
? init.input
|
|
660
|
-
? init.map(init.input)
|
|
661
|
-
: init.ifUndefined || {}
|
|
662
|
-
: init || {};
|
|
663
|
-
return pickFields(config, initValue);
|
|
664
|
-
}
|