@homebound/form-state 2.20.1 → 2.22.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 +10 -10
- package/dist/FormStateApp.js +9 -11
- package/dist/config.d.ts +4 -8
- package/dist/configBuilders.d.ts +60 -0
- package/dist/configBuilders.js +102 -0
- package/dist/fields/listField.d.ts +2 -2
- package/dist/fields/listField.js +49 -24
- package/dist/fields/objectField.d.ts +1 -0
- package/dist/fields/objectField.js +16 -6
- package/dist/fields/valueField.d.ts +3 -1
- package/dist/fields/valueField.js +7 -8
- package/dist/formState.test.d.ts +11 -1
- package/dist/formState.test.js +139 -94
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/mobx-behavior.test.d.ts +1 -0
- package/dist/mobx-behavior.test.js +51 -0
- package/dist/stackoverlfow.d.ts +17 -0
- package/dist/stackoverlfow.js +17 -0
- package/dist/useFormStates.js +3 -1
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +16 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 (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
|
|
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 push back into global stores).
|
|
9
9
|
|
|
10
10
|
It also keeps track of low-level form UX details like:
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ It also keeps track of low-level form UX details like:
|
|
|
21
21
|
- Building a wire payload that has only changed fields
|
|
22
22
|
- `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
|
|
23
23
|
- Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
|
|
24
|
-
- Child collections can be either
|
|
24
|
+
- Child collections can be either exhaustive (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
|
|
|
@@ -37,16 +37,17 @@ The core abstraction that `form-state` provides is a `FieldState` interface that
|
|
|
37
37
|
```ts
|
|
38
38
|
// Very simplified example
|
|
39
39
|
interface FieldState {
|
|
40
|
-
value
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
// Can be used to read & write the value bound into the form field
|
|
41
|
+
value: V;
|
|
42
|
+
errors: string[];
|
|
43
|
+
valid: boolean;
|
|
44
|
+
touched: boolean;
|
|
44
45
|
}
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
Which combines all the logical aspects of "a single form field" into a single object/prop.
|
|
48
49
|
|
|
49
|
-
This facilitates the "gold standard" of form
|
|
50
|
+
This facilitates the "gold standard" of form DX, which is "one line per form field", i.e:
|
|
50
51
|
|
|
51
52
|
```tsx
|
|
52
53
|
function AuthorEditorComponent() {
|
|
@@ -124,14 +125,14 @@ type AuthorFragment = { firstName: string; miscOtherData: {} };
|
|
|
124
125
|
// For your page's form state, add-in the "extra data"
|
|
125
126
|
type AuthorForm = AuthorInput & {
|
|
126
127
|
// The `Fragment` type tells form-state this is not a regular form field
|
|
127
|
-
data: Fragment<AuthorFragment
|
|
128
|
+
data: Fragment<AuthorFragment>;
|
|
128
129
|
};
|
|
129
130
|
|
|
130
131
|
// Tell the form config the "fragment" is not a real field
|
|
131
132
|
const config: ObjectConfig<AuthorForm> = {
|
|
132
133
|
firstName: { type: "value", rules: [require] },
|
|
133
134
|
data: { type: "fragment" },
|
|
134
|
-
}
|
|
135
|
+
};
|
|
135
136
|
|
|
136
137
|
// Now in the component...
|
|
137
138
|
const data = useGraphQLQuery();
|
|
@@ -147,7 +148,6 @@ const form = useFormState({
|
|
|
147
148
|
});
|
|
148
149
|
```
|
|
149
150
|
|
|
150
|
-
|
|
151
151
|
# Internal Implementation Notes
|
|
152
152
|
|
|
153
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).
|
package/dist/FormStateApp.js
CHANGED
|
@@ -33,17 +33,15 @@ function FormStateApp() {
|
|
|
33
33
|
}
|
|
34
34
|
exports.FormStateApp = FormStateApp;
|
|
35
35
|
// Configure the fields/behavior for AuthorInput's fields
|
|
36
|
-
const formConfig = {
|
|
37
|
-
firstName:
|
|
38
|
-
lastName:
|
|
39
|
-
books:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
};
|
|
36
|
+
const formConfig = index_1.f.config({
|
|
37
|
+
firstName: index_1.f.value().req(),
|
|
38
|
+
lastName: index_1.f.value().readOnly(),
|
|
39
|
+
books: index_1.f
|
|
40
|
+
.list({
|
|
41
|
+
title: index_1.f.value().req(),
|
|
42
|
+
})
|
|
43
|
+
.rule(({ value }) => (value.length === 0 ? "Empty" : undefined)),
|
|
44
|
+
});
|
|
47
45
|
function TextField(props) {
|
|
48
46
|
const { field } = props;
|
|
49
47
|
// Somewhat odd: input won't update unless we use <Observer>, even though our
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Fragment, ObjectState } from "./fields/objectField";
|
|
2
2
|
import { Rule } from "./rules";
|
|
3
|
-
import { Builtin } from "./utils";
|
|
3
|
+
import { Builtin, OmitIf } from "./utils";
|
|
4
4
|
/**
|
|
5
5
|
* Config rules for each field in `T` that we're editing in a form.
|
|
6
6
|
*
|
|
@@ -8,17 +8,14 @@ import { Builtin } from "./utils";
|
|
|
8
8
|
* the caller define field-specific behavior, i.e. validation rules.
|
|
9
9
|
*/
|
|
10
10
|
export type ObjectConfig<T> = {
|
|
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
|
|
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[P]> : ListFieldConfig<U> : ValueFieldConfig<T[P]> | ObjectFieldConfig<T[P]>;
|
|
12
12
|
};
|
|
13
|
-
type OmitIf<Base, Condition> = Pick<Base, {
|
|
14
|
-
[Key in keyof Base]: Base[Key] extends Condition ? never : Key;
|
|
15
|
-
}[keyof Base]>;
|
|
16
13
|
/** Field configuration for an opaque value that we don't actually want to include. */
|
|
17
14
|
export type FragmentFieldConfig = {
|
|
18
15
|
type: "fragment";
|
|
19
16
|
};
|
|
20
17
|
/** Field configuration for primitive values, i.e. strings/numbers/Dates/user-defined types. */
|
|
21
|
-
export type ValueFieldConfig<
|
|
18
|
+
export type ValueFieldConfig<V> = {
|
|
22
19
|
type: "value";
|
|
23
20
|
rules?: Rule<V | null | undefined>[];
|
|
24
21
|
/**
|
|
@@ -45,7 +42,7 @@ export type ValueFieldConfig<T, V> = {
|
|
|
45
42
|
strictOrder?: false;
|
|
46
43
|
};
|
|
47
44
|
/** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
|
|
48
|
-
export type ListFieldConfig<
|
|
45
|
+
export type ListFieldConfig<U> = {
|
|
49
46
|
type: "list";
|
|
50
47
|
/** Rules that can run on the full list of children. */
|
|
51
48
|
rules?: Rule<readonly ObjectState<U>[]>[];
|
|
@@ -71,4 +68,3 @@ export type ObjectFieldConfig<U> = {
|
|
|
71
68
|
/** Config for the child's form state, i.e. each book. */
|
|
72
69
|
config: ObjectConfig<U>;
|
|
73
70
|
};
|
|
74
|
-
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ValueFieldConfig } from "./config";
|
|
2
|
+
import { Fragment, ObjectState } from "./fields/objectField";
|
|
3
|
+
import { Rule } from "./rules";
|
|
4
|
+
import { Builtin, OmitIf } from "./utils";
|
|
5
|
+
/**
|
|
6
|
+
* Provides a Zod-ish API for building form configs.
|
|
7
|
+
*/
|
|
8
|
+
export declare const f: {
|
|
9
|
+
/** Creates the top-level config for a form, like an author or book. */
|
|
10
|
+
config<T>(fields: ObjectConfigBuilderFields<T>): ObjectConfig<T>;
|
|
11
|
+
/** Creates the config DSL for an object, like an author or book. */
|
|
12
|
+
object<T_1>(fields: ObjectConfigBuilderFields<T_1>): ObjectConfigBuilder<T_1>;
|
|
13
|
+
/** Creates the config DSL for an list of objects, like an author's list of books. */
|
|
14
|
+
list<U>(fields: ObjectConfigBuilderFields<U>): ListFieldConfigBuilder<U>;
|
|
15
|
+
/** Creates the config DSL for a primitive value, like a first name or phone number. */
|
|
16
|
+
value<V>(): ValueFieldConfigBuilder<V>;
|
|
17
|
+
/** A shorthand for creating a computed value. */
|
|
18
|
+
computed<V_1>(): ValueFieldConfigBuilder<V_1>;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Defines the fields that should be passed into `f.object({ ... })` for fhe form type `T`.
|
|
22
|
+
*
|
|
23
|
+
* This is basically a map of `key` in `T` to `f.value()`, `f.list()`, or `f.object()`
|
|
24
|
+
* DSL objects that define the runtime structure/fields of the form.
|
|
25
|
+
*/
|
|
26
|
+
export type ObjectConfigBuilderFields<T> = {
|
|
27
|
+
[P in keyof OmitIf<T, Function>]: T[P] extends Fragment<infer V> ? FragmentFieldConfigBuilder : T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfigBuilder<T[P]> : ListFieldConfigBuilder<U> : ValueFieldConfigBuilder<T[P]> | ObjectConfigBuilder<T[P]>;
|
|
28
|
+
};
|
|
29
|
+
export interface FragmentFieldConfigBuilder {
|
|
30
|
+
build(): FragmentFieldConfig;
|
|
31
|
+
}
|
|
32
|
+
/** Provides a fluent DSL for building up an object's config. */
|
|
33
|
+
export declare class ObjectConfigBuilder<T> {
|
|
34
|
+
private config;
|
|
35
|
+
constructor(fields: ObjectConfigBuilderFields<T>);
|
|
36
|
+
build(): ObjectConfig<T>;
|
|
37
|
+
}
|
|
38
|
+
/** Provides a fluent DSL for building up a value's config. */
|
|
39
|
+
export declare class ValueFieldConfigBuilder<V> {
|
|
40
|
+
private config;
|
|
41
|
+
/** Marks the field as required. */
|
|
42
|
+
req(): this;
|
|
43
|
+
/** Marks the field as read only. */
|
|
44
|
+
readOnly(): this;
|
|
45
|
+
/** Appends `rules` to the field's validation rules. */
|
|
46
|
+
rules(rules: Rule<V | null | undefined>[]): this;
|
|
47
|
+
/** Marks the field as computed. */
|
|
48
|
+
computed(): this;
|
|
49
|
+
build(): ValueFieldConfig<V>;
|
|
50
|
+
}
|
|
51
|
+
/** Provides a fluent DSL for building up a list's config. */
|
|
52
|
+
export declare class ListFieldConfigBuilder<U> {
|
|
53
|
+
private config;
|
|
54
|
+
constructor(fields: ObjectConfigBuilderFields<U>);
|
|
55
|
+
/** Appends `rule` to the field's validation rules. */
|
|
56
|
+
rule(rule: Rule<readonly ObjectState<U>[]>): this;
|
|
57
|
+
/** Appends `rules` to the field's validation rules. */
|
|
58
|
+
rules(rules: Rule<readonly ObjectState<U>[]>[]): this;
|
|
59
|
+
build(): ListFieldConfig<U>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ListFieldConfigBuilder = exports.ValueFieldConfigBuilder = exports.ObjectConfigBuilder = exports.f = void 0;
|
|
4
|
+
const rules_1 = require("./rules");
|
|
5
|
+
/**
|
|
6
|
+
* Provides a Zod-ish API for building form configs.
|
|
7
|
+
*/
|
|
8
|
+
exports.f = {
|
|
9
|
+
/** Creates the top-level config for a form, like an author or book. */
|
|
10
|
+
config(fields) {
|
|
11
|
+
return new ObjectConfigBuilder(fields).build();
|
|
12
|
+
},
|
|
13
|
+
/** Creates the config DSL for an object, like an author or book. */
|
|
14
|
+
object(fields) {
|
|
15
|
+
return new ObjectConfigBuilder(fields);
|
|
16
|
+
},
|
|
17
|
+
/** Creates the config DSL for an list of objects, like an author's list of books. */
|
|
18
|
+
list(fields) {
|
|
19
|
+
return new ListFieldConfigBuilder(fields);
|
|
20
|
+
},
|
|
21
|
+
/** Creates the config DSL for a primitive value, like a first name or phone number. */
|
|
22
|
+
value() {
|
|
23
|
+
return new ValueFieldConfigBuilder();
|
|
24
|
+
},
|
|
25
|
+
/** A shorthand for creating a computed value. */
|
|
26
|
+
computed() {
|
|
27
|
+
return this.value().computed();
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
/** Provides a fluent DSL for building up an object's config. */
|
|
31
|
+
class ObjectConfigBuilder {
|
|
32
|
+
constructor(fields) {
|
|
33
|
+
this.config = {};
|
|
34
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
35
|
+
this.config[key] = value.build();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
build() {
|
|
39
|
+
return this.config;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
exports.ObjectConfigBuilder = ObjectConfigBuilder;
|
|
43
|
+
/** Provides a fluent DSL for building up a value's config. */
|
|
44
|
+
class ValueFieldConfigBuilder {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.config = { type: "value", rules: [] };
|
|
47
|
+
}
|
|
48
|
+
/** Marks the field as required. */
|
|
49
|
+
req() {
|
|
50
|
+
this.config.rules = [rules_1.required];
|
|
51
|
+
return this;
|
|
52
|
+
}
|
|
53
|
+
/** Marks the field as read only. */
|
|
54
|
+
readOnly() {
|
|
55
|
+
this.config.readOnly = true;
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
/** Appends `rules` to the field's validation rules. */
|
|
59
|
+
rules(rules) {
|
|
60
|
+
var _a;
|
|
61
|
+
var _b;
|
|
62
|
+
((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(...rules);
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
/** Marks the field as computed. */
|
|
66
|
+
computed() {
|
|
67
|
+
this.config.computed = true;
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
build() {
|
|
71
|
+
return this.config;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
exports.ValueFieldConfigBuilder = ValueFieldConfigBuilder;
|
|
75
|
+
/** Provides a fluent DSL for building up a list's config. */
|
|
76
|
+
class ListFieldConfigBuilder {
|
|
77
|
+
constructor(fields) {
|
|
78
|
+
const config = {};
|
|
79
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
80
|
+
config[key] = value.build();
|
|
81
|
+
}
|
|
82
|
+
this.config = { type: "list", rules: [], config };
|
|
83
|
+
}
|
|
84
|
+
/** Appends `rule` to the field's validation rules. */
|
|
85
|
+
rule(rule) {
|
|
86
|
+
var _a;
|
|
87
|
+
var _b;
|
|
88
|
+
((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(rule);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
/** Appends `rules` to the field's validation rules. */
|
|
92
|
+
rules(rules) {
|
|
93
|
+
var _a;
|
|
94
|
+
var _b;
|
|
95
|
+
((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(...rules);
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
build() {
|
|
99
|
+
return this.config;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.ListFieldConfigBuilder = ListFieldConfigBuilder;
|
|
@@ -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<U> extends
|
|
6
|
+
export interface ListFieldState<U> extends FieldState<U[]> {
|
|
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<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<
|
|
11
|
+
export declare function newListFieldState<T, K extends keyof T, U>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
|
package/dist/fields/listField.js
CHANGED
|
@@ -5,13 +5,20 @@ const mobx_1 = require("mobx");
|
|
|
5
5
|
const objectField_1 = require("./objectField");
|
|
6
6
|
const rules_1 = require("../rules");
|
|
7
7
|
const utils_1 = require("../utils");
|
|
8
|
-
function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
|
|
8
|
+
function newListFieldState(parentCopy, parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
|
|
9
|
+
var _a;
|
|
9
10
|
// Keep a map of "item in the parent list" -> "that item's ObjectState"
|
|
10
11
|
const rowMap = new Map();
|
|
11
12
|
const _tick = (0, mobx_1.observable)({ value: 1 });
|
|
13
|
+
const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
|
|
12
14
|
const _childTick = (0, mobx_1.observable)({ value: 1 });
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
+
// When child rows don't have ids (i.e. for new rows that aren't saved yet), we need an id-less
|
|
16
|
+
// "clone <-> current" map to tell if we're dirty or not, i.e. whether `parentInstance[key]` has
|
|
17
|
+
// drifted from `parentCopy[key]`
|
|
18
|
+
const copyMap = new Map();
|
|
19
|
+
((_a = parentCopy[key]) !== null && _a !== void 0 ? _a : []).forEach((copy, i) => {
|
|
20
|
+
copyMap.set(copy, parentInstance[key][i]);
|
|
21
|
+
});
|
|
15
22
|
const list = {
|
|
16
23
|
key: key,
|
|
17
24
|
// Our fundamental state of wrapped Us
|
|
@@ -46,22 +53,19 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
46
53
|
return parentState().isNewEntity;
|
|
47
54
|
},
|
|
48
55
|
// private
|
|
56
|
+
/** Returns whether a row has been added/removed. */
|
|
49
57
|
hasChanged() {
|
|
50
|
-
const [current, original] = [this.value || [],
|
|
51
|
-
if (current.length !== original.length)
|
|
58
|
+
const [current, original] = [this.value || [], this.originalValue];
|
|
59
|
+
if (current.length !== original.length)
|
|
52
60
|
return true;
|
|
53
|
-
}
|
|
54
61
|
if (strictOrder) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
// With strict order, every copy[i] === original[i] must be true
|
|
63
|
+
return original.some((e, idx) => current[idx] !== copyMap.get(e));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// With loose order, we just want every copy to still have its original somewhere
|
|
67
|
+
return !original.every((e) => current.includes(copyMap.get(e)));
|
|
60
68
|
}
|
|
61
|
-
return !original.every((e) => {
|
|
62
|
-
const state = rowMap.get(e);
|
|
63
|
-
return current.some((e) => rowMap.get(e) === state);
|
|
64
|
-
});
|
|
65
69
|
},
|
|
66
70
|
// And we can derive each value's ObjectState wrapper as needed from the rowMap cache
|
|
67
71
|
get rows() {
|
|
@@ -92,7 +96,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
92
96
|
get valid() {
|
|
93
97
|
const value = this.rows;
|
|
94
98
|
// TODO Passing `originalCopy || []` is probably not 100% right
|
|
95
|
-
const opts = { value, key: key, originalValue:
|
|
99
|
+
const opts = { value, key: key, originalValue: this.originalValue, object: parentState() };
|
|
96
100
|
const collectionValid = this.rules.every((r) => r(opts) === undefined);
|
|
97
101
|
const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
|
|
98
102
|
return collectionValid && entriesValid;
|
|
@@ -100,7 +104,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
100
104
|
get errors() {
|
|
101
105
|
if (_tick.value < 0)
|
|
102
106
|
(0, utils_1.fail)();
|
|
103
|
-
const opts = { value: this.rows, key: key, originalValue:
|
|
107
|
+
const opts = { value: this.rows, key: key, originalValue: this.originalValue, object: parentState() };
|
|
104
108
|
return this.rules.map((r) => r(opts)).filter(utils_1.isNotUndefined);
|
|
105
109
|
},
|
|
106
110
|
get changedValue() {
|
|
@@ -143,6 +147,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
143
147
|
// We should be passed values that are non-proxies.
|
|
144
148
|
parentInstance[key] = values.map((value) => {
|
|
145
149
|
let childState = rowMap.get(value);
|
|
150
|
+
// If we're being reverted to our originalValue, i.e. values is actually
|
|
151
|
+
// a list of copies, use the copyMap to recover our child states
|
|
152
|
+
if (!childState && copyMap.has(value)) {
|
|
153
|
+
childState = rowMap.get(copyMap.get(value));
|
|
154
|
+
}
|
|
146
155
|
if (!childState) {
|
|
147
156
|
// Look for an existing child (requires having an id key configured)
|
|
148
157
|
for (const [, otherState] of rowMap.entries()) {
|
|
@@ -159,11 +168,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
159
168
|
// Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
|
|
160
169
|
return childState.value;
|
|
161
170
|
});
|
|
171
|
+
// Make sure to tick first so that `setOriginalValue` sees the latest `rows`
|
|
172
|
+
_tick.value++;
|
|
162
173
|
// Reset originalCopy so that our dirty checks have the right # of rows.
|
|
163
174
|
if (opts.refreshing) {
|
|
164
|
-
|
|
175
|
+
this.setOriginalValue();
|
|
165
176
|
}
|
|
166
|
-
_tick.value++;
|
|
167
177
|
},
|
|
168
178
|
add(value, spliceIndex) {
|
|
169
179
|
// This is called by the user, so value should be a non-proxy value we should keep
|
|
@@ -189,16 +199,31 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
189
199
|
maybeAutoSave();
|
|
190
200
|
},
|
|
191
201
|
revertChanges() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
202
|
+
this.set(this.originalValue, { resetting: true });
|
|
203
|
+
this.rows.forEach((r) => r.revertChanges());
|
|
204
|
+
this.touched = false;
|
|
196
205
|
},
|
|
197
206
|
commitChanges() {
|
|
207
|
+
// Tell each child ObjectState to have its fields commit into its parentCopy
|
|
198
208
|
this.rows.forEach((r) => r.commitChanges());
|
|
199
|
-
|
|
209
|
+
this.setOriginalValue();
|
|
210
|
+
this.touched = false;
|
|
200
211
|
_tick.value++;
|
|
201
212
|
},
|
|
213
|
+
get originalValue() {
|
|
214
|
+
// A dummy check to for reactivity around our non-proxy value
|
|
215
|
+
const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
|
|
216
|
+
return value !== null && value !== void 0 ? value : [];
|
|
217
|
+
},
|
|
218
|
+
// This should only be called when value === originalValue
|
|
219
|
+
setOriginalValue() {
|
|
220
|
+
// Use the rows' originalValues to update the parentCopy
|
|
221
|
+
parentCopy[key] = this.rows.map((r) => r.originalValue);
|
|
222
|
+
parentCopy[key].forEach((copy, i) => {
|
|
223
|
+
copyMap.set(copy, parentInstance[key][i]);
|
|
224
|
+
});
|
|
225
|
+
_originalValueTick.value++;
|
|
226
|
+
},
|
|
202
227
|
ensureSet() {
|
|
203
228
|
if (!parentInstance[key]) {
|
|
204
229
|
parentInstance[key] = [];
|
|
@@ -36,6 +36,7 @@ export type ObjectState<T> = FieldStates<T> & FieldState<T> & {
|
|
|
36
36
|
};
|
|
37
37
|
export type ObjectStateInternal<T> = ObjectState<T> & {
|
|
38
38
|
set(value: T, opts?: InternalSetOpts): void;
|
|
39
|
+
isSameEntity(other: T): boolean;
|
|
39
40
|
};
|
|
40
41
|
declare const fragmentSym: unique symbol;
|
|
41
42
|
export type Fragment<V> = V & {
|
|
@@ -34,20 +34,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
34
34
|
}
|
|
35
35
|
return proxy;
|
|
36
36
|
}
|
|
37
|
+
// We directly mutate `instance` as the user edits the form, so keep a deep copy of the POJO.
|
|
38
|
+
// If the user passed us `instance` that was a mobx store/class, this originalValue won't really
|
|
39
|
+
// be a true match (i.e. pass instanceof), but it should be good enough
|
|
40
|
+
const originalCopy = (0, utils_1.deepClone)(instance);
|
|
37
41
|
const objectConfig = config;
|
|
38
42
|
const fieldStates = Object.entries(config).map(([_key, _config]) => {
|
|
39
|
-
var _a, _b;
|
|
43
|
+
var _a, _b, _c, _d;
|
|
40
44
|
const key = _key;
|
|
41
45
|
const config = _config;
|
|
42
46
|
let field;
|
|
43
47
|
if (config.type === "value") {
|
|
44
|
-
field = (0, valueField_1.newValueFieldState)(instance, getObjectState, key, config.rules || [], config.isIdKey ||
|
|
48
|
+
field = (0, valueField_1.newValueFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config.isIdKey ||
|
|
45
49
|
// Default the id key to "id" unless some other field has isIdKey set
|
|
46
50
|
(key === "id" &&
|
|
47
|
-
!Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false,
|
|
51
|
+
!Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, (_a = config.computed) !== null && _a !== void 0 ? _a :
|
|
52
|
+
// If instance is a mobx class, we can detect computeds as they won't be enumerable
|
|
53
|
+
((0, mobx_1.isObservable)(instance) ? !(key in originalCopy) : false), (_b = config.readOnly) !== null && _b !== void 0 ? _b : false, (_c = config.strictOrder) !== null && _c !== void 0 ? _c : true, maybeAutoSave);
|
|
48
54
|
}
|
|
49
55
|
else if (config.type === "list") {
|
|
50
|
-
field = (0, listField_1.newListFieldState)(instance, getObjectState, key, config.rules || [], config, config.config, (
|
|
56
|
+
field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, config.config, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
|
|
51
57
|
}
|
|
52
58
|
else if (config.type === "object") {
|
|
53
59
|
if (!instance[key]) {
|
|
@@ -176,7 +182,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
176
182
|
return result;
|
|
177
183
|
},
|
|
178
184
|
get originalValue() {
|
|
179
|
-
|
|
185
|
+
getFields(proxy).map((f) => f.originalValue);
|
|
186
|
+
return originalCopy;
|
|
180
187
|
},
|
|
181
188
|
// An internal helper method to see if `other` is for "the same entity" as our current row
|
|
182
189
|
isSameEntity(other) {
|
|
@@ -184,7 +191,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
184
191
|
if (!idField) {
|
|
185
192
|
return false;
|
|
186
193
|
}
|
|
187
|
-
|
|
194
|
+
// If the otherIdValue is undefined, it's a new entity so can't be the same as us
|
|
195
|
+
const otherIdValue = other[idField.key];
|
|
196
|
+
return otherIdValue !== undefined && otherIdValue === this[idField.key].value;
|
|
188
197
|
},
|
|
189
198
|
};
|
|
190
199
|
proxy = (0, mobx_1.makeAutoObservable)(obj, {
|
|
@@ -193,6 +202,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
193
202
|
// to mobx this looks like the value never changes, and it will never invoke observers
|
|
194
203
|
// even with our tick-based hacks.
|
|
195
204
|
value: (0, mobx_1.computed)({ equals: () => false }),
|
|
205
|
+
originalValue: (0, mobx_1.computed)({ equals: () => false }),
|
|
196
206
|
});
|
|
197
207
|
// Any time a field changes, percolate that change up to us
|
|
198
208
|
(0, mobx_1.reaction)(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
|
|
@@ -11,7 +11,9 @@ import { Rule } from "../rules";
|
|
|
11
11
|
* i.e. text boxes, can always be cleared out/deleted.
|
|
12
12
|
*/
|
|
13
13
|
export interface FieldState<V> {
|
|
14
|
+
/** The key in the parent object, i.e. `firstName` in `author: { firstName: string }`. */
|
|
14
15
|
readonly key: string;
|
|
16
|
+
/** The current value of the field. */
|
|
15
17
|
value: V;
|
|
16
18
|
readonly originalValue: V;
|
|
17
19
|
touched: boolean;
|
|
@@ -69,4 +71,4 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
|
|
|
69
71
|
_isReadOnlyKey: boolean;
|
|
70
72
|
_focused: boolean;
|
|
71
73
|
}
|
|
72
|
-
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>;
|
|
74
|
+
export declare function newValueFieldState<T, K extends keyof T>(parentCopy: 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>;
|
|
@@ -6,10 +6,7 @@ const mobx_1 = require("mobx");
|
|
|
6
6
|
const proxies_1 = require("../proxies");
|
|
7
7
|
const rules_1 = require("../rules");
|
|
8
8
|
const utils_1 = require("../utils");
|
|
9
|
-
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
|
|
10
|
-
// keep a copy here for reference equality
|
|
11
|
-
const value = parentInstance[key];
|
|
12
|
-
let _originalValue = value === null ? undefined : (0, is_plain_object_1.isPlainObject)(value) ? (0, mobx_1.toJS)(value) : value;
|
|
9
|
+
function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
|
|
13
10
|
// Because we read/write the value directly back into parentInstance[key],
|
|
14
11
|
// which itself is not a proxy, we use this as our "value changed" trigger.
|
|
15
12
|
const _tick = (0, mobx_1.observable)({ value: 1 });
|
|
@@ -31,7 +28,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
31
28
|
// Re-create the `keepNull` logic on sets but for our initial read where our
|
|
32
29
|
// originalValue is null (empty) but we want to expose it as undefined for
|
|
33
30
|
// consistency of "empty-ness" to our UI components.
|
|
34
|
-
return value === null && (0, utils_1.isEmpty)(
|
|
31
|
+
return value === null && (0, utils_1.isEmpty)(parentCopy[key]) ? undefined : value;
|
|
35
32
|
},
|
|
36
33
|
set value(v) {
|
|
37
34
|
this.set(v);
|
|
@@ -94,7 +91,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
94
91
|
if (opts.refreshing && this.dirty && this.value !== value) {
|
|
95
92
|
// Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
|
|
96
93
|
// matches the incoming value (value), b/c if it does we should accept it and reset originalValue
|
|
97
|
-
// so that we're
|
|
94
|
+
// so that we're no longer dirty.
|
|
98
95
|
return;
|
|
99
96
|
}
|
|
100
97
|
else if (computed && (opts.resetting || opts.refreshing)) {
|
|
@@ -140,12 +137,14 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
140
137
|
},
|
|
141
138
|
get originalValue() {
|
|
142
139
|
// A dummy check to for reactivity around our non-proxy value
|
|
143
|
-
const value = _originalValueTick.value > -1 ?
|
|
140
|
+
const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
|
|
144
141
|
// Re-create the `keepNull` logic so that `.value` === `.originalValue`
|
|
145
142
|
return value === null ? undefined : value;
|
|
146
143
|
},
|
|
147
144
|
set originalValue(v) {
|
|
148
|
-
|
|
145
|
+
const canSkip = v === undefined && !(key in parentCopy);
|
|
146
|
+
if (!canSkip)
|
|
147
|
+
parentCopy[key] = v;
|
|
149
148
|
_originalValueTick.value++;
|
|
150
149
|
},
|
|
151
150
|
maybeTrim() {
|
package/dist/formState.test.d.ts
CHANGED
|
@@ -1 +1,11 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export declare class ObservableObject {
|
|
2
|
+
firstName: string | undefined;
|
|
3
|
+
lastName: string | undefined;
|
|
4
|
+
age?: number | undefined;
|
|
5
|
+
constructor();
|
|
6
|
+
get fullName(): string;
|
|
7
|
+
set fullName(fullName: string);
|
|
8
|
+
toInput(): {
|
|
9
|
+
firstName: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
}
|
package/dist/formState.test.js
CHANGED
|
@@ -1,55 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ObservableObject = void 0;
|
|
3
4
|
const mobx_1 = require("mobx");
|
|
5
|
+
const configBuilders_1 = require("./configBuilders");
|
|
4
6
|
const objectField_1 = require("./fields/objectField");
|
|
5
7
|
const formStateDomain_1 = require("./formStateDomain");
|
|
6
8
|
const rules_1 = require("./rules");
|
|
7
|
-
describe("mobx behavior", () => {
|
|
8
|
-
it("mobx batches if in actions", () => {
|
|
9
|
-
const a = (0, mobx_1.observable)({ name: "a1" });
|
|
10
|
-
const b = (0, mobx_1.observable)({ name: "b1" });
|
|
11
|
-
const map = new mobx_1.ObservableMap();
|
|
12
|
-
map.set("a", a);
|
|
13
|
-
map.set("b", b);
|
|
14
|
-
let runs = 0;
|
|
15
|
-
(0, mobx_1.autorun)(() => {
|
|
16
|
-
noop([...map.values()].filter((v) => v.name.includes("1")));
|
|
17
|
-
runs++;
|
|
18
|
-
});
|
|
19
|
-
expect(runs).toBe(1);
|
|
20
|
-
// When we change both a and b
|
|
21
|
-
(0, mobx_1.runInAction)(() => {
|
|
22
|
-
a.name = "a11";
|
|
23
|
-
b.name = "b2";
|
|
24
|
-
});
|
|
25
|
-
// Then the reaction waited and only ran once
|
|
26
|
-
expect(runs).toBe(2);
|
|
27
|
-
});
|
|
28
|
-
it("mobx can watch undefined keys", () => {
|
|
29
|
-
const a = new ObservableObject();
|
|
30
|
-
const b = new ObservableObject();
|
|
31
|
-
const map = new mobx_1.ObservableMap();
|
|
32
|
-
map.set("a", a);
|
|
33
|
-
map.set("b", b);
|
|
34
|
-
let runs = 0;
|
|
35
|
-
(0, mobx_1.autorun)(() => {
|
|
36
|
-
noop([...map.values()].map((a) => a.age));
|
|
37
|
-
runs++;
|
|
38
|
-
});
|
|
39
|
-
expect(runs).toBe(1);
|
|
40
|
-
b.age = 1;
|
|
41
|
-
expect(runs).toBe(2);
|
|
42
|
-
});
|
|
43
|
-
it("mobx lists maintain observable identity", () => {
|
|
44
|
-
// given a parent observable
|
|
45
|
-
const a = (0, mobx_1.observable)({ list: [] });
|
|
46
|
-
// if we observable-ize a value being pushing it on the list
|
|
47
|
-
const c1 = (0, mobx_1.observable)({});
|
|
48
|
-
a.list.push(c1);
|
|
49
|
-
// then we get identify equality on the list lookups
|
|
50
|
-
expect(a.list[0] === c1).toEqual(true);
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
9
|
describe("formState", () => {
|
|
54
10
|
it("can create a simple object", () => {
|
|
55
11
|
const a = (0, objectField_1.createObjectState)({ title: { type: "value", rules: [rules_1.required] } }, {});
|
|
@@ -156,7 +112,8 @@ describe("formState", () => {
|
|
|
156
112
|
const a1 = { firstName: "a1" };
|
|
157
113
|
const state = createAuthorInputState(a1);
|
|
158
114
|
state.firstName.set("a2");
|
|
159
|
-
expect(state.
|
|
115
|
+
expect(state.value === a1).toEqual(true);
|
|
116
|
+
expect(state.originalValue !== a1).toEqual(true);
|
|
160
117
|
expect(a1.firstName).toEqual("a2");
|
|
161
118
|
});
|
|
162
119
|
it("maintains object identity of lists", () => {
|
|
@@ -166,11 +123,11 @@ describe("formState", () => {
|
|
|
166
123
|
const state = createAuthorInputState(a1);
|
|
167
124
|
const b2 = { title: "t2" };
|
|
168
125
|
state.books.add(b2);
|
|
169
|
-
expect(state.
|
|
126
|
+
expect(state.value.books === a1.books).toEqual(true);
|
|
170
127
|
expect(state.books.value.length).toEqual(2);
|
|
171
128
|
expect((_a = a1.books) === null || _a === void 0 ? void 0 : _a.length).toEqual(2);
|
|
172
|
-
expect(state.books.rows[0].
|
|
173
|
-
expect(state.books.rows[1].
|
|
129
|
+
expect(state.books.rows[0].value === b1).toEqual(true);
|
|
130
|
+
expect(state.books.rows[1].value === b2).toEqual(true);
|
|
174
131
|
expect(a1.books[1] === b2).toEqual(true);
|
|
175
132
|
});
|
|
176
133
|
it("can add items anywhere within a list", () => {
|
|
@@ -185,14 +142,14 @@ describe("formState", () => {
|
|
|
185
142
|
});
|
|
186
143
|
const b2 = { title: "t2" };
|
|
187
144
|
state.books.add(b2, 0);
|
|
188
|
-
expect(state.books.rows[0].
|
|
189
|
-
expect(state.books.rows[1].
|
|
145
|
+
expect(state.books.rows[0].value === b2).toEqual(true);
|
|
146
|
+
expect(state.books.rows[1].value === b1).toEqual(true);
|
|
190
147
|
expect(ticks).toEqual(2);
|
|
191
148
|
const b3 = { title: "t3" };
|
|
192
149
|
state.books.add(b3, 1);
|
|
193
|
-
expect(state.books.rows[0].
|
|
194
|
-
expect(state.books.rows[1].
|
|
195
|
-
expect(state.books.rows[2].
|
|
150
|
+
expect(state.books.rows[0].value === b2).toEqual(true);
|
|
151
|
+
expect(state.books.rows[1].value === b3).toEqual(true);
|
|
152
|
+
expect(state.books.rows[2].value === b1).toEqual(true);
|
|
196
153
|
expect(ticks).toEqual(3);
|
|
197
154
|
});
|
|
198
155
|
it("list value can observe changes", () => {
|
|
@@ -208,6 +165,34 @@ describe("formState", () => {
|
|
|
208
165
|
state.books.add({ title: "t2" }, 0);
|
|
209
166
|
expect(ticks).toEqual(2);
|
|
210
167
|
});
|
|
168
|
+
it("list originalValue can observe changes", () => {
|
|
169
|
+
const b1 = { title: "t1" };
|
|
170
|
+
const a1 = { firstName: "a1", books: [b1] };
|
|
171
|
+
const state = createAuthorInputState(a1);
|
|
172
|
+
let books;
|
|
173
|
+
let ticks = 0;
|
|
174
|
+
(0, mobx_1.autorun)(() => {
|
|
175
|
+
books = state.books.originalValue;
|
|
176
|
+
ticks++;
|
|
177
|
+
});
|
|
178
|
+
state.books.add({ title: "t2" }, 0);
|
|
179
|
+
expect(ticks).toEqual(1);
|
|
180
|
+
state.commitChanges();
|
|
181
|
+
expect(ticks).toEqual(2);
|
|
182
|
+
});
|
|
183
|
+
it("state originalValue can observe changes", () => {
|
|
184
|
+
const a1 = { firstName: "a1", books: [] };
|
|
185
|
+
const state = createAuthorInputState(a1);
|
|
186
|
+
let ticks = 0;
|
|
187
|
+
(0, mobx_1.autorun)(() => {
|
|
188
|
+
noop(state.originalValue);
|
|
189
|
+
ticks++;
|
|
190
|
+
});
|
|
191
|
+
state.books.add({ title: "t2" }, 0);
|
|
192
|
+
expect(ticks).toEqual(1);
|
|
193
|
+
state.commitChanges();
|
|
194
|
+
expect(ticks).toEqual(2);
|
|
195
|
+
});
|
|
211
196
|
it("maintains unknown fields", () => {
|
|
212
197
|
// Given the form is not directly editing id fields
|
|
213
198
|
const config = {
|
|
@@ -221,9 +206,8 @@ describe("formState", () => {
|
|
|
221
206
|
state.firstName.set("a2");
|
|
222
207
|
state.books.add({ title: "t2" });
|
|
223
208
|
// When we get back the originalValue
|
|
224
|
-
const a2 = state.originalValue;
|
|
225
209
|
// Then it has the ids and the new values
|
|
226
|
-
expect(
|
|
210
|
+
expect(state.value).toMatchObject({
|
|
227
211
|
id: "1",
|
|
228
212
|
books: [{ id: "2", title: "t1" }, { title: "t2" }],
|
|
229
213
|
});
|
|
@@ -644,8 +628,8 @@ describe("formState", () => {
|
|
|
644
628
|
firstName: "a1",
|
|
645
629
|
lastName: "aL1",
|
|
646
630
|
books: [
|
|
647
|
-
{ title: "b1", classification: formStateDomain_1.dd100 },
|
|
648
|
-
{ title: "b2", classification: formStateDomain_1.dd100 },
|
|
631
|
+
{ id: "b:1", title: "b1", classification: formStateDomain_1.dd100 },
|
|
632
|
+
{ id: "b:2", title: "b2", classification: formStateDomain_1.dd100 },
|
|
649
633
|
],
|
|
650
634
|
});
|
|
651
635
|
// And some values have been changed
|
|
@@ -722,7 +706,7 @@ describe("formState", () => {
|
|
|
722
706
|
a1.firstName.value = "";
|
|
723
707
|
// Then we keep it as null
|
|
724
708
|
expect(a1.firstName.value).toBeNull();
|
|
725
|
-
expect(a1.originalValue.firstName).
|
|
709
|
+
expect(a1.originalValue.firstName).toBe("asdf");
|
|
726
710
|
expect(a1.firstName.dirty).toBeTruthy();
|
|
727
711
|
});
|
|
728
712
|
it("initializes null values to be undefined", () => {
|
|
@@ -968,6 +952,15 @@ describe("formState", () => {
|
|
|
968
952
|
// Throw away assertion, test is making sure ^ line compiles
|
|
969
953
|
expect(config).toBeDefined();
|
|
970
954
|
});
|
|
955
|
+
it("supports observable objects with helper methods in the config DSL", () => {
|
|
956
|
+
const config = configBuilders_1.f.config({
|
|
957
|
+
firstName: configBuilders_1.f.value(),
|
|
958
|
+
lastName: configBuilders_1.f.value(),
|
|
959
|
+
fullName: configBuilders_1.f.computed(),
|
|
960
|
+
});
|
|
961
|
+
// Throw away assertion, test is making sure ^ line compiles
|
|
962
|
+
expect(config).toBeDefined();
|
|
963
|
+
});
|
|
971
964
|
it("can reset observable objects with computeds", () => {
|
|
972
965
|
const formState = (0, objectField_1.createObjectState)({
|
|
973
966
|
firstName: { type: "value" },
|
|
@@ -1111,6 +1104,45 @@ describe("formState", () => {
|
|
|
1111
1104
|
id: "a:1",
|
|
1112
1105
|
books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }],
|
|
1113
1106
|
});
|
|
1107
|
+
// And add a 3rd new book
|
|
1108
|
+
formState.books.add({ title: "t3" });
|
|
1109
|
+
// Then we get the author id (for updates) and all 3 books
|
|
1110
|
+
expect(formState.changedValue).toEqual({
|
|
1111
|
+
id: "a:1",
|
|
1112
|
+
books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }, { title: "t3" }],
|
|
1113
|
+
});
|
|
1114
|
+
// And we can still get the original value
|
|
1115
|
+
expect(formState.originalValue).toEqual({
|
|
1116
|
+
id: "a:1",
|
|
1117
|
+
firstName: "f",
|
|
1118
|
+
books: [
|
|
1119
|
+
{ id: "b:1", title: "t1" },
|
|
1120
|
+
{ id: "b:2", title: "t2" },
|
|
1121
|
+
],
|
|
1122
|
+
});
|
|
1123
|
+
// And the books.originalValue as well
|
|
1124
|
+
expect(formState.books.originalValue).toEqual([
|
|
1125
|
+
{ id: "b:1", title: "t1" },
|
|
1126
|
+
{ id: "b:2", title: "t2" },
|
|
1127
|
+
]);
|
|
1128
|
+
// And when we commit changes
|
|
1129
|
+
formState.commitChanges();
|
|
1130
|
+
// Then our originalValue reflects the commit
|
|
1131
|
+
expect(formState.originalValue).toEqual({
|
|
1132
|
+
id: "a:1",
|
|
1133
|
+
firstName: "f",
|
|
1134
|
+
books: [
|
|
1135
|
+
{ id: "b:1", title: "t1b" },
|
|
1136
|
+
{ id: "b:2", title: "t2" },
|
|
1137
|
+
{ id: undefined, title: "t3" },
|
|
1138
|
+
],
|
|
1139
|
+
});
|
|
1140
|
+
// And the books.originalValue as well
|
|
1141
|
+
expect(formState.books.originalValue).toEqual([
|
|
1142
|
+
{ id: "b:1", title: "t1b" },
|
|
1143
|
+
{ id: "b:2", title: "t2" },
|
|
1144
|
+
{ title: "t3" },
|
|
1145
|
+
]);
|
|
1114
1146
|
});
|
|
1115
1147
|
it("can return only changed but incremental list fields", () => {
|
|
1116
1148
|
// Given an author
|
|
@@ -1512,19 +1544,17 @@ class ObservableObject {
|
|
|
1512
1544
|
return { firstName: this.firstName };
|
|
1513
1545
|
}
|
|
1514
1546
|
}
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
},
|
|
1527
|
-
};
|
|
1547
|
+
exports.ObservableObject = ObservableObject;
|
|
1548
|
+
const authorWithBooksConfig = configBuilders_1.f.config({
|
|
1549
|
+
id: configBuilders_1.f.value(),
|
|
1550
|
+
firstName: configBuilders_1.f.value(),
|
|
1551
|
+
lastName: configBuilders_1.f.value(),
|
|
1552
|
+
books: configBuilders_1.f.list({
|
|
1553
|
+
id: configBuilders_1.f.value(),
|
|
1554
|
+
title: configBuilders_1.f.value().req(),
|
|
1555
|
+
classification: configBuilders_1.f.value(),
|
|
1556
|
+
}),
|
|
1557
|
+
});
|
|
1528
1558
|
function createAuthorInputState(input, maybeAutoSave) {
|
|
1529
1559
|
return (0, objectField_1.createObjectState)(authorWithBooksConfig, input, { maybeAutoSave });
|
|
1530
1560
|
}
|
|
@@ -1540,27 +1570,42 @@ const authorWithAddressConfig = {
|
|
|
1540
1570
|
},
|
|
1541
1571
|
},
|
|
1542
1572
|
};
|
|
1543
|
-
const authorWithAddressAndBooksConfig = {
|
|
1544
|
-
id:
|
|
1545
|
-
firstName:
|
|
1546
|
-
lastName:
|
|
1547
|
-
address: {
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1573
|
+
const authorWithAddressAndBooksConfig = configBuilders_1.f.config({
|
|
1574
|
+
id: configBuilders_1.f.value(),
|
|
1575
|
+
firstName: configBuilders_1.f.value(),
|
|
1576
|
+
lastName: configBuilders_1.f.value(),
|
|
1577
|
+
address: configBuilders_1.f.object({
|
|
1578
|
+
id: configBuilders_1.f.value(),
|
|
1579
|
+
street: configBuilders_1.f.value().rules([rules_1.required]),
|
|
1580
|
+
city: configBuilders_1.f.value(),
|
|
1581
|
+
}),
|
|
1582
|
+
books: configBuilders_1.f.list({
|
|
1583
|
+
id: configBuilders_1.f.value(),
|
|
1584
|
+
title: configBuilders_1.f.value(),
|
|
1585
|
+
classification: configBuilders_1.f.value(),
|
|
1586
|
+
}),
|
|
1587
|
+
});
|
|
1588
|
+
// const authorWithAddressAndBooksConfig: ObjectConfig<AuthorInput> = {
|
|
1589
|
+
// id: { type: "value" },
|
|
1590
|
+
// firstName: { type: "value" },
|
|
1591
|
+
// lastName: { type: "value" },
|
|
1592
|
+
// address: {
|
|
1593
|
+
// type: "object",
|
|
1594
|
+
// config: {
|
|
1595
|
+
// id: { type: "value" },
|
|
1596
|
+
// street: { type: "value", rules: [required] },
|
|
1597
|
+
// city: { type: "value" },
|
|
1598
|
+
// },
|
|
1599
|
+
// },
|
|
1600
|
+
// books: {
|
|
1601
|
+
// type: "list",
|
|
1602
|
+
// config: {
|
|
1603
|
+
// id: { type: "value" },
|
|
1604
|
+
// title: { type: "value", rules: [required] },
|
|
1605
|
+
// classification: { type: "value" },
|
|
1606
|
+
// },
|
|
1607
|
+
// },
|
|
1608
|
+
// };
|
|
1564
1609
|
function createAuthorWithAddressInputState(input) {
|
|
1565
1610
|
return (0, objectField_1.createObjectState)(authorWithAddressConfig, input);
|
|
1566
1611
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ObjectConfig } from "./config";
|
|
2
|
+
export { f } from "./configBuilders";
|
|
2
3
|
export { ListFieldState } from "./fields/listField";
|
|
3
4
|
export { ObjectState, createObjectState } from "./fields/objectField";
|
|
4
5
|
export { FieldState, ValueAdapter } from "./fields/valueField";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
|
|
3
|
+
exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = exports.f = void 0;
|
|
4
|
+
var configBuilders_1 = require("./configBuilders");
|
|
5
|
+
Object.defineProperty(exports, "f", { enumerable: true, get: function () { return configBuilders_1.f; } });
|
|
4
6
|
var objectField_1 = require("./fields/objectField");
|
|
5
7
|
Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return objectField_1.createObjectState; } });
|
|
6
8
|
var rules_1 = require("./rules");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mobx_1 = require("mobx");
|
|
4
|
+
const formState_test_1 = require("./formState.test");
|
|
5
|
+
describe("mobx behavior", () => {
|
|
6
|
+
it("mobx batches if in actions", () => {
|
|
7
|
+
const a = (0, mobx_1.observable)({ name: "a1" });
|
|
8
|
+
const b = (0, mobx_1.observable)({ name: "b1" });
|
|
9
|
+
const map = new mobx_1.ObservableMap();
|
|
10
|
+
map.set("a", a);
|
|
11
|
+
map.set("b", b);
|
|
12
|
+
let runs = 0;
|
|
13
|
+
(0, mobx_1.autorun)(() => {
|
|
14
|
+
noop([...map.values()].filter((v) => v.name.includes("1")));
|
|
15
|
+
runs++;
|
|
16
|
+
});
|
|
17
|
+
expect(runs).toBe(1);
|
|
18
|
+
// When we change both a and b
|
|
19
|
+
(0, mobx_1.runInAction)(() => {
|
|
20
|
+
a.name = "a11";
|
|
21
|
+
b.name = "b2";
|
|
22
|
+
});
|
|
23
|
+
// Then the reaction waited and only ran once
|
|
24
|
+
expect(runs).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
it("mobx can watch undefined keys", () => {
|
|
27
|
+
const a = new formState_test_1.ObservableObject();
|
|
28
|
+
const b = new formState_test_1.ObservableObject();
|
|
29
|
+
const map = new mobx_1.ObservableMap();
|
|
30
|
+
map.set("a", a);
|
|
31
|
+
map.set("b", b);
|
|
32
|
+
let runs = 0;
|
|
33
|
+
(0, mobx_1.autorun)(() => {
|
|
34
|
+
noop([...map.values()].map((a) => a.age));
|
|
35
|
+
runs++;
|
|
36
|
+
});
|
|
37
|
+
expect(runs).toBe(1);
|
|
38
|
+
b.age = 1;
|
|
39
|
+
expect(runs).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
it("mobx lists maintain observable identity", () => {
|
|
42
|
+
// given a parent observable
|
|
43
|
+
const a = (0, mobx_1.observable)({ list: [] });
|
|
44
|
+
// if we observable-ize a value being pushing it on the list
|
|
45
|
+
const c1 = (0, mobx_1.observable)({});
|
|
46
|
+
a.list.push(c1);
|
|
47
|
+
// then we get identify equality on the list lookups
|
|
48
|
+
expect(a.list[0] === c1).toEqual(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
function noop(t) { }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
declare const c: {
|
|
2
|
+
value<V>(): ValueConfig<V>;
|
|
3
|
+
};
|
|
4
|
+
type Config<T> = {
|
|
5
|
+
[P in keyof T]: ValueConfig<T[P]>;
|
|
6
|
+
};
|
|
7
|
+
declare function config<T>(object: Config<T>): void;
|
|
8
|
+
interface ValueConfig<V> {
|
|
9
|
+
req(): ValueConfig<V>;
|
|
10
|
+
readOnly(): ValueConfig<V>;
|
|
11
|
+
validate(fn: (value: V) => boolean): ValueConfig<V>;
|
|
12
|
+
}
|
|
13
|
+
type Author = {
|
|
14
|
+
name: string;
|
|
15
|
+
address: string;
|
|
16
|
+
city: string;
|
|
17
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Create a Zod-style fluent DSL for building config
|
|
3
|
+
const c = {
|
|
4
|
+
value() {
|
|
5
|
+
return null;
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
function config(object) { }
|
|
9
|
+
// Try and configure the author
|
|
10
|
+
config({
|
|
11
|
+
// correctly inferred as c.value<string> --> ValueConfig<string>
|
|
12
|
+
name: c.value(),
|
|
13
|
+
// inferred only as c.value<unknown> --> ValueConfig<unknown>
|
|
14
|
+
address: c.value().req(),
|
|
15
|
+
// compile error, v is implicitly typed as any
|
|
16
|
+
city: c.value().validate((v) => v.length > 0),
|
|
17
|
+
});
|
package/dist/useFormStates.js
CHANGED
|
@@ -64,7 +64,9 @@ function useFormStates(opts) {
|
|
|
64
64
|
}
|
|
65
65
|
// If the source of truth changed, then update the existing state and return it.
|
|
66
66
|
if (existing && existing[1] !== input) {
|
|
67
|
-
form.set((0, utils_1.initValue)(config, map ? { map, input } : input), {
|
|
67
|
+
form.set((0, utils_1.initValue)(config, map ? { map, input } : input), {
|
|
68
|
+
refreshing: true,
|
|
69
|
+
});
|
|
68
70
|
existing[1] = input;
|
|
69
71
|
}
|
|
70
72
|
form.readOnly = readOnlyRef.current || !!opts.readOnly;
|
package/dist/utils.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ export type Primitive = undefined | null | boolean | string | number | Function
|
|
|
8
8
|
export type DeepRequired<T> = T extends Primitive ? T : {
|
|
9
9
|
[P in keyof Required<T>]: T[P] extends Array<infer U> ? Array<DeepRequired<U>> : T[P] extends ReadonlyArray<infer U2> ? ReadonlyArray<DeepRequired<U2>> : DeepRequired<T[P]>;
|
|
10
10
|
};
|
|
11
|
+
export type OmitIf<Base, Condition> = Pick<Base, {
|
|
12
|
+
[Key in keyof Base]: Base[Key] extends Condition ? never : Key;
|
|
13
|
+
}[keyof Base]>;
|
|
11
14
|
export declare function fail(message?: string): never;
|
|
12
15
|
export declare function assertNever(x: never): never;
|
|
13
16
|
/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
|
|
@@ -41,3 +44,5 @@ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolea
|
|
|
41
44
|
export declare function hasToJSON(o?: unknown): o is {
|
|
42
45
|
toJSON(): void;
|
|
43
46
|
};
|
|
47
|
+
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
48
|
+
export declare function deepClone<T>(obj: T, map?: WeakMap<WeakKey, any>): T;
|
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.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
|
|
6
|
+
exports.deepClone = 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");
|
|
@@ -126,3 +126,18 @@ function hasToJSON(o) {
|
|
|
126
126
|
return !!(o && typeof o === "object" && "toJSON" in o);
|
|
127
127
|
}
|
|
128
128
|
exports.hasToJSON = hasToJSON;
|
|
129
|
+
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
130
|
+
function deepClone(obj, map = new WeakMap()) {
|
|
131
|
+
if (obj && typeof obj === "object" && ((0, is_plain_object_1.isPlainObject)(obj) || Array.isArray(obj) || (0, mobx_1.isObservable)(obj))) {
|
|
132
|
+
if (map.has(obj))
|
|
133
|
+
return map.get(obj);
|
|
134
|
+
const result = Array.isArray(obj) ? [] : {};
|
|
135
|
+
map.set(obj, result);
|
|
136
|
+
Object.assign(result, ...Object.keys(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
return obj;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
exports.deepClone = deepClone;
|