@homebound/form-state 2.15.4 → 2.17.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 +113 -28
- package/dist/FormStateApp.d.ts +1 -1
- package/dist/config.d.ts +8 -4
- package/dist/fields/fragmentField.d.ts +4 -0
- package/dist/fields/fragmentField.js +26 -0
- package/dist/fields/listField.d.ts +2 -2
- package/dist/fields/objectField.d.ts +9 -3
- package/dist/fields/objectField.js +10 -1
- package/dist/fields/valueField.d.ts +4 -4
- package/dist/formState.test.js +36 -18
- package/dist/rules.d.ts +1 -5
- package/dist/useFormState.test.js +10 -10
- package/dist/useFormStates.test.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,27 +5,69 @@
|
|
|
5
5
|
|
|
6
6
|
form-state is a headless form state management library, built on top of mobx.
|
|
7
7
|
|
|
8
|
-
It acts as a buffer between the canonical data
|
|
8
|
+
It acts as a buffer between the canonical data (i.e. the server-side data, or your app's GraphQL/Redux/etc. global store) and the user's WIP data that is being actively mutated in form fields (which is "too chatty"/WIP to store in the global store).
|
|
9
9
|
|
|
10
10
|
It also keeps track of low-level form UX details like:
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
12
|
+
- Which form fields are dirty
|
|
13
|
+
- Which form fields are valid/invalid
|
|
14
|
+
- Which form fields are touched (i.e. don't show validation errors for untouched fields)
|
|
15
|
+
- Enabling/disabling buttons/form UX based on the overall form-wide state
|
|
16
|
+
- Submitting the form should touch (validate) all fields
|
|
17
|
+
- Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
|
|
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
|
|
21
|
+
- Building a wire payload that has only changed fields
|
|
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
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# Main Features
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
There are two main reasons why form-state exists:
|
|
29
|
+
|
|
30
|
+
1. Provide a `FieldState` interface for component libraries
|
|
31
|
+
2. Match the "three shapes" mental model of our forms
|
|
32
|
+
|
|
33
|
+
## FieldState Interface & "One-Line" Forms
|
|
34
|
+
|
|
35
|
+
The core abstraction that `form-state` provides is a `FieldState` interface that looks like:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// Very simplified example
|
|
39
|
+
interface FieldState {
|
|
40
|
+
value: V
|
|
41
|
+
errors: string[]
|
|
42
|
+
valid: boolean
|
|
43
|
+
touched: boolean
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Which combines all the logical aspects of "a single form field" into a single object/prop.
|
|
48
|
+
|
|
49
|
+
This facilitates the "gold standard" of form libraries, which is "one line per form field", i.e:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
function AuthorEditorComponent() {
|
|
53
|
+
const author = useFormState(() => /* ... */ );
|
|
54
|
+
return (
|
|
55
|
+
<FormLines>
|
|
56
|
+
<BoundTextField field={author.firstName} />
|
|
57
|
+
<BoundTextField field={author.lastName} />
|
|
58
|
+
<BoundSelectField field={author.city} options={...cities...} />
|
|
59
|
+
</FormLines>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Besides great developer ergonomics (low boilerplate, very DRY code), this approach also provides a very consistent UI/UX for users, because all forms get the highly-polish behavior of `BoundTextField` for free.
|
|
65
|
+
|
|
66
|
+
(See the `BoundTextField` in [Beam](https://github.com/homebound-team/beam) for an actual implementation of this approach.)
|
|
67
|
+
|
|
68
|
+
## The Three Shapes Mental Model
|
|
69
|
+
|
|
70
|
+
In general when working with any forms (i.e. not just `form-state`), there are three types/shapes of data involved:
|
|
29
71
|
|
|
30
72
|
1. The input data/shape from the server (i.e. a GraphQL/REST query)
|
|
31
73
|
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} />`)
|
|
@@ -33,18 +75,17 @@ In general when working with forms (e.g. not just form-state), there are three t
|
|
|
33
75
|
|
|
34
76
|
form-state generally refers to each of these shapes as:
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
|
|
78
|
+
- The input type
|
|
79
|
+
- (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).
|
|
80
|
+
- The form type
|
|
81
|
+
- The ...third type...
|
|
42
82
|
|
|
83
|
+
And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
|
|
43
84
|
|
|
44
85
|
Admittedly (and hopefully, b/c it makes the code simpler), the differences between each of these types can often be small, i.e.:
|
|
45
86
|
|
|
46
|
-
|
|
47
|
-
|
|
87
|
+
- The input type might have `{ author: { book: { id: "b:1" } }` but the mutation wants `{ author: { bookId: "b:1" } }`
|
|
88
|
+
- ...have other examples...
|
|
48
89
|
|
|
49
90
|
These are usually simple/mechanistic changes, but nonetheless just some boilerplate that form-state provides conventions for.
|
|
50
91
|
|
|
@@ -60,7 +101,54 @@ See the [sample](https://github.com/homebound-team/form-state/blob/main/src/Form
|
|
|
60
101
|
|
|
61
102
|
- Undo/redo would in theory be neat and easy to do on top of the existing infra
|
|
62
103
|
|
|
63
|
-
#
|
|
104
|
+
# Features
|
|
105
|
+
|
|
106
|
+
## Fragments
|
|
107
|
+
|
|
108
|
+
Normally, form-state expects all fields in the form to be inputs to the GraphQL mutation/wire call. For example, the `author.firstName` field will always be submitted to the `saveAuthor` mutation (albeit with `author.changedValue` you can have `firstName` conditionally included).
|
|
109
|
+
|
|
110
|
+
However, sometimes there is "other data" that your UX needs to render the form, which is not strictly a form field, but would be handy for the data to "just be on the form" anyway, as you're passing it in around code.
|
|
111
|
+
|
|
112
|
+
A stereotypical example of this is GraphQL fragments, where an `AuthorFragment` might have a lot of misc read-only info that you want to display next to/within your form, but is not technically editable.
|
|
113
|
+
|
|
114
|
+
In form-state, you can model with as a `Fragment`, which is set up as:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// Your input type, likely generated from GraphQL mutation
|
|
118
|
+
type AuthorInput = { firstName?: string };
|
|
119
|
+
|
|
120
|
+
// Your wire data likely from your page's GraphQL query to get
|
|
121
|
+
// the author to edit + also "misc other data"
|
|
122
|
+
type AuthorFragment = { firstName: string; miscOtherData: {} };
|
|
123
|
+
|
|
124
|
+
// For your page's form state, add-in the "extra data"
|
|
125
|
+
type AuthorForm = AuthorInput & {
|
|
126
|
+
// The `Fragment` type tells form-state this is not a regular form field
|
|
127
|
+
data: Fragment<AuthorFragment >
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Tell the form config the "fragment" is not a real field
|
|
131
|
+
const config: ObjectConfig<AuthorForm> = {
|
|
132
|
+
firstName: { type: "value", rules: [require] },
|
|
133
|
+
data: { type: "fragment" },
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Now in the component...
|
|
137
|
+
const data = useGraphQLQuery();
|
|
138
|
+
const form = useFormState({
|
|
139
|
+
config,
|
|
140
|
+
init: {
|
|
141
|
+
input: data,
|
|
142
|
+
map: (d) => ({
|
|
143
|
+
firstName: data.author.firstName,
|
|
144
|
+
data: fragment(data),
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Internal Implementation Notes
|
|
64
152
|
|
|
65
153
|
form-state keeps the "actual data" (basically a POJO of your form data) separate from the "mobx proxies that track reactivity" (the `ObjectState` interface with `.get` / `.set` / `.errors` other methods).
|
|
66
154
|
|
|
@@ -69,6 +157,3 @@ This works well b/c the "actual data" returned from `ObjectState.value` or `Fiel
|
|
|
69
157
|
However, it does mean that form-state internally uses a few "that looks odd" tricks like `_tick.value++` to ensure code like `formState.value.firstName` will be reactive, even though the `.firstName` is not actually a proxy access (but doing `formState.firstName.value` would be).
|
|
70
158
|
|
|
71
159
|
(To be clear, both `formState.firstName.value` and `formState.value.firstName` return the same value, and also have the same reactivity semantics, this is just noting that form-state's internals need to do a few extra tricks to get the latter to be reactive.)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
package/dist/FormStateApp.d.ts
CHANGED
package/dist/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ObjectState } from "./fields/objectField";
|
|
1
|
+
import { Fragment, ObjectState } from "./fields/objectField";
|
|
2
2
|
import { Rule } from "./rules";
|
|
3
3
|
import { Builtin } from "./utils";
|
|
4
4
|
/**
|
|
@@ -8,15 +8,19 @@ import { Builtin } from "./utils";
|
|
|
8
8
|
* the caller define field-specific behavior, i.e. validation rules.
|
|
9
9
|
*/
|
|
10
10
|
export declare type ObjectConfig<T> = {
|
|
11
|
-
[P in keyof OmitIf<T, Function>]: T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfig<T, T[P]> : ListFieldConfig<T, U> : ValueFieldConfig<T, T[P]> | ObjectFieldConfig<T[P]>;
|
|
11
|
+
[P in keyof OmitIf<T, Function>]: T[P] extends Fragment<infer V> ? FragmentFieldConfig : T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfig<T, T[P]> : ListFieldConfig<T, U> : ValueFieldConfig<T, T[P]> | ObjectFieldConfig<T[P]>;
|
|
12
12
|
};
|
|
13
13
|
declare type OmitIf<Base, Condition> = Pick<Base, {
|
|
14
14
|
[Key in keyof Base]: Base[Key] extends Condition ? never : Key;
|
|
15
15
|
}[keyof Base]>;
|
|
16
|
+
/** Field configuration for an opaque value that we don't actually want to include. */
|
|
17
|
+
export declare type FragmentFieldConfig = {
|
|
18
|
+
type: "fragment";
|
|
19
|
+
};
|
|
16
20
|
/** Field configuration for primitive values, i.e. strings/numbers/Dates/user-defined types. */
|
|
17
21
|
export declare type ValueFieldConfig<T, V> = {
|
|
18
22
|
type: "value";
|
|
19
|
-
rules?: Rule<
|
|
23
|
+
rules?: Rule<V | null | undefined>[];
|
|
20
24
|
/**
|
|
21
25
|
* If true, marks this field as the id, which will be used for things like "always include in changedValue".
|
|
22
26
|
*
|
|
@@ -44,7 +48,7 @@ export declare type ValueFieldConfig<T, V> = {
|
|
|
44
48
|
export declare type ListFieldConfig<T, U> = {
|
|
45
49
|
type: "list";
|
|
46
50
|
/** Rules that can run on the full list of children. */
|
|
47
|
-
rules?: Rule<
|
|
51
|
+
rules?: Rule<readonly ObjectState<U>[]>[];
|
|
48
52
|
/** Config for each child's form state, i.e. each book. */
|
|
49
53
|
config: ObjectConfig<U>;
|
|
50
54
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.newFragmentField = void 0;
|
|
4
|
+
const mobx_1 = require("mobx");
|
|
5
|
+
const utils_1 = require("../utils");
|
|
6
|
+
function newFragmentField(parentInstance, key) {
|
|
7
|
+
// We always return the same `instance` field from our `value` method, but
|
|
8
|
+
// we want to pretend that it's observable, so use a tick to force it.
|
|
9
|
+
const _tick = mobx_1.observable({ value: 1 });
|
|
10
|
+
// We steal the fragment from our parent, so that it doesn't
|
|
11
|
+
// accidentally end up on the wire
|
|
12
|
+
let value = parentInstance[key];
|
|
13
|
+
delete parentInstance[key];
|
|
14
|
+
const obj = {
|
|
15
|
+
get value() {
|
|
16
|
+
_tick.value > 0 || utils_1.fail();
|
|
17
|
+
return value;
|
|
18
|
+
},
|
|
19
|
+
set value(v) {
|
|
20
|
+
value = v;
|
|
21
|
+
_tick.value++;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return mobx_1.makeAutoObservable(obj, { value: false });
|
|
25
|
+
}
|
|
26
|
+
exports.newFragmentField = newFragmentField;
|
|
@@ -3,9 +3,9 @@ import { ObjectState } from "./objectField";
|
|
|
3
3
|
import { FieldState } from "./valueField";
|
|
4
4
|
import { Rule } from "../rules";
|
|
5
5
|
/** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
|
|
6
|
-
export interface ListFieldState<T, U> extends Omit<FieldState<
|
|
6
|
+
export interface ListFieldState<T, U> extends Omit<FieldState<U[]>, "originalValue"> {
|
|
7
7
|
readonly rows: ReadonlyArray<ObjectState<U>>;
|
|
8
8
|
add(value: U, index?: number): void;
|
|
9
9
|
remove(indexOrValue: number | U): void;
|
|
10
10
|
}
|
|
11
|
-
export declare function newListFieldState<T, K extends keyof T, U>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<
|
|
11
|
+
export declare function newListFieldState<T, K extends keyof T, U>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<T, U>, config: ObjectConfig<U>, maybeAutoSave: () => void): ListFieldState<T, U>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ObjectConfig } from "../config";
|
|
2
|
+
import { FragmentField } from "./fragmentField";
|
|
2
3
|
import { ListFieldState } from "./listField";
|
|
3
4
|
import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
|
|
4
5
|
import { Builtin } from "../utils";
|
|
@@ -27,7 +28,7 @@ import { Builtin } from "../utils";
|
|
|
27
28
|
* - title: FieldState
|
|
28
29
|
* ```
|
|
29
30
|
*/
|
|
30
|
-
export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<
|
|
31
|
+
export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<T> & {
|
|
31
32
|
/** Sets the state of fields in `state`. */
|
|
32
33
|
set(state: Partial<T>, opts?: SetOpts): void;
|
|
33
34
|
/** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
|
|
@@ -36,9 +37,14 @@ export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<P, T>
|
|
|
36
37
|
export declare type ObjectStateInternal<T, P = any> = ObjectState<T, P> & {
|
|
37
38
|
set(value: P, opts?: InternalSetOpts): void;
|
|
38
39
|
};
|
|
40
|
+
declare const fragmentSym: unique symbol;
|
|
41
|
+
export declare type Fragment<V> = V & {
|
|
42
|
+
[fragmentSym]: true;
|
|
43
|
+
};
|
|
44
|
+
export declare function fragment<V>(value: V): Fragment<V>;
|
|
39
45
|
/** For a given input type `T`, decorate each field into the "field state" type that holds our form-relevant state, i.e. valid/touched/etc. */
|
|
40
46
|
declare type FieldStates<T> = {
|
|
41
|
-
[K in keyof T]-?: T[K] extends Array<infer U> | null | undefined ? [U] extends [Builtin] ? FieldState<T
|
|
47
|
+
[K in keyof T]-?: T[K] extends Fragment<infer V> ? FragmentField<V> : T[K] extends Array<infer U> | null | undefined ? [U] extends [Builtin] ? FieldState<T[K]> : ListFieldState<T, U> : T[K] extends Builtin | null | undefined ? FieldState<T[K]> : ObjectState<T[K], T>;
|
|
42
48
|
};
|
|
43
49
|
/**
|
|
44
50
|
* Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
|
|
@@ -50,5 +56,5 @@ declare type FieldStates<T> = {
|
|
|
50
56
|
export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
|
|
51
57
|
maybeAutoSave?: () => void;
|
|
52
58
|
}): ObjectState<T>;
|
|
53
|
-
export declare function newObjectState<T, P = any>(config: ObjectConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentListState: FieldState<any
|
|
59
|
+
export declare function newObjectState<T, P = any>(config: ObjectConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T, P>;
|
|
54
60
|
export {};
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.newObjectState = exports.createObjectState = void 0;
|
|
3
|
+
exports.newObjectState = exports.createObjectState = exports.fragment = void 0;
|
|
4
4
|
const mobx_1 = require("mobx");
|
|
5
|
+
const fragmentField_1 = require("./fragmentField");
|
|
5
6
|
const listField_1 = require("./listField");
|
|
6
7
|
const valueField_1 = require("./valueField");
|
|
7
8
|
const utils_1 = require("../utils");
|
|
9
|
+
const fragmentSym = Symbol("fragment");
|
|
10
|
+
function fragment(value) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
exports.fragment = fragment;
|
|
8
14
|
/**
|
|
9
15
|
* Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
|
|
10
16
|
*
|
|
@@ -45,6 +51,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
45
51
|
}
|
|
46
52
|
field = newObjectState(config.config, getObjectState, undefined, instance[key], key, maybeAutoSave);
|
|
47
53
|
}
|
|
54
|
+
else if (config.type === "fragment") {
|
|
55
|
+
field = fragmentField_1.newFragmentField(instance, key);
|
|
56
|
+
}
|
|
48
57
|
else {
|
|
49
58
|
throw new Error("Unsupported");
|
|
50
59
|
}
|
|
@@ -10,7 +10,7 @@ import { Rule } from "../rules";
|
|
|
10
10
|
* Note that `V` will always have `null | undefined` added to it by `FieldStates`, b/c most form fields
|
|
11
11
|
* i.e. text boxes, can always be cleared out/deleted.
|
|
12
12
|
*/
|
|
13
|
-
export interface FieldState<
|
|
13
|
+
export interface FieldState<V> {
|
|
14
14
|
readonly key: string;
|
|
15
15
|
value: V;
|
|
16
16
|
readonly originalValue: V;
|
|
@@ -21,7 +21,7 @@ export interface FieldState<T, V> {
|
|
|
21
21
|
readonly dirty: boolean;
|
|
22
22
|
readonly valid: boolean;
|
|
23
23
|
readonly isNewEntity: boolean;
|
|
24
|
-
rules: Rule<
|
|
24
|
+
rules: Rule<V>[];
|
|
25
25
|
readonly errors: string[];
|
|
26
26
|
/** Returns a subset of V with only the changed values. Currently not observable. */
|
|
27
27
|
readonly changedValue: V;
|
|
@@ -49,11 +49,11 @@ export interface InternalSetOpts extends SetOpts {
|
|
|
49
49
|
/** `refreshing` is when `useFormState` sees a new value. */
|
|
50
50
|
refreshing?: boolean;
|
|
51
51
|
}
|
|
52
|
-
export interface FieldStateInternal<T, V> extends FieldState<
|
|
52
|
+
export interface FieldStateInternal<T, V> extends FieldState<V> {
|
|
53
53
|
set(value: V, opts?: InternalSetOpts): void;
|
|
54
54
|
_isIdKey: boolean;
|
|
55
55
|
_isDeleteKey: boolean;
|
|
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
|
|
59
|
+
export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T[K] | null | undefined>;
|
package/dist/formState.test.js
CHANGED
|
@@ -941,24 +941,6 @@ describe("formState", () => {
|
|
|
941
941
|
formState.revertChanges();
|
|
942
942
|
expect(formState.firstName.value).toEqual("first");
|
|
943
943
|
});
|
|
944
|
-
it("lets rules validate against other fields", () => {
|
|
945
|
-
const formState = objectField_1.createObjectState({
|
|
946
|
-
firstName: { type: "value", rules: [] },
|
|
947
|
-
lastName: {
|
|
948
|
-
type: "value",
|
|
949
|
-
rules: [
|
|
950
|
-
({ object }) => {
|
|
951
|
-
if (object.firstName.value === object.lastName.value) {
|
|
952
|
-
return "Must not match first name";
|
|
953
|
-
}
|
|
954
|
-
},
|
|
955
|
-
],
|
|
956
|
-
},
|
|
957
|
-
}, {});
|
|
958
|
-
formState.firstName.value = "bob";
|
|
959
|
-
formState.lastName.value = "bob";
|
|
960
|
-
expect(formState.lastName.errors).toEqual(["Must not match first name"]);
|
|
961
|
-
});
|
|
962
944
|
it("can return only changed primitive fields", () => {
|
|
963
945
|
// Given an author
|
|
964
946
|
const formState = objectField_1.createObjectState(authorWithBooksConfig, {
|
|
@@ -1338,6 +1320,41 @@ describe("formState", () => {
|
|
|
1338
1320
|
// The list is again dirty
|
|
1339
1321
|
expect(a1.books.dirty).toBeTruthy();
|
|
1340
1322
|
});
|
|
1323
|
+
describe("fragments", () => {
|
|
1324
|
+
it("ignores changes within fragments", () => {
|
|
1325
|
+
const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
|
|
1326
|
+
let numCalcs = 0;
|
|
1327
|
+
mobx_1.autorun(() => {
|
|
1328
|
+
numCalcs++;
|
|
1329
|
+
noop(a.value);
|
|
1330
|
+
});
|
|
1331
|
+
expect(a.data.value).toEqual({ foo: "1" });
|
|
1332
|
+
expect(mobx_1.isObservable(a.data.value)).toBe(false);
|
|
1333
|
+
expect(numCalcs).toEqual(1);
|
|
1334
|
+
a.data.value = { foo: "2" };
|
|
1335
|
+
expect(numCalcs).toEqual(2);
|
|
1336
|
+
a.data.value.foo = "3";
|
|
1337
|
+
expect(numCalcs).toEqual(2);
|
|
1338
|
+
});
|
|
1339
|
+
it("ignores fragments in changedValue", () => {
|
|
1340
|
+
const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
|
|
1341
|
+
a.title.value = "b2";
|
|
1342
|
+
a.data.value = objectField_1.fragment({ foo: "2" });
|
|
1343
|
+
expect(a.changedValue).toMatchInlineSnapshot(`
|
|
1344
|
+
Object {
|
|
1345
|
+
"title": "b2",
|
|
1346
|
+
}
|
|
1347
|
+
`);
|
|
1348
|
+
});
|
|
1349
|
+
it("ignores fragments in value", () => {
|
|
1350
|
+
const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
|
|
1351
|
+
expect(a.value).toMatchInlineSnapshot(`
|
|
1352
|
+
Object {
|
|
1353
|
+
"title": "b1",
|
|
1354
|
+
}
|
|
1355
|
+
`);
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1341
1358
|
});
|
|
1342
1359
|
class ObservableObject {
|
|
1343
1360
|
constructor() {
|
|
@@ -1409,3 +1426,4 @@ const authorWithAddressAndBooksConfig = {
|
|
|
1409
1426
|
function createAuthorWithAddressInputState(input) {
|
|
1410
1427
|
return objectField_1.createObjectState(authorWithAddressConfig, input);
|
|
1411
1428
|
}
|
|
1429
|
+
function noop(t) { }
|
package/dist/rules.d.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import { ObjectState } from "./fields/objectField";
|
|
2
|
-
declare type IfAny<T, Y, N> = 0 extends 1 & T ? Y : N;
|
|
3
1
|
/** A validation rule, given the value and name, return the error string if valid, or undefined if valid. */
|
|
4
|
-
export declare type Rule<
|
|
2
|
+
export declare type Rule<V> = (opts: {
|
|
5
3
|
value: V;
|
|
6
4
|
key: string;
|
|
7
5
|
originalValue: V;
|
|
8
|
-
object: IfAny<T, any, ObjectState<T>>;
|
|
9
6
|
}) => string | undefined;
|
|
10
7
|
/** A rule that validates `value` is not `undefined`, `null`, or empty string. */
|
|
11
8
|
export declare const required: <V>({ value: v }: {
|
|
12
9
|
value: V;
|
|
13
10
|
}) => string | undefined;
|
|
14
|
-
export {};
|
|
@@ -261,17 +261,17 @@ describe("useFormState", () => {
|
|
|
261
261
|
});
|
|
262
262
|
it("useFormState can accept new data with computed fields", async () => {
|
|
263
263
|
// Given a component
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return this.firstName + " " + this.lastName;
|
|
273
|
-
}
|
|
264
|
+
// And it's using a class/mobx proxy as the basis for the data
|
|
265
|
+
class AuthorRow {
|
|
266
|
+
constructor(firstName, lastName) {
|
|
267
|
+
this.firstName = firstName;
|
|
268
|
+
this.lastName = lastName;
|
|
269
|
+
}
|
|
270
|
+
get fullName() {
|
|
271
|
+
return this.firstName + " " + this.lastName;
|
|
274
272
|
}
|
|
273
|
+
}
|
|
274
|
+
function TestComponent() {
|
|
275
275
|
// And we have two sets of data
|
|
276
276
|
const data1 = { firstName: "f1", lastName: "l1" };
|
|
277
277
|
const data2 = { firstName: "f2", lastName: "l2" };
|
|
@@ -39,7 +39,7 @@ describe("useFormStates", () => {
|
|
|
39
39
|
const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon" });
|
|
40
40
|
const { getFormState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
|
|
41
41
|
// Memoize an original for comparing the update against.
|
|
42
|
-
const originalState = react_1.useMemo(() => getFormState(apiData), []);
|
|
42
|
+
const originalState = react_1.useMemo(() => getFormState(apiData), [getFormState]);
|
|
43
43
|
const state = getFormState(apiData);
|
|
44
44
|
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
|
|
45
45
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
|
|
@@ -124,6 +124,7 @@ describe("useFormStates", () => {
|
|
|
124
124
|
const [config, setConfig] = react_1.useState(originalConfig);
|
|
125
125
|
const { getFormState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
|
|
126
126
|
// Memoize an original for comparing the update against.
|
|
127
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
128
|
const originalState = react_1.useMemo(() => getFormState(apiData), []);
|
|
128
129
|
const state = getFormState(apiData);
|
|
129
130
|
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
|