@homebound/form-state 2.2.5 → 2.2.9
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/formState.js +45 -12
- package/dist/formState.test.js +83 -19
- package/package.json +1 -1
package/dist/formState.js
CHANGED
|
@@ -180,7 +180,7 @@ function newObjectState(config, parentState, instance, key, onBlur) {
|
|
|
180
180
|
throw new Error(`${key || "formState"} is currently readOnly`);
|
|
181
181
|
}
|
|
182
182
|
getFields(this).forEach((field) => {
|
|
183
|
-
if (field.key in value
|
|
183
|
+
if (field.key in value) {
|
|
184
184
|
field.set(value[field.key], opts);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
@@ -314,11 +314,8 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
314
314
|
},
|
|
315
315
|
blur() {
|
|
316
316
|
// Now that the user is done editing the field, we sneak in some trim logic
|
|
317
|
-
if (
|
|
318
|
-
this.
|
|
319
|
-
if (this.value === "") {
|
|
320
|
-
this.set(undefined);
|
|
321
|
-
}
|
|
317
|
+
if (this._focused) {
|
|
318
|
+
this.maybeTrim();
|
|
322
319
|
}
|
|
323
320
|
this._focused = false;
|
|
324
321
|
// touched is readonly, but we're allowed to change it
|
|
@@ -329,8 +326,10 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
329
326
|
if (this.readOnly && !opts.resetting && !opts.refreshing) {
|
|
330
327
|
throw new Error(`${key} is currently readOnly`);
|
|
331
328
|
}
|
|
332
|
-
if (opts.refreshing && this.dirty) {
|
|
333
|
-
// Ignore
|
|
329
|
+
if (opts.refreshing && this.dirty && this.value !== value) {
|
|
330
|
+
// Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
|
|
331
|
+
// matches the incoming value (value), b/c if it does we should accept it and reset originalValue
|
|
332
|
+
// so that we're not longer dirty.
|
|
334
333
|
return;
|
|
335
334
|
}
|
|
336
335
|
else if (computed && (opts.resetting || opts.refreshing)) {
|
|
@@ -349,6 +348,11 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
349
348
|
if (opts.refreshing) {
|
|
350
349
|
this.originalValue = newValue;
|
|
351
350
|
}
|
|
351
|
+
// If we're being set programmatically, i.e. we don't currently have focus,
|
|
352
|
+
// call blur to trigger any auto-saves.
|
|
353
|
+
if (!this._focused) {
|
|
354
|
+
this.blur();
|
|
355
|
+
}
|
|
352
356
|
},
|
|
353
357
|
reset() {
|
|
354
358
|
if (!computed) {
|
|
@@ -373,6 +377,17 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
373
377
|
_originalValue = v;
|
|
374
378
|
_originalValueTick.value++;
|
|
375
379
|
},
|
|
380
|
+
maybeTrim() {
|
|
381
|
+
if (typeof this.value === "string") {
|
|
382
|
+
let newValue = this.value.trim();
|
|
383
|
+
if (newValue === "") {
|
|
384
|
+
newValue = undefined;
|
|
385
|
+
}
|
|
386
|
+
if (newValue !== this.value) {
|
|
387
|
+
this.set(newValue);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
376
391
|
};
|
|
377
392
|
return field;
|
|
378
393
|
}
|
|
@@ -411,9 +426,18 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
411
426
|
},
|
|
412
427
|
// private
|
|
413
428
|
hasNewItems() {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
429
|
+
const [current, original] = [this.value || [], originalCopy || []];
|
|
430
|
+
// Instead of relying on just object identities, we look up each child's state
|
|
431
|
+
// in rowMap, because we already dedup/check object identity (i.e. look for id fields)
|
|
432
|
+
// when create object states.
|
|
433
|
+
const a = current.every((e) => {
|
|
434
|
+
const state = rowMap.get(e);
|
|
435
|
+
return original.some((e) => rowMap.get(e) === state);
|
|
436
|
+
});
|
|
437
|
+
const b = original.every((e) => {
|
|
438
|
+
const state = rowMap.get(e);
|
|
439
|
+
return current.some((e) => rowMap.get(e) === state);
|
|
440
|
+
});
|
|
417
441
|
const isSame = a && b;
|
|
418
442
|
return !isSame;
|
|
419
443
|
},
|
|
@@ -496,6 +520,10 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
496
520
|
// Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
|
|
497
521
|
return childState.value;
|
|
498
522
|
});
|
|
523
|
+
// Reset originalCopy so that our dirty checks have the right # of rows.
|
|
524
|
+
if (opts.refreshing) {
|
|
525
|
+
originalCopy = [...(parentInstance[key] || [])];
|
|
526
|
+
}
|
|
499
527
|
_tick.value++;
|
|
500
528
|
},
|
|
501
529
|
add(value, spliceIndex) {
|
|
@@ -505,6 +533,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
505
533
|
this.ensureSet();
|
|
506
534
|
this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
|
|
507
535
|
_tick.value++;
|
|
536
|
+
onBlur();
|
|
508
537
|
},
|
|
509
538
|
remove(indexOrValue) {
|
|
510
539
|
this.ensureSet();
|
|
@@ -518,6 +547,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
518
547
|
}
|
|
519
548
|
}
|
|
520
549
|
_tick.value++;
|
|
550
|
+
onBlur();
|
|
521
551
|
},
|
|
522
552
|
reset() {
|
|
523
553
|
if (originalCopy) {
|
|
@@ -539,7 +569,10 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
539
569
|
_tick.value++;
|
|
540
570
|
},
|
|
541
571
|
};
|
|
542
|
-
return list
|
|
572
|
+
return mobx_1.makeAutoObservable(list, {
|
|
573
|
+
// See other makeAutoObservable comment
|
|
574
|
+
value: mobx_1.computed({ equals: () => false }),
|
|
575
|
+
});
|
|
543
576
|
}
|
|
544
577
|
function isNotUndefined(value) {
|
|
545
578
|
return value !== undefined;
|
package/dist/formState.test.js
CHANGED
|
@@ -117,6 +117,19 @@ describe("formState", () => {
|
|
|
117
117
|
expect(state.books.rows[2].originalValue === b1).toEqual(true);
|
|
118
118
|
expect(ticks).toEqual(3);
|
|
119
119
|
});
|
|
120
|
+
it("list value can observe changes", () => {
|
|
121
|
+
const b1 = { title: "t1" };
|
|
122
|
+
const a1 = { firstName: "a1", books: [b1] };
|
|
123
|
+
const state = createAuthorInputState(a1);
|
|
124
|
+
let books;
|
|
125
|
+
let ticks = 0;
|
|
126
|
+
mobx_1.autorun(() => {
|
|
127
|
+
books = state.books.value;
|
|
128
|
+
ticks++;
|
|
129
|
+
});
|
|
130
|
+
state.books.add({ title: "t2" }, 0);
|
|
131
|
+
expect(ticks).toEqual(2);
|
|
132
|
+
});
|
|
120
133
|
it("maintains unknown fields", () => {
|
|
121
134
|
// Given the form is not directly editing id fields
|
|
122
135
|
const config = {
|
|
@@ -321,6 +334,36 @@ describe("formState", () => {
|
|
|
321
334
|
a1.books.remove(0);
|
|
322
335
|
expect(a1.dirty).toBeFalsy();
|
|
323
336
|
});
|
|
337
|
+
it("calls onBlur when adding or removing to a list", () => {
|
|
338
|
+
const onBlur = jest.fn();
|
|
339
|
+
const a1 = createAuthorInputState({ books: [] }, onBlur);
|
|
340
|
+
expect(a1.books.dirty).toBeFalsy();
|
|
341
|
+
a1.books.add({ title: "t2" });
|
|
342
|
+
expect(a1.books.dirty).toBeTruthy();
|
|
343
|
+
a1.books.remove(0);
|
|
344
|
+
expect(a1.dirty).toBeFalsy();
|
|
345
|
+
expect(onBlur).toBeCalledTimes(2);
|
|
346
|
+
});
|
|
347
|
+
it("calls onBlur when programmatically setting a value", () => {
|
|
348
|
+
const onBlur = jest.fn();
|
|
349
|
+
// Given an author listening for blur
|
|
350
|
+
const a1 = createAuthorInputState({ books: [{}] }, onBlur);
|
|
351
|
+
// When we programmatically set a field that isn't focused
|
|
352
|
+
a1.firstName.value = "first";
|
|
353
|
+
// Then we call onBlur
|
|
354
|
+
expect(onBlur).toBeCalledTimes(1);
|
|
355
|
+
});
|
|
356
|
+
it("defers calling onBlur when setting a bound value", () => {
|
|
357
|
+
const onBlur = jest.fn();
|
|
358
|
+
// Given an author listening for blur
|
|
359
|
+
const a1 = createAuthorInputState({ books: [{}] }, onBlur);
|
|
360
|
+
// And the field is focused
|
|
361
|
+
a1.firstName.focus();
|
|
362
|
+
// When we we set the field
|
|
363
|
+
a1.firstName.value = "first";
|
|
364
|
+
// Then we don't call onBlur
|
|
365
|
+
expect(onBlur).toBeCalledTimes(0);
|
|
366
|
+
});
|
|
324
367
|
it("knows list of primitives are dirty", () => {
|
|
325
368
|
const a1 = formState_1.createObjectState({ favoriteColors: { type: "value" } }, {});
|
|
326
369
|
expect(a1.favoriteColors.dirty).toBeFalsy();
|
|
@@ -921,7 +964,7 @@ describe("formState", () => {
|
|
|
921
964
|
// And they have wip edits
|
|
922
965
|
formState.firstName.set("ff");
|
|
923
966
|
// When a mutation result sets both firstName and lastName
|
|
924
|
-
formState.set({ firstName: "f2", lastName: "l2" });
|
|
967
|
+
formState.set({ firstName: "f2", lastName: "l2" }, { refreshing: true });
|
|
925
968
|
// Then we don't overwrite the user's WIP work
|
|
926
969
|
expect(formState.firstName.value).toEqual("ff");
|
|
927
970
|
expect(formState.lastName.value).toEqual("l2");
|
|
@@ -934,13 +977,14 @@ describe("formState", () => {
|
|
|
934
977
|
// Given first name is focused, but has not changed
|
|
935
978
|
formState.firstName.focus();
|
|
936
979
|
// When a mutation result sets both firstName and lastName
|
|
937
|
-
formState.set({ firstName: "f2", lastName: "l2" });
|
|
980
|
+
formState.set({ firstName: "f2", lastName: "l2" }, { refreshing: true });
|
|
938
981
|
// Then we do update the value
|
|
939
982
|
expect(formState.firstName.value).toEqual("f2");
|
|
940
983
|
});
|
|
941
984
|
it("trims string values on blur", () => {
|
|
942
985
|
const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
|
|
943
986
|
// Given the user is typing with spaces
|
|
987
|
+
formState.firstName.focus();
|
|
944
988
|
formState.firstName.set("f ");
|
|
945
989
|
// And we initially keep the space
|
|
946
990
|
expect(formState.firstName.value).toEqual("f ");
|
|
@@ -952,6 +996,7 @@ describe("formState", () => {
|
|
|
952
996
|
it("trims empty values to undefined on blur", () => {
|
|
953
997
|
const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
|
|
954
998
|
// Given the user is typing with only spaces
|
|
999
|
+
formState.firstName.focus();
|
|
955
1000
|
formState.firstName.set(" ");
|
|
956
1001
|
// And we initially keep the space
|
|
957
1002
|
expect(formState.firstName.value).toEqual(" ");
|
|
@@ -1187,6 +1232,18 @@ describe("formState", () => {
|
|
|
1187
1232
|
{ id: "b:3", title: "b3" },
|
|
1188
1233
|
],
|
|
1189
1234
|
};
|
|
1235
|
+
// Eventually our local values are saved
|
|
1236
|
+
const data3 = {
|
|
1237
|
+
id: "a:1",
|
|
1238
|
+
firstName: "local",
|
|
1239
|
+
lastName: "l2",
|
|
1240
|
+
address: { id: "address:1", street: "local", city: "c2" },
|
|
1241
|
+
books: [
|
|
1242
|
+
{ id: "b:1", title: "local" },
|
|
1243
|
+
{ id: "b:2", title: "b2" },
|
|
1244
|
+
{ id: "b:3", title: "b3" },
|
|
1245
|
+
],
|
|
1246
|
+
};
|
|
1190
1247
|
// And we start out with data1
|
|
1191
1248
|
const [data, setData] = react_1.useState(data1);
|
|
1192
1249
|
const form = formState_1.useFormState({ config, init: { input: data, map: (d) => d } });
|
|
@@ -1195,9 +1252,6 @@ describe("formState", () => {
|
|
|
1195
1252
|
form.address.street.value = "local";
|
|
1196
1253
|
form.books.rows[0].title.value = "local";
|
|
1197
1254
|
}
|
|
1198
|
-
function refreshData() {
|
|
1199
|
-
setData(data2);
|
|
1200
|
-
}
|
|
1201
1255
|
return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: form.firstName.value }), void 0),
|
|
1202
1256
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "lastName" }, { children: form.lastName.value }), void 0),
|
|
1203
1257
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "street" }, { children: form.address.street.value }), void 0),
|
|
@@ -1206,7 +1260,8 @@ describe("formState", () => {
|
|
|
1206
1260
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "title2" }, { children: form.books.rows[1].title.value }), void 0),
|
|
1207
1261
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "booksLength" }, { children: form.books.rows.length }), void 0),
|
|
1208
1262
|
jsx_runtime_1.jsx("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }, void 0),
|
|
1209
|
-
jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick:
|
|
1263
|
+
jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: () => setData(data2) }, void 0),
|
|
1264
|
+
jsx_runtime_1.jsx("button", { "data-testid": "saveData", onClick: () => setData(data3) }, void 0),
|
|
1210
1265
|
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "changedValue" }, { children: JSON.stringify(form.changedValue) }), void 0)] }, void 0)) }, void 0));
|
|
1211
1266
|
}
|
|
1212
1267
|
// And we start out with the initial query data
|
|
@@ -1243,6 +1298,12 @@ describe("formState", () => {
|
|
|
1243
1298
|
books: [{ id: "b:1", title: "local" }, { id: "b:2" }, { id: "b:3" }],
|
|
1244
1299
|
firstName: "local",
|
|
1245
1300
|
});
|
|
1301
|
+
// And then when our mutation results come back
|
|
1302
|
+
rtl_utils_1.click(r.saveData);
|
|
1303
|
+
// Then changedValue doesn't show our local changes anymore
|
|
1304
|
+
expect(JSON.parse(r.changedValue().textContent)).toEqual({
|
|
1305
|
+
id: "a:1",
|
|
1306
|
+
});
|
|
1246
1307
|
});
|
|
1247
1308
|
it("useFormState can accept new data while read only", async () => {
|
|
1248
1309
|
// Given a component
|
|
@@ -1322,8 +1383,8 @@ describe("formState", () => {
|
|
|
1322
1383
|
expect(r.firstName().textContent).toEqual("f2");
|
|
1323
1384
|
expect(r.fullName().textContent).toEqual("f2 l2");
|
|
1324
1385
|
});
|
|
1325
|
-
it("can trigger
|
|
1326
|
-
const
|
|
1386
|
+
it("can trigger auto save for fields in list that were initially undefined", async () => {
|
|
1387
|
+
const autoSave = jest.fn();
|
|
1327
1388
|
// Given a component
|
|
1328
1389
|
function TestComponent() {
|
|
1329
1390
|
// When the data is initially undefined
|
|
@@ -1332,27 +1393,30 @@ describe("formState", () => {
|
|
|
1332
1393
|
const form = formState_1.useFormState({
|
|
1333
1394
|
config: authorWithBooksConfig,
|
|
1334
1395
|
init: { input: data, map: (d) => d, ifUndefined: { books: [] } },
|
|
1335
|
-
autoSave
|
|
1396
|
+
autoSave,
|
|
1336
1397
|
});
|
|
1337
1398
|
return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: () => setData(data2) }, void 0),
|
|
1338
1399
|
jsx_runtime_1.jsx("button", { "data-testid": "add", onClick: () => form.books.add({ title: "New Book" }) }, void 0),
|
|
1339
|
-
jsx_runtime_1.jsx("button", { "data-testid": "
|
|
1340
|
-
jsx_runtime_1.jsx("button", { "data-testid": "
|
|
1400
|
+
jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => form.books.rows[0].title.blur() }, void 0),
|
|
1401
|
+
jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => form.books.rows[1].title.blur() }, void 0)] }, void 0)) }, void 0));
|
|
1341
1402
|
}
|
|
1342
1403
|
// Given a formState with `onBlur` set
|
|
1343
1404
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
1344
1405
|
// When the data/child is now available
|
|
1345
1406
|
rtl_utils_1.click(r.refreshData);
|
|
1346
1407
|
// And the field is blurred
|
|
1347
|
-
rtl_utils_1.click(r.
|
|
1348
|
-
// Then
|
|
1349
|
-
expect(
|
|
1408
|
+
rtl_utils_1.click(r.blurBookOne);
|
|
1409
|
+
// Then we don't auto-save because nothing has changed
|
|
1410
|
+
expect(autoSave).toBeCalledTimes(0);
|
|
1350
1411
|
// And when adding a new book
|
|
1351
1412
|
rtl_utils_1.click(r.add);
|
|
1413
|
+
// We autoSave the new row right away (because we don't have any validation rules
|
|
1414
|
+
// that say the new row can't be empty)
|
|
1415
|
+
expect(autoSave).toBeCalledTimes(1);
|
|
1352
1416
|
// And the new book is blurred
|
|
1353
|
-
rtl_utils_1.click(r.
|
|
1354
|
-
// Then
|
|
1355
|
-
expect(
|
|
1417
|
+
rtl_utils_1.click(r.blurBookTwo);
|
|
1418
|
+
// Then we auto save again
|
|
1419
|
+
expect(autoSave).toBeCalledTimes(2);
|
|
1356
1420
|
});
|
|
1357
1421
|
});
|
|
1358
1422
|
class ObservableObject {
|
|
@@ -1386,8 +1450,8 @@ const authorWithBooksConfig = {
|
|
|
1386
1450
|
},
|
|
1387
1451
|
},
|
|
1388
1452
|
};
|
|
1389
|
-
function createAuthorInputState(input) {
|
|
1390
|
-
return formState_1.createObjectState(authorWithBooksConfig, input);
|
|
1453
|
+
function createAuthorInputState(input, onBlur) {
|
|
1454
|
+
return formState_1.createObjectState(authorWithBooksConfig, input, { onBlur });
|
|
1391
1455
|
}
|
|
1392
1456
|
const authorWithAddressConfig = {
|
|
1393
1457
|
id: { type: "value" },
|