@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 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 && (!field.dirty || !field._focused)) {
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 (typeof this.value === "string") {
318
- this.set(this.value.trim());
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 refreshed values if we're already dirty
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 currentList = this.value;
415
- const a = (currentList || []).every((e) => (originalCopy || []).includes(e));
416
- const b = (originalCopy || []).every((e) => (currentList || []).includes(e));
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;
@@ -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: refreshData }, void 0),
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 onBlur for fields in list that were initially undefined", async () => {
1326
- const onBlur = jest.fn();
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: () => onBlur(),
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": "blur", onClick: () => form.books.rows[0].title.blur() }, void 0),
1340
- jsx_runtime_1.jsx("button", { "data-testid": "blurNew", onClick: () => form.books.rows[1].title.blur() }, void 0)] }, void 0)) }, void 0));
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.blur);
1348
- // Then expect onBlur to triggered
1349
- expect(onBlur).toBeCalledTimes(1);
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.blurNew);
1354
- // Then expect onBlur to be triggered again
1355
- expect(onBlur).toBeCalledTimes(2);
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" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.2.5",
3
+ "version": "2.2.9",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",