@homebound/form-state 2.0.1 → 2.1.2
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 +2 -0
- package/dist/FormStateApp.test.js +14 -14
- package/dist/formState.d.ts +1 -0
- package/dist/formState.js +31 -18
- package/dist/formState.test.js +102 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,3 +5,5 @@ Push release.
|
|
|
5
5
|
# Todo
|
|
6
6
|
|
|
7
7
|
* Add conditional readonly logic, like `{ type: "field", readOnlyIf: i => i.isInternal.value }`
|
|
8
|
+
|
|
9
|
+
* Add `omitFromValue` so we can have two fields, `book.author.id` / `book.author.name`, where `book.author.name` is used for showing the author name, but `book.author.id` is the only field that is submitted to the server on mutation (maybe pair this with `Ref` based mutations)
|
|
@@ -6,21 +6,21 @@ const react_1 = require("@testing-library/react");
|
|
|
6
6
|
const FormStateApp_1 = require("./FormStateApp");
|
|
7
7
|
describe("FormStateApp", () => {
|
|
8
8
|
it("save resets dirty reactively", async () => {
|
|
9
|
-
const
|
|
10
|
-
expect(firstName_dirty()).toHaveTextContent("false");
|
|
11
|
-
rtl_utils_1.type(firstName, "changed");
|
|
12
|
-
expect(firstName_dirty()).toHaveTextContent("true");
|
|
13
|
-
react_1.fireEvent.blur(firstName());
|
|
14
|
-
expect(firstName_touched()).toHaveTextContent("true");
|
|
15
|
-
rtl_utils_1.click(save);
|
|
16
|
-
expect(firstName_dirty()).toHaveTextContent("false");
|
|
17
|
-
expect(firstName_touched()).toHaveTextContent("false");
|
|
9
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
|
|
10
|
+
expect(r.firstName_dirty()).toHaveTextContent("false");
|
|
11
|
+
rtl_utils_1.type(r.firstName, "changed");
|
|
12
|
+
expect(r.firstName_dirty()).toHaveTextContent("true");
|
|
13
|
+
react_1.fireEvent.blur(r.firstName());
|
|
14
|
+
expect(r.firstName_touched()).toHaveTextContent("true");
|
|
15
|
+
rtl_utils_1.click(r.save);
|
|
16
|
+
expect(r.firstName_dirty()).toHaveTextContent("false");
|
|
17
|
+
expect(r.firstName_touched()).toHaveTextContent("false");
|
|
18
18
|
});
|
|
19
19
|
it("originalValue is reactive", async () => {
|
|
20
|
-
const
|
|
21
|
-
expect(firstName_original()).toHaveTextContent("a1");
|
|
22
|
-
rtl_utils_1.click(set);
|
|
23
|
-
rtl_utils_1.click(save);
|
|
24
|
-
expect(firstName_original()).toHaveTextContent("a2");
|
|
20
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
|
|
21
|
+
expect(r.firstName_original()).toHaveTextContent("a1");
|
|
22
|
+
rtl_utils_1.click(r.set);
|
|
23
|
+
rtl_utils_1.click(r.save);
|
|
24
|
+
expect(r.firstName_original()).toHaveTextContent("a2");
|
|
25
25
|
});
|
|
26
26
|
});
|
package/dist/formState.d.ts
CHANGED
|
@@ -109,6 +109,7 @@ export interface FieldState<T, V> {
|
|
|
109
109
|
readonly required: boolean;
|
|
110
110
|
readonly dirty: boolean;
|
|
111
111
|
readonly valid: boolean;
|
|
112
|
+
readonly isNewEntity: boolean;
|
|
112
113
|
rules: Rule<T, V>[];
|
|
113
114
|
readonly errors: string[];
|
|
114
115
|
/** Returns a subset of V with only the changed values. Currently not observable. */
|
package/dist/formState.js
CHANGED
|
@@ -40,7 +40,7 @@ function useFormState(opts) {
|
|
|
40
40
|
}, [
|
|
41
41
|
config,
|
|
42
42
|
// If they're using init.input, useMemo on it, otherwise let the identity of init be unstable
|
|
43
|
-
...(init && "input" in init && "map" in init ? [init.input] : []),
|
|
43
|
+
...(init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : []),
|
|
44
44
|
]);
|
|
45
45
|
// Use useEffect so that we don't touch the form.init proxy during a render
|
|
46
46
|
react_1.useEffect(() => {
|
|
@@ -64,10 +64,10 @@ exports.required = mobx_1.action(({ value: v }) => {
|
|
|
64
64
|
*/
|
|
65
65
|
function createObjectState(config, instance, opts = {}) {
|
|
66
66
|
const noop = () => { };
|
|
67
|
-
return newObjectState(config, instance, undefined, opts.onBlur || noop);
|
|
67
|
+
return newObjectState(config, undefined, instance, undefined, opts.onBlur || noop);
|
|
68
68
|
}
|
|
69
69
|
exports.createObjectState = createObjectState;
|
|
70
|
-
function newObjectState(config, instance, key, onBlur) {
|
|
70
|
+
function newObjectState(config, parentState, instance, key, onBlur) {
|
|
71
71
|
// This is what we return, but we only know it's value until we call `observable`, so
|
|
72
72
|
// we create a mutable variable to capture it so that we can create fields/call their
|
|
73
73
|
// constructors and give them a way to access it later.
|
|
@@ -92,7 +92,7 @@ function newObjectState(config, instance, key, onBlur) {
|
|
|
92
92
|
if (!instance[key]) {
|
|
93
93
|
instance[key] = {};
|
|
94
94
|
}
|
|
95
|
-
field = newObjectState(config.config, instance[key], key, onBlur);
|
|
95
|
+
field = newObjectState(config.config, getObjectState, instance[key], key, onBlur);
|
|
96
96
|
}
|
|
97
97
|
else {
|
|
98
98
|
throw new Error("Unsupported");
|
|
@@ -143,9 +143,19 @@ function newObjectState(config, instance, key, onBlur) {
|
|
|
143
143
|
get valid() {
|
|
144
144
|
return getFields(this).every((f) => f.valid);
|
|
145
145
|
},
|
|
146
|
+
get errors() {
|
|
147
|
+
return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
|
|
148
|
+
},
|
|
146
149
|
get dirty() {
|
|
147
150
|
return getFields(this).some((f) => f.dirty);
|
|
148
151
|
},
|
|
152
|
+
get isNewEntity() {
|
|
153
|
+
const idField = getFields(this).find((f) => f._isIdKey);
|
|
154
|
+
if (!idField && parentState) {
|
|
155
|
+
return parentState().isNewEntity;
|
|
156
|
+
}
|
|
157
|
+
return !idField || idField.value === null || idField.value === undefined;
|
|
158
|
+
},
|
|
149
159
|
canSave() {
|
|
150
160
|
this.touched = true;
|
|
151
161
|
return this.valid;
|
|
@@ -153,7 +163,7 @@ function newObjectState(config, instance, key, onBlur) {
|
|
|
153
163
|
// Accepts new values in bulk, i.e. when setting the form initial state from the backend.
|
|
154
164
|
set(value) {
|
|
155
165
|
if (this.readOnly) {
|
|
156
|
-
throw new Error("
|
|
166
|
+
throw new Error(`${key || "formState"} is currently readOnly`);
|
|
157
167
|
}
|
|
158
168
|
getFields(this).forEach((field) => {
|
|
159
169
|
if (field.key in value && (!field.dirty || !field._focused)) {
|
|
@@ -267,6 +277,9 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
267
277
|
get required() {
|
|
268
278
|
return this.rules.some((rule) => rule === exports.required);
|
|
269
279
|
},
|
|
280
|
+
get isNewEntity() {
|
|
281
|
+
return parentState().isNewEntity;
|
|
282
|
+
},
|
|
270
283
|
focus() {
|
|
271
284
|
this._focused = true;
|
|
272
285
|
},
|
|
@@ -283,9 +296,9 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
283
296
|
this.touched = true;
|
|
284
297
|
onBlur();
|
|
285
298
|
},
|
|
286
|
-
set(value) {
|
|
287
|
-
if (this.readOnly) {
|
|
288
|
-
throw new Error(
|
|
299
|
+
set(value, opts = {}) {
|
|
300
|
+
if (this.readOnly && !opts.resetting) {
|
|
301
|
+
throw new Error(`${key} is currently readOnly`);
|
|
289
302
|
}
|
|
290
303
|
// If the user has deleted/emptied a value that was originally set, keep it as `null`
|
|
291
304
|
// so that our partial update to the backend correctly unsets it.
|
|
@@ -298,11 +311,8 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
298
311
|
_tick.value++;
|
|
299
312
|
},
|
|
300
313
|
reset() {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// using a `isReadOnlyKey` has made an entity read-only.
|
|
304
|
-
if (!computed && !this.readOnly) {
|
|
305
|
-
this.set(this.originalValue);
|
|
314
|
+
if (!computed) {
|
|
315
|
+
this.set(this.originalValue, { resetting: true });
|
|
306
316
|
}
|
|
307
317
|
this.touched = false;
|
|
308
318
|
},
|
|
@@ -356,6 +366,9 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
356
366
|
get required() {
|
|
357
367
|
return this.rules.some((rule) => rule === exports.required);
|
|
358
368
|
},
|
|
369
|
+
get isNewEntity() {
|
|
370
|
+
return parentState().isNewEntity;
|
|
371
|
+
},
|
|
359
372
|
// private
|
|
360
373
|
hasNewItems() {
|
|
361
374
|
const currentList = this.value;
|
|
@@ -374,7 +387,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
374
387
|
// Because we're reading from this.value, child will be the proxy version
|
|
375
388
|
let childState = rowMap.get(child);
|
|
376
389
|
if (!childState) {
|
|
377
|
-
childState = newObjectState(config, child, undefined, onBlur);
|
|
390
|
+
childState = newObjectState(config, parentState, child, undefined, onBlur);
|
|
378
391
|
rowMap.set(child, childState);
|
|
379
392
|
}
|
|
380
393
|
return childState;
|
|
@@ -420,9 +433,9 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
420
433
|
this.touched = true;
|
|
421
434
|
onBlur();
|
|
422
435
|
},
|
|
423
|
-
set(values) {
|
|
424
|
-
if (this.readOnly) {
|
|
425
|
-
throw new Error(
|
|
436
|
+
set(values, opts = {}) {
|
|
437
|
+
if (this.readOnly && !opts.resetting) {
|
|
438
|
+
throw new Error(`${key} is currently readOnly`);
|
|
426
439
|
}
|
|
427
440
|
// We should be passed values that are non-proxies.
|
|
428
441
|
parentInstance[key] = values.map((value) => {
|
|
@@ -459,7 +472,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
459
472
|
},
|
|
460
473
|
reset() {
|
|
461
474
|
if (originalCopy) {
|
|
462
|
-
this.set(originalCopy);
|
|
475
|
+
this.set(originalCopy, { resetting: true });
|
|
463
476
|
this.rows.forEach((r) => r.reset());
|
|
464
477
|
}
|
|
465
478
|
},
|
package/dist/formState.test.js
CHANGED
|
@@ -31,6 +31,8 @@ describe("formState", () => {
|
|
|
31
31
|
a.title.value = null;
|
|
32
32
|
expect(a.title.valid).toBeFalsy();
|
|
33
33
|
expect(numErrors).toEqual(1);
|
|
34
|
+
expect(a.title.errors).toEqual(["Required"]);
|
|
35
|
+
expect(a.errors).toEqual(["title: Required"]);
|
|
34
36
|
});
|
|
35
37
|
it("can tell what is required", () => {
|
|
36
38
|
const a = formState_1.createObjectState({
|
|
@@ -149,8 +151,12 @@ describe("formState", () => {
|
|
|
149
151
|
// Then it's title is invalid
|
|
150
152
|
expect(a1.books.rows.length).toEqual(1);
|
|
151
153
|
expect(a1.books.rows[0].title.valid).toBeFalsy();
|
|
154
|
+
expect(a1.books.rows[0].title.errors).toEqual(["Required"]);
|
|
152
155
|
// And the books collection itself is invalid
|
|
153
156
|
expect(a1.books.valid).toBeFalsy();
|
|
157
|
+
// But we don't currently show the error here b/c it's not an error on the collection itself;
|
|
158
|
+
// we could have like an `a1.books.allErrors` property instead.
|
|
159
|
+
expect(a1.books.errors).toEqual([]);
|
|
154
160
|
// And the author itself is also invalid
|
|
155
161
|
expect(a1.valid).toBeFalsy();
|
|
156
162
|
expect(lastValid).toBeFalsy();
|
|
@@ -403,6 +409,43 @@ describe("formState", () => {
|
|
|
403
409
|
expect(a1.dirty).toBeFalsy();
|
|
404
410
|
expect(a1.touched).toBeFalsy();
|
|
405
411
|
});
|
|
412
|
+
it("resets values even if read only", () => {
|
|
413
|
+
// Given a form state
|
|
414
|
+
const a1 = createAuthorInputState({
|
|
415
|
+
firstName: "a1",
|
|
416
|
+
lastName: "aL1",
|
|
417
|
+
books: [
|
|
418
|
+
{ title: "b1", classification: formStateDomain_1.dd100 },
|
|
419
|
+
{ title: "b2", classification: formStateDomain_1.dd100 },
|
|
420
|
+
],
|
|
421
|
+
});
|
|
422
|
+
// And some values have been changed
|
|
423
|
+
expect(a1.dirty).toBeFalsy();
|
|
424
|
+
a1.firstName.set("a2");
|
|
425
|
+
a1.firstName.touched = true;
|
|
426
|
+
a1.lastName.set("aL2");
|
|
427
|
+
a1.books.rows[0].set({ title: "b2" });
|
|
428
|
+
a1.books.rows[1].set({ title: "bb2" });
|
|
429
|
+
a1.books.add({ title: "b3" });
|
|
430
|
+
expect(a1.books.touched).toEqual(true);
|
|
431
|
+
expect(a1.dirty).toBeTruthy();
|
|
432
|
+
// And the "readOnly=true" operation has "beat" the reset operation
|
|
433
|
+
a1.readOnly = true;
|
|
434
|
+
// When we reset
|
|
435
|
+
a1.reset();
|
|
436
|
+
// Then it still works
|
|
437
|
+
expect(a1.firstName.value).toBe("a1");
|
|
438
|
+
expect(a1.firstName.touched).toBeFalsy();
|
|
439
|
+
expect(a1.lastName.value).toBe("aL1");
|
|
440
|
+
expect(a1.books.rows.length).toBe(2);
|
|
441
|
+
expect(a1.books.touched).toBe(false);
|
|
442
|
+
expect(a1.books.rows[0].title.value).toBe("b1");
|
|
443
|
+
expect(a1.books.rows[0].title.dirty).toBe(false);
|
|
444
|
+
expect(a1.books.rows[0].title.touched).toBe(false);
|
|
445
|
+
expect(a1.books.rows[1].title.value).toBe("b2");
|
|
446
|
+
expect(a1.dirty).toBeFalsy();
|
|
447
|
+
expect(a1.touched).toBeFalsy();
|
|
448
|
+
});
|
|
406
449
|
it("saves values into _originalState", () => {
|
|
407
450
|
const a1 = createAuthorInputState({
|
|
408
451
|
firstName: "a1",
|
|
@@ -490,7 +533,7 @@ describe("formState", () => {
|
|
|
490
533
|
a1.readOnly = true;
|
|
491
534
|
fields.forEach((f) => expect(f.readOnly).toBeTruthy());
|
|
492
535
|
fields.forEach((f) => {
|
|
493
|
-
expect(() => f.set(null)).toThrow("
|
|
536
|
+
expect(() => f.set(null)).toThrow("is currently readOnly");
|
|
494
537
|
});
|
|
495
538
|
});
|
|
496
539
|
it("maintain field readOnly state when form is readOnly", () => {
|
|
@@ -953,6 +996,23 @@ describe("formState", () => {
|
|
|
953
996
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
954
997
|
expect(r.baseElement).toHaveTextContent("bob");
|
|
955
998
|
});
|
|
999
|
+
it("memoizes on init.input being an array", async () => {
|
|
1000
|
+
const config = { firstName: { type: "value" } };
|
|
1001
|
+
function TestComponent() {
|
|
1002
|
+
const [, setTick] = react_1.useState(0);
|
|
1003
|
+
const form = formState_1.useFormState({
|
|
1004
|
+
config,
|
|
1005
|
+
init: { input: ["a", "b"], map: ([a, b]) => ({ firstName: a + b }) },
|
|
1006
|
+
});
|
|
1007
|
+
const onClick = () => [form.firstName.set("fred"), setTick(1)];
|
|
1008
|
+
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "change", onClick: onClick }, void 0),
|
|
1009
|
+
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: form.firstName.value }), void 0)] }, void 0));
|
|
1010
|
+
}
|
|
1011
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
1012
|
+
expect(r.firstName()).toHaveTextContent("ab");
|
|
1013
|
+
rtl_utils_1.click(r.change);
|
|
1014
|
+
expect(r.firstName()).toHaveTextContent("fred");
|
|
1015
|
+
});
|
|
956
1016
|
it("uses default if init.input is undefined", async () => {
|
|
957
1017
|
// Given a component
|
|
958
1018
|
function TestComponent() {
|
|
@@ -1022,6 +1082,47 @@ describe("formState", () => {
|
|
|
1022
1082
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
1023
1083
|
expect(r.baseElement.textContent).toEqual("");
|
|
1024
1084
|
});
|
|
1085
|
+
it("provides isNewEntity", () => {
|
|
1086
|
+
// Given an author without an id
|
|
1087
|
+
const formState = formState_1.createObjectState(authorWithAddressConfig, {
|
|
1088
|
+
firstName: "f",
|
|
1089
|
+
address: { city: "c1", street: "s1" },
|
|
1090
|
+
});
|
|
1091
|
+
// Then their fields know the entity is new
|
|
1092
|
+
expect(formState.firstName.isNewEntity).toBeTruthy();
|
|
1093
|
+
expect(formState.address.city.isNewEntity).toBeTruthy();
|
|
1094
|
+
// When we have an id
|
|
1095
|
+
formState.id.set("1");
|
|
1096
|
+
// Then their fields know the entity is not new
|
|
1097
|
+
expect(formState.firstName.isNewEntity).toBeFalsy();
|
|
1098
|
+
expect(formState.address.city.isNewEntity).toBeFalsy();
|
|
1099
|
+
});
|
|
1100
|
+
it("provides isNewEntity for lists", () => {
|
|
1101
|
+
// Given an author & books without an id
|
|
1102
|
+
const formState = formState_1.createObjectState(authorWithBooksConfig, {
|
|
1103
|
+
books: [{ title: "t1" }, { title: "t2" }],
|
|
1104
|
+
});
|
|
1105
|
+
// Then both books know they are new
|
|
1106
|
+
expect(formState.books.rows[0].title.isNewEntity).toBeTruthy();
|
|
1107
|
+
expect(formState.books.rows[1].title.isNewEntity).toBeTruthy();
|
|
1108
|
+
expect(formState.books.rows[0].isNewEntity).toBeTruthy();
|
|
1109
|
+
expect(formState.books.rows[1].isNewEntity).toBeTruthy();
|
|
1110
|
+
expect(formState.books.isNewEntity).toBeTruthy();
|
|
1111
|
+
// And when the author is not new, only it changes
|
|
1112
|
+
formState.id.set("1");
|
|
1113
|
+
expect(formState.books.rows[0].title.isNewEntity).toBeTruthy();
|
|
1114
|
+
expect(formState.books.rows[1].title.isNewEntity).toBeTruthy();
|
|
1115
|
+
expect(formState.books.rows[0].isNewEntity).toBeTruthy();
|
|
1116
|
+
expect(formState.books.rows[1].isNewEntity).toBeTruthy();
|
|
1117
|
+
expect(formState.books.isNewEntity).toBeFalsy();
|
|
1118
|
+
// And when the 1st book is not new, only it changes
|
|
1119
|
+
formState.books.rows[0].id.set("2");
|
|
1120
|
+
expect(formState.books.rows[0].title.isNewEntity).toBeFalsy();
|
|
1121
|
+
expect(formState.books.rows[1].title.isNewEntity).toBeTruthy();
|
|
1122
|
+
expect(formState.books.rows[0].isNewEntity).toBeFalsy();
|
|
1123
|
+
expect(formState.books.rows[1].isNewEntity).toBeTruthy();
|
|
1124
|
+
expect(formState.books.isNewEntity).toBeFalsy();
|
|
1125
|
+
});
|
|
1025
1126
|
});
|
|
1026
1127
|
class ObservableObject {
|
|
1027
1128
|
constructor() {
|