@homebound/form-state 2.22.5 → 2.23.1
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/config.d.ts +2 -0
- package/dist/configBuilders.d.ts +8 -3
- package/dist/configBuilders.js +11 -3
- package/dist/configBuilders.test.d.ts +1 -0
- package/dist/configBuilders.test.js +65 -0
- package/dist/fields/listField.d.ts +2 -2
- package/dist/fields/listField.js +3 -2
- package/dist/fields/objectField.d.ts +3 -2
- package/dist/fields/objectField.js +48 -14
- package/dist/fields/valueField.js +1 -0
- package/dist/formState.test.js +96 -18
- package/dist/formStateDomain.d.ts +1 -0
- package/dist/useFormState.js +10 -1
- package/dist/useFormState.test.js +29 -0
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export type ListFieldConfig<U> = {
|
|
|
65
65
|
};
|
|
66
66
|
export type ObjectFieldConfig<U> = {
|
|
67
67
|
type: "object";
|
|
68
|
+
/** Marks an object as a reference, which means we'll only include it's `id` in `changedValue` output. */
|
|
69
|
+
reference?: boolean;
|
|
68
70
|
/** Config for the child's form state, i.e. each book. */
|
|
69
71
|
config: ObjectConfig<U>;
|
|
70
72
|
};
|
package/dist/configBuilders.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ValueFieldConfig } from "./config";
|
|
1
|
+
import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ObjectFieldConfig, ValueFieldConfig } from "./config";
|
|
2
2
|
import { Fragment, ObjectState } from "./fields/objectField";
|
|
3
3
|
import { Rule } from "./rules";
|
|
4
4
|
import { Builtin, OmitIf } from "./utils";
|
|
@@ -14,8 +14,12 @@ export declare const f: {
|
|
|
14
14
|
list<U>(fields: ObjectConfigBuilderFields<U>): ListFieldConfigBuilder<U>;
|
|
15
15
|
/** Creates the config DSL for a primitive value, like a first name or phone number. */
|
|
16
16
|
value<V>(): ValueFieldConfigBuilder<V>;
|
|
17
|
+
/** A shortcut for creating a child object with a single `id` key. */
|
|
18
|
+
reference<V_1 extends {
|
|
19
|
+
id?: unknown;
|
|
20
|
+
}>(fields?: ObjectConfigBuilderFields<V_1> | undefined): ObjectConfigBuilder<V_1>;
|
|
17
21
|
/** A shorthand for creating a computed value. */
|
|
18
|
-
computed<
|
|
22
|
+
computed<V_2>(): ValueFieldConfigBuilder<V_2>;
|
|
19
23
|
};
|
|
20
24
|
/**
|
|
21
25
|
* Defines the fields that should be passed into `f.object({ ... })` for fhe form type `T`.
|
|
@@ -33,7 +37,8 @@ export interface FragmentFieldConfigBuilder {
|
|
|
33
37
|
export declare class ObjectConfigBuilder<T> {
|
|
34
38
|
private config;
|
|
35
39
|
constructor(fields: ObjectConfigBuilderFields<T>);
|
|
36
|
-
|
|
40
|
+
ref(): this;
|
|
41
|
+
build(): ObjectFieldConfig<T>;
|
|
37
42
|
}
|
|
38
43
|
/** Provides a fluent DSL for building up a value's config. */
|
|
39
44
|
export declare class ValueFieldConfigBuilder<V> {
|
package/dist/configBuilders.js
CHANGED
|
@@ -8,7 +8,7 @@ const rules_1 = require("./rules");
|
|
|
8
8
|
exports.f = {
|
|
9
9
|
/** Creates the top-level config for a form, like an author or book. */
|
|
10
10
|
config(fields) {
|
|
11
|
-
return new ObjectConfigBuilder(fields).build();
|
|
11
|
+
return new ObjectConfigBuilder(fields).build().config;
|
|
12
12
|
},
|
|
13
13
|
/** Creates the config DSL for an object, like an author or book. */
|
|
14
14
|
object(fields) {
|
|
@@ -22,6 +22,10 @@ exports.f = {
|
|
|
22
22
|
value() {
|
|
23
23
|
return new ValueFieldConfigBuilder();
|
|
24
24
|
},
|
|
25
|
+
/** A shortcut for creating a child object with a single `id` key. */
|
|
26
|
+
reference(fields) {
|
|
27
|
+
return exports.f.object({ id: exports.f.value(), ...fields }).ref();
|
|
28
|
+
},
|
|
25
29
|
/** A shorthand for creating a computed value. */
|
|
26
30
|
computed() {
|
|
27
31
|
return this.value().computed();
|
|
@@ -30,11 +34,15 @@ exports.f = {
|
|
|
30
34
|
/** Provides a fluent DSL for building up an object's config. */
|
|
31
35
|
class ObjectConfigBuilder {
|
|
32
36
|
constructor(fields) {
|
|
33
|
-
this.config = {};
|
|
37
|
+
this.config = { type: "object", config: {} };
|
|
34
38
|
for (const [key, value] of Object.entries(fields)) {
|
|
35
|
-
this.config[key] = value.build();
|
|
39
|
+
this.config.config[key] = value.build();
|
|
36
40
|
}
|
|
37
41
|
}
|
|
42
|
+
ref() {
|
|
43
|
+
this.config.reference = true;
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
38
46
|
build() {
|
|
39
47
|
return this.config;
|
|
40
48
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const configBuilders_1 = require("./configBuilders");
|
|
4
|
+
describe("config", () => {
|
|
5
|
+
it("supports observable objects with helper methods in the config DSL", () => {
|
|
6
|
+
const config = configBuilders_1.f.config({
|
|
7
|
+
firstName: configBuilders_1.f.value(),
|
|
8
|
+
lastName: configBuilders_1.f.value(),
|
|
9
|
+
fullName: configBuilders_1.f.computed(),
|
|
10
|
+
});
|
|
11
|
+
// Throw away assertion, test is making sure ^ line compiles
|
|
12
|
+
expect(config).toBeDefined();
|
|
13
|
+
});
|
|
14
|
+
it("supports nested objects", () => {
|
|
15
|
+
const config = configBuilders_1.f.config({
|
|
16
|
+
id: configBuilders_1.f.value(),
|
|
17
|
+
address: configBuilders_1.f.object({ id: configBuilders_1.f.value() }),
|
|
18
|
+
});
|
|
19
|
+
expect(config).toMatchInlineSnapshot(`
|
|
20
|
+
{
|
|
21
|
+
"address": {
|
|
22
|
+
"config": {
|
|
23
|
+
"id": {
|
|
24
|
+
"rules": [],
|
|
25
|
+
"type": "value",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
"type": "object",
|
|
29
|
+
},
|
|
30
|
+
"id": {
|
|
31
|
+
"rules": [],
|
|
32
|
+
"type": "value",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
`);
|
|
36
|
+
});
|
|
37
|
+
it("supports Reference alias", () => {
|
|
38
|
+
const config = configBuilders_1.f.config({
|
|
39
|
+
id: configBuilders_1.f.value(),
|
|
40
|
+
address: configBuilders_1.f.reference({ name: configBuilders_1.f.value() }),
|
|
41
|
+
});
|
|
42
|
+
expect(config).toMatchInlineSnapshot(`
|
|
43
|
+
{
|
|
44
|
+
"address": {
|
|
45
|
+
"config": {
|
|
46
|
+
"id": {
|
|
47
|
+
"rules": [],
|
|
48
|
+
"type": "value",
|
|
49
|
+
},
|
|
50
|
+
"name": {
|
|
51
|
+
"rules": [],
|
|
52
|
+
"type": "value",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"reference": true,
|
|
56
|
+
"type": "object",
|
|
57
|
+
},
|
|
58
|
+
"id": {
|
|
59
|
+
"rules": [],
|
|
60
|
+
"type": "value",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
`);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ListFieldConfig,
|
|
1
|
+
import { ListFieldConfig, ObjectFieldConfig } from "../config";
|
|
2
2
|
import { ObjectState } from "./objectField";
|
|
3
3
|
import { FieldState } from "./valueField";
|
|
4
4
|
import { Rule } from "../rules";
|
|
@@ -8,4 +8,4 @@ export interface ListFieldState<U> extends FieldState<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>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config:
|
|
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: ObjectFieldConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
|
package/dist/fields/listField.js
CHANGED
|
@@ -41,7 +41,7 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
41
41
|
}
|
|
42
42
|
// If we didn't have an existing child, just make a new object state
|
|
43
43
|
if (!childState) {
|
|
44
|
-
childState = (0, objectField_1.newObjectState)(config, parentState, list, child, undefined, maybeAutoSave);
|
|
44
|
+
childState = (0, objectField_1.newObjectState)(config, parentState, undefined, list, child, undefined, maybeAutoSave);
|
|
45
45
|
rowMap.set(child, childState);
|
|
46
46
|
}
|
|
47
47
|
return childState;
|
|
@@ -181,7 +181,8 @@ function newListFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
181
181
|
},
|
|
182
182
|
add(value, spliceIndex) {
|
|
183
183
|
// This is called by the user, so value should be a non-proxy value we should keep
|
|
184
|
-
const childState = (
|
|
184
|
+
const childState = getOrCreateChildState(value);
|
|
185
|
+
// const childState = createObjectState(config, value, { maybeAutoSave }) as ObjectStateInternal<U>;
|
|
185
186
|
rowMap.set(value, childState);
|
|
186
187
|
this.ensureSet();
|
|
187
188
|
this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ObjectConfig } from "../config";
|
|
1
|
+
import { ObjectConfig, ObjectFieldConfig } from "../config";
|
|
2
2
|
import { FragmentField } from "./fragmentField";
|
|
3
3
|
import { ListFieldState } from "./listField";
|
|
4
4
|
import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
|
|
@@ -57,5 +57,6 @@ type FieldStates<T> = {
|
|
|
57
57
|
export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
|
|
58
58
|
maybeAutoSave?: () => void;
|
|
59
59
|
}): ObjectState<T>;
|
|
60
|
-
|
|
60
|
+
/** A more internal version of `createObjectState`. */
|
|
61
|
+
export declare function newObjectState<T, P = any>(config: ObjectFieldConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentInstance: P | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void): ObjectState<T>;
|
|
61
62
|
export {};
|
|
@@ -20,10 +20,11 @@ exports.fragment = fragment;
|
|
|
20
20
|
*/
|
|
21
21
|
function createObjectState(config, instance, opts = {}) {
|
|
22
22
|
const noop = () => { };
|
|
23
|
-
return newObjectState(config, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
|
|
23
|
+
return newObjectState({ type: "object", config }, undefined, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
|
|
24
24
|
}
|
|
25
25
|
exports.createObjectState = createObjectState;
|
|
26
|
-
|
|
26
|
+
/** A more internal version of `createObjectState`. */
|
|
27
|
+
function newObjectState(config, parentState, parentInstance, parentListState, instance, key, maybeAutoSave) {
|
|
27
28
|
// This is what we return, but we only know it's value until we call `observable`, so
|
|
28
29
|
// we create a mutable variable to capture it so that we can create fields/call their
|
|
29
30
|
// constructors and give them a way to access it later.
|
|
@@ -38,8 +39,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
38
39
|
// If the user passed us `instance` that was a mobx store/class, this originalValue won't really
|
|
39
40
|
// be a true match (i.e. pass instanceof), but it should be good enough
|
|
40
41
|
const originalCopy = (0, utils_1.deepClone)(instance);
|
|
41
|
-
const objectConfig = config;
|
|
42
|
-
const fieldStates = Object.entries(
|
|
42
|
+
const objectConfig = config.config;
|
|
43
|
+
const fieldStates = Object.entries(objectConfig).map(([_key, _config]) => {
|
|
43
44
|
var _a, _b, _c, _d;
|
|
44
45
|
const key = _key;
|
|
45
46
|
const config = _config;
|
|
@@ -53,26 +54,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
53
54
|
((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);
|
|
54
55
|
}
|
|
55
56
|
else if (config.type === "list") {
|
|
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);
|
|
57
|
+
field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, { type: "object", config: config.config }, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
|
|
57
58
|
}
|
|
58
59
|
else if (config.type === "object") {
|
|
59
60
|
if (!instance[key]) {
|
|
60
61
|
instance[key] = {};
|
|
61
62
|
}
|
|
62
|
-
field = newObjectState(config
|
|
63
|
+
field = newObjectState(config, getObjectState, instance, undefined, instance[key], key, maybeAutoSave);
|
|
63
64
|
}
|
|
64
65
|
else if (config.type === "fragment") {
|
|
65
66
|
field = (0, fragmentField_1.newFragmentField)(instance, key);
|
|
66
67
|
}
|
|
67
68
|
else {
|
|
68
|
-
throw new Error(
|
|
69
|
+
throw new Error(`Invalid type value ${config.type}`);
|
|
69
70
|
}
|
|
70
71
|
return [key, field];
|
|
71
72
|
});
|
|
72
73
|
// We always return the same `instance` field from our `value` method, but
|
|
73
74
|
// we want to pretend that it's observable, so use a tick to force it.
|
|
74
75
|
const _tick = (0, mobx_1.observable)({ value: 1 });
|
|
75
|
-
const fieldNames = Object.keys(
|
|
76
|
+
const fieldNames = Object.keys(objectConfig);
|
|
76
77
|
function getFields(proxyThis) {
|
|
77
78
|
return fieldNames.map((name) => proxyThis[name]);
|
|
78
79
|
}
|
|
@@ -87,8 +88,10 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
87
88
|
this.set(value);
|
|
88
89
|
},
|
|
89
90
|
// private
|
|
91
|
+
_kind: "object",
|
|
90
92
|
_readOnly: false,
|
|
91
93
|
_loading: false,
|
|
94
|
+
_isAutoSaving: false,
|
|
92
95
|
_considerDeleted() {
|
|
93
96
|
const deleteField = getFields(this).find((f) => f._isDeleteKey);
|
|
94
97
|
return !!(deleteField === null || deleteField === void 0 ? void 0 : deleteField.value);
|
|
@@ -128,7 +131,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
128
131
|
return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
|
|
129
132
|
},
|
|
130
133
|
get dirty() {
|
|
131
|
-
return getFields(this).some((f) => f.dirty);
|
|
134
|
+
return getFields(this).some((f) => f.dirty) || this.isUnset();
|
|
132
135
|
},
|
|
133
136
|
get isNewEntity() {
|
|
134
137
|
const idField = getFields(this).find((f) => f._isIdKey);
|
|
@@ -148,6 +151,13 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
148
151
|
if (this.readOnly && !opts.resetting && !opts.refreshing) {
|
|
149
152
|
throw new Error(`${String(key) || "formState"} is currently readOnly`);
|
|
150
153
|
}
|
|
154
|
+
// Restore our instance if we're being reset
|
|
155
|
+
if (value && this.isUnset())
|
|
156
|
+
parentInstance[key] = instance;
|
|
157
|
+
// Delete our instance if we're being unset
|
|
158
|
+
if (!value && parentInstance)
|
|
159
|
+
parentInstance[key] = undefined;
|
|
160
|
+
// Otherwise just copy over the fields
|
|
151
161
|
getFields(this).forEach((field) => {
|
|
152
162
|
if (value && typeof value === "object" && field.key in value) {
|
|
153
163
|
field.set(value[field.key], opts);
|
|
@@ -160,22 +170,43 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
160
170
|
},
|
|
161
171
|
// Saves all current values into _originalValue
|
|
162
172
|
commitChanges() {
|
|
173
|
+
if (this._isAutoSaving) {
|
|
174
|
+
// `commitChanges` will mark all WIP changes as committed, which might drop changes if there
|
|
175
|
+
// is an auto-save in-flight, and the user made more changes, that are waiting for their turn
|
|
176
|
+
// on the next auto-save request.
|
|
177
|
+
//
|
|
178
|
+
// Instead of doing a form-wide "all changes are committed", instead autosave forms should use
|
|
179
|
+
// init.map/input to have the server's latest results updated within the form, which will then
|
|
180
|
+
// selectively "un-dirty"/commit the just-saved data, but keep the "still-dirty" WIP data alone.
|
|
181
|
+
throw new Error("When using autoSave, you should not manually call commitChanges, instead have init.map/input update the form state");
|
|
182
|
+
}
|
|
183
|
+
// asdf
|
|
163
184
|
getFields(this).forEach((f) => f.commitChanges());
|
|
164
185
|
},
|
|
165
186
|
// Create a result that is only populated with changed keys
|
|
166
187
|
get changedValue() {
|
|
167
188
|
const result = {};
|
|
189
|
+
// If we're a child, like `author.address` that was unset, return null
|
|
190
|
+
if (this.isUnset()) {
|
|
191
|
+
// If we're a reference, always return `{ id: ... }` for easy binding
|
|
192
|
+
const idField = getFields(this).find((f) => f._isIdKey);
|
|
193
|
+
if (idField && config.reference)
|
|
194
|
+
return { id: null };
|
|
195
|
+
// Otherwise mark the whole child as gone
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
168
198
|
getFields(this).forEach((f) => {
|
|
169
199
|
if (f._computed)
|
|
170
200
|
return;
|
|
201
|
+
// References only include the id key below
|
|
202
|
+
if (config.reference)
|
|
203
|
+
return;
|
|
171
204
|
if (f.dirty ||
|
|
172
205
|
// If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
|
|
173
206
|
// look dirty, but if we're new we should include them anyway.
|
|
174
|
-
(this.isNewEntity &&
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// And unless they're empty sub-objects
|
|
178
|
-
!(f.value instanceof Object && Object.entries(f.changedValue).length === 0))) {
|
|
207
|
+
(this.isNewEntity && f._kind === "value" && f.value !== undefined) ||
|
|
208
|
+
// And unless they're empty sub-objects
|
|
209
|
+
(this.isNewEntity && f._kind === "object" && Object.entries(f.changedValue).length > 0)) {
|
|
179
210
|
result[f.key] = f.changedValue;
|
|
180
211
|
}
|
|
181
212
|
});
|
|
@@ -200,6 +231,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
|
|
|
200
231
|
// If the otherIdValue is undefined, it's a new entity so can't be the same as us
|
|
201
232
|
return otherIdValue !== undefined && otherIdValue === ourIdValue;
|
|
202
233
|
},
|
|
234
|
+
isUnset() {
|
|
235
|
+
return !!parentInstance && parentInstance[key] === undefined;
|
|
236
|
+
},
|
|
203
237
|
};
|
|
204
238
|
proxy = (0, mobx_1.makeAutoObservable)(obj, {
|
|
205
239
|
// Use custom equality on `value` that is _never_ equals. This sounds weird, but
|
|
@@ -17,6 +17,7 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
17
17
|
/** Current readOnly value. */
|
|
18
18
|
_readOnly: readOnly || false,
|
|
19
19
|
_loading: false,
|
|
20
|
+
_kind: "value",
|
|
20
21
|
// Expose so computed can be skipped in changedValue
|
|
21
22
|
_computed: computed,
|
|
22
23
|
_focused: false,
|
package/dist/formState.test.js
CHANGED
|
@@ -957,24 +957,6 @@ describe("formState", () => {
|
|
|
957
957
|
// Then the formState saw it
|
|
958
958
|
expect(lastLength).toEqual(2);
|
|
959
959
|
});
|
|
960
|
-
it("supports observable objects with helper methods", () => {
|
|
961
|
-
const config = {
|
|
962
|
-
firstName: { type: "value" },
|
|
963
|
-
lastName: { type: "value" },
|
|
964
|
-
fullName: { type: "value", computed: true },
|
|
965
|
-
};
|
|
966
|
-
// Throw away assertion, test is making sure ^ line compiles
|
|
967
|
-
expect(config).toBeDefined();
|
|
968
|
-
});
|
|
969
|
-
it("supports observable objects with helper methods in the config DSL", () => {
|
|
970
|
-
const config = configBuilders_1.f.config({
|
|
971
|
-
firstName: configBuilders_1.f.value(),
|
|
972
|
-
lastName: configBuilders_1.f.value(),
|
|
973
|
-
fullName: configBuilders_1.f.computed(),
|
|
974
|
-
});
|
|
975
|
-
// Throw away assertion, test is making sure ^ line compiles
|
|
976
|
-
expect(config).toBeDefined();
|
|
977
|
-
});
|
|
978
960
|
it("can reset observable objects with computeds", () => {
|
|
979
961
|
const formState = (0, objectField_1.createObjectState)({
|
|
980
962
|
firstName: { type: "value" },
|
|
@@ -1087,6 +1069,89 @@ describe("formState", () => {
|
|
|
1087
1069
|
// because this might trigger creating a throw-away entity
|
|
1088
1070
|
expect(formState.changedValue).toEqual({ firstName: "f" });
|
|
1089
1071
|
});
|
|
1072
|
+
it("changedValue includes new entity nested fields", () => {
|
|
1073
|
+
// Given a new author with an address FK
|
|
1074
|
+
const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, {
|
|
1075
|
+
firstName: "f",
|
|
1076
|
+
address: { id: "add:1" },
|
|
1077
|
+
});
|
|
1078
|
+
// Then changedValue includes the reference to the FK
|
|
1079
|
+
expect(formState.changedValue).toEqual({
|
|
1080
|
+
firstName: "f",
|
|
1081
|
+
address: { id: "add:1" },
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
it("changedValue includes new entity nested fields with multiple keys", () => {
|
|
1085
|
+
// Given a new author with an address FK
|
|
1086
|
+
const formState = (0, objectField_1.createObjectState)(authorWithAddressFkConfig, { firstName: "f" });
|
|
1087
|
+
formState.address.set({ id: "add:1", street: "Main St" });
|
|
1088
|
+
// Then changedValue includes the reference to the FK
|
|
1089
|
+
expect(formState.changedValue).toEqual({
|
|
1090
|
+
firstName: "f",
|
|
1091
|
+
address: { id: "add:1", street: "Main St" },
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
it("changedValue includes deleted nested fields", () => {
|
|
1095
|
+
var _a;
|
|
1096
|
+
// Given a new author with an address FK
|
|
1097
|
+
const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
|
|
1098
|
+
id: configBuilders_1.f.value(),
|
|
1099
|
+
firstName: configBuilders_1.f.value(),
|
|
1100
|
+
// And address is a reference
|
|
1101
|
+
address: configBuilders_1.f.reference(),
|
|
1102
|
+
}), { id: "a:1", firstName: "f", address: { id: "add:1" } });
|
|
1103
|
+
// When the address is unset
|
|
1104
|
+
formState.address.set(undefined);
|
|
1105
|
+
// Then the field is dirty and will be removed in changedValue
|
|
1106
|
+
expect(formState.address.dirty).toBe(true);
|
|
1107
|
+
expect(formState.dirty).toBe(true);
|
|
1108
|
+
expect(formState.changedValue).toEqual({
|
|
1109
|
+
id: "a:1",
|
|
1110
|
+
address: { id: null },
|
|
1111
|
+
});
|
|
1112
|
+
// So that we can do a binding like
|
|
1113
|
+
const addressId = (_a = formState.changedValue.address) === null || _a === void 0 ? void 0 : _a.id;
|
|
1114
|
+
expect(addressId).toBeNull();
|
|
1115
|
+
// And when we're restored to the same value
|
|
1116
|
+
formState.address.set({ id: "add:1" });
|
|
1117
|
+
// Then it's back to not being dirty
|
|
1118
|
+
expect(formState.address.dirty).toBe(false);
|
|
1119
|
+
expect(formState.dirty).toBe(false);
|
|
1120
|
+
// And when we're changed to a different address
|
|
1121
|
+
formState.address.set({ id: "add:2" });
|
|
1122
|
+
// Then we're dirty again
|
|
1123
|
+
expect(formState.changedValue).toEqual({
|
|
1124
|
+
id: "a:1",
|
|
1125
|
+
address: { id: "add:2" },
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
it("changedValue includes deleted nested fields with a name", () => {
|
|
1129
|
+
// Given a new author with an address FK
|
|
1130
|
+
const formState = (0, objectField_1.createObjectState)(configBuilders_1.f.config({
|
|
1131
|
+
id: configBuilders_1.f.value(),
|
|
1132
|
+
firstName: configBuilders_1.f.value(),
|
|
1133
|
+
address: configBuilders_1.f.reference({ street: configBuilders_1.f.value() }),
|
|
1134
|
+
}), { id: "a:1", firstName: "f", address: { id: "add:1", street: "Main St" } });
|
|
1135
|
+
// Initially both id and street can be bound to the UI
|
|
1136
|
+
expect(formState.address.id.value).toBe("add:1");
|
|
1137
|
+
expect(formState.address.street.value).toBe("Main St");
|
|
1138
|
+
// When the address is unset
|
|
1139
|
+
formState.address.set(undefined);
|
|
1140
|
+
// Then the field is dirty and will be removed in changedValue
|
|
1141
|
+
expect(formState.address.dirty).toBe(true);
|
|
1142
|
+
// And our changedValue only includes the id
|
|
1143
|
+
expect(formState.changedValue).toEqual({
|
|
1144
|
+
id: "a:1",
|
|
1145
|
+
address: { id: null },
|
|
1146
|
+
});
|
|
1147
|
+
// And when we're changed to a new address
|
|
1148
|
+
formState.address.set({ id: "add:2", street: "Side St" });
|
|
1149
|
+
// Then we see oly the id come back in changedValue
|
|
1150
|
+
expect(formState.changedValue).toEqual({
|
|
1151
|
+
id: "a:1",
|
|
1152
|
+
address: { id: "add:2" },
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1090
1155
|
it("changedValue skips effectively empty nested fields", () => {
|
|
1091
1156
|
// Given a new author with an address object
|
|
1092
1157
|
const formState = (0, objectField_1.createObjectState)(authorWithAddressConfig, {
|
|
@@ -1620,6 +1685,19 @@ const authorWithAddressConfig = {
|
|
|
1620
1685
|
},
|
|
1621
1686
|
},
|
|
1622
1687
|
};
|
|
1688
|
+
const authorWithAddressFkConfig = {
|
|
1689
|
+
id: { type: "value" },
|
|
1690
|
+
firstName: { type: "value" },
|
|
1691
|
+
lastName: { type: "value" },
|
|
1692
|
+
address: {
|
|
1693
|
+
type: "object",
|
|
1694
|
+
config: {
|
|
1695
|
+
id: { type: "value" },
|
|
1696
|
+
street: { type: "value", rules: [rules_1.required] },
|
|
1697
|
+
city: { type: "value" },
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
};
|
|
1623
1701
|
const authorWithAddressAndBooksConfig = configBuilders_1.f.config({
|
|
1624
1702
|
id: configBuilders_1.f.value(),
|
|
1625
1703
|
firstName: configBuilders_1.f.value(),
|
package/dist/useFormState.js
CHANGED
|
@@ -32,6 +32,10 @@ function useFormState(opts) {
|
|
|
32
32
|
pendingAutoSave = true;
|
|
33
33
|
}
|
|
34
34
|
else if (isAutoSaving === "queued") {
|
|
35
|
+
// If we've queued up an auto-save, that means we haven't called the `autoSave(...)` function yet, so
|
|
36
|
+
// we assume that the scheduled-but-not-yet-invoked invocation will find both it's original change, and
|
|
37
|
+
// also this potentially new change, and put them both onto the wire as a single operation.
|
|
38
|
+
// So we can just ignore this call.
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
37
41
|
// Don't use canSave() because we don't want to set touched for all the fields
|
|
@@ -41,8 +45,12 @@ function useFormState(opts) {
|
|
|
41
45
|
// We use setTimeout as a cheap way to wait until the end of the current event listener
|
|
42
46
|
setTimeout(async () => {
|
|
43
47
|
try {
|
|
48
|
+
// Tell commitChanges to blow up.
|
|
49
|
+
form._isAutoSaving = true;
|
|
44
50
|
// We technically don't flip to in-flight until after the call in case the
|
|
45
|
-
// user's autoSave function itself wants to call a .set
|
|
51
|
+
// user's autoSave function itself wants to call a .set (which would call `maybeAutoSave`,
|
|
52
|
+
// and we don't want it to see `isAutoSaving=in-flight` and think it "missed the boat",
|
|
53
|
+
// and so schedule an unnecessary follow-up autosave.)
|
|
46
54
|
const promise = autoSaveRef.current(form);
|
|
47
55
|
isAutoSaving = "in-flight";
|
|
48
56
|
await promise;
|
|
@@ -53,6 +61,7 @@ function useFormState(opts) {
|
|
|
53
61
|
}
|
|
54
62
|
finally {
|
|
55
63
|
isAutoSaving = false;
|
|
64
|
+
form._isAutoSaving = false;
|
|
56
65
|
if (pendingAutoSave) {
|
|
57
66
|
pendingAutoSave = false;
|
|
58
67
|
// Push out the follow-up by 1 tick to allow refreshes to happen to potentially
|
|
@@ -539,6 +539,35 @@ describe("useFormState", () => {
|
|
|
539
539
|
expect(r.originalValue).toHaveTextContent("a1");
|
|
540
540
|
expect(r.objectValue).toHaveTextContent("a1");
|
|
541
541
|
});
|
|
542
|
+
it("doesn't allow calling commitChanges", async () => {
|
|
543
|
+
// Given a component
|
|
544
|
+
const autoSave = jest.fn();
|
|
545
|
+
function TestComponent() {
|
|
546
|
+
const config = authorConfig;
|
|
547
|
+
const form = (0, useFormState_1.useFormState)({
|
|
548
|
+
config,
|
|
549
|
+
init: { firstName: "f1", lastName: "f1" },
|
|
550
|
+
autoSave: async (form) => {
|
|
551
|
+
autoSave(form.changedValue);
|
|
552
|
+
// And the autoSave functions erroneously calls commitChanges
|
|
553
|
+
form.commitChanges();
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
return (0, jsx_runtime_1.jsx)("button", { "data-testid": "set", onClick: () => form.firstName.set("f2") });
|
|
557
|
+
}
|
|
558
|
+
const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
|
|
559
|
+
// When we change a field, and try to commit, it blows up
|
|
560
|
+
// await expect(clickAndWait(r.set)).rejects.toThrow(
|
|
561
|
+
// "When using autoSave, you should not manually call commitChanges",
|
|
562
|
+
// );
|
|
563
|
+
//
|
|
564
|
+
// ...for some reason, I think maybe due to rtl-utils calling runOnlyPendingTimers
|
|
565
|
+
// instead of runOnlyPendingTimers async, the `clickAndWait` actually resolves
|
|
566
|
+
// successfully (it's not a rejected promise), and our test technically keeps running,
|
|
567
|
+
// except then somewhere in the guts of Jest, the fake timer infra realizes that a tick
|
|
568
|
+
// failed (our autosave is actually fired from a setTimout tick), and so the Jest test
|
|
569
|
+
// fails anyway, but without anyway for us to catch and do a `.toThrow` assertion.
|
|
570
|
+
});
|
|
542
571
|
});
|
|
543
572
|
const authorConfig = {
|
|
544
573
|
id: { type: "value" },
|