@homebound/form-state 2.2.6 → 2.2.10

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 && !opts.refreshing && !opts.resetting && this.dirty) {
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) {
@@ -541,7 +569,10 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
541
569
  _tick.value++;
542
570
  },
543
571
  };
544
- return list;
572
+ return mobx_1.makeAutoObservable(list, {
573
+ // See other makeAutoObservable comment
574
+ value: mobx_1.computed({ equals: () => false }),
575
+ });
545
576
  }
546
577
  function isNotUndefined(value) {
547
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,7 +334,7 @@ describe("formState", () => {
321
334
  a1.books.remove(0);
322
335
  expect(a1.dirty).toBeFalsy();
323
336
  });
324
- it("it calls onBlur when adding or removing to a list", () => {
337
+ it("calls onBlur when adding or removing to a list", () => {
325
338
  const onBlur = jest.fn();
326
339
  const a1 = createAuthorInputState({ books: [] }, onBlur);
327
340
  expect(a1.books.dirty).toBeFalsy();
@@ -331,6 +344,60 @@ describe("formState", () => {
331
344
  expect(a1.dirty).toBeFalsy();
332
345
  expect(onBlur).toBeCalledTimes(2);
333
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
+ // And when we set a nested value
356
+ a1.books.rows[0].title.value = "title";
357
+ // Then we called onBlur again
358
+ expect(onBlur).toBeCalledTimes(2);
359
+ });
360
+ it("defers calling onBlur when setting a bound value", () => {
361
+ const onBlur = jest.fn();
362
+ // Given an author listening for blur
363
+ const a1 = createAuthorInputState({ books: [{}] }, onBlur);
364
+ // And the field is focused
365
+ a1.firstName.focus();
366
+ // When we we set the field
367
+ a1.firstName.value = "first";
368
+ // Then we don't call onBlur
369
+ expect(onBlur).toBeCalledTimes(0);
370
+ });
371
+ it("skips onBlur when refreshing", () => {
372
+ const onBlur = jest.fn();
373
+ // Given an author listening for blur
374
+ const a1 = createAuthorInputState({ books: [{}] }, onBlur);
375
+ // When we programmatically set a field that isn't focused
376
+ a1.set({ firstName: "first" }, { refreshing: true });
377
+ // Then we don't call onBlur
378
+ expect(onBlur).toBeCalledTimes(0);
379
+ });
380
+ it("skips onBlur when resetting", () => {
381
+ const onBlur = jest.fn();
382
+ // Given an author listening for blur
383
+ const a1 = createAuthorInputState({ books: [{}] }, onBlur);
384
+ // And we called onBlur once
385
+ a1.set({ firstName: "first" });
386
+ expect(onBlur).toBeCalledTimes(1);
387
+ // When we reset
388
+ a1.reset();
389
+ // We don't call blur again
390
+ expect(onBlur).toBeCalledTimes(1);
391
+ });
392
+ it("skips onBlur when not dirty", () => {
393
+ const onBlur = jest.fn();
394
+ // Given an author listening for blur
395
+ const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, onBlur);
396
+ // When we programmatically set a field to it's existing valued
397
+ a1.firstName.value = "first";
398
+ // Then we don't call onBlur
399
+ expect(onBlur).toBeCalledTimes(0);
400
+ });
334
401
  it("knows list of primitives are dirty", () => {
335
402
  const a1 = formState_1.createObjectState({ favoriteColors: { type: "value" } }, {});
336
403
  expect(a1.favoriteColors.dirty).toBeFalsy();
@@ -931,7 +998,7 @@ describe("formState", () => {
931
998
  // And they have wip edits
932
999
  formState.firstName.set("ff");
933
1000
  // When a mutation result sets both firstName and lastName
934
- formState.set({ firstName: "f2", lastName: "l2" });
1001
+ formState.set({ firstName: "f2", lastName: "l2" }, { refreshing: true });
935
1002
  // Then we don't overwrite the user's WIP work
936
1003
  expect(formState.firstName.value).toEqual("ff");
937
1004
  expect(formState.lastName.value).toEqual("l2");
@@ -944,13 +1011,14 @@ describe("formState", () => {
944
1011
  // Given first name is focused, but has not changed
945
1012
  formState.firstName.focus();
946
1013
  // When a mutation result sets both firstName and lastName
947
- formState.set({ firstName: "f2", lastName: "l2" });
1014
+ formState.set({ firstName: "f2", lastName: "l2" }, { refreshing: true });
948
1015
  // Then we do update the value
949
1016
  expect(formState.firstName.value).toEqual("f2");
950
1017
  });
951
1018
  it("trims string values on blur", () => {
952
1019
  const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
953
1020
  // Given the user is typing with spaces
1021
+ formState.firstName.focus();
954
1022
  formState.firstName.set("f ");
955
1023
  // And we initially keep the space
956
1024
  expect(formState.firstName.value).toEqual("f ");
@@ -962,6 +1030,7 @@ describe("formState", () => {
962
1030
  it("trims empty values to undefined on blur", () => {
963
1031
  const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
964
1032
  // Given the user is typing with only spaces
1033
+ formState.firstName.focus();
965
1034
  formState.firstName.set(" ");
966
1035
  // And we initially keep the space
967
1036
  expect(formState.firstName.value).toEqual(" ");
@@ -1197,6 +1266,18 @@ describe("formState", () => {
1197
1266
  { id: "b:3", title: "b3" },
1198
1267
  ],
1199
1268
  };
1269
+ // Eventually our local values are saved
1270
+ const data3 = {
1271
+ id: "a:1",
1272
+ firstName: "local",
1273
+ lastName: "l2",
1274
+ address: { id: "address:1", street: "local", city: "c2" },
1275
+ books: [
1276
+ { id: "b:1", title: "local" },
1277
+ { id: "b:2", title: "b2" },
1278
+ { id: "b:3", title: "b3" },
1279
+ ],
1280
+ };
1200
1281
  // And we start out with data1
1201
1282
  const [data, setData] = react_1.useState(data1);
1202
1283
  const form = formState_1.useFormState({ config, init: { input: data, map: (d) => d } });
@@ -1205,9 +1286,6 @@ describe("formState", () => {
1205
1286
  form.address.street.value = "local";
1206
1287
  form.books.rows[0].title.value = "local";
1207
1288
  }
1208
- function refreshData() {
1209
- setData(data2);
1210
- }
1211
1289
  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),
1212
1290
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "lastName" }, { children: form.lastName.value }), void 0),
1213
1291
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "street" }, { children: form.address.street.value }), void 0),
@@ -1216,7 +1294,8 @@ describe("formState", () => {
1216
1294
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "title2" }, { children: form.books.rows[1].title.value }), void 0),
1217
1295
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "booksLength" }, { children: form.books.rows.length }), void 0),
1218
1296
  jsx_runtime_1.jsx("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }, void 0),
1219
- jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: refreshData }, void 0),
1297
+ jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: () => setData(data2) }, void 0),
1298
+ jsx_runtime_1.jsx("button", { "data-testid": "saveData", onClick: () => setData(data3) }, void 0),
1220
1299
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "changedValue" }, { children: JSON.stringify(form.changedValue) }), void 0)] }, void 0)) }, void 0));
1221
1300
  }
1222
1301
  // And we start out with the initial query data
@@ -1253,6 +1332,12 @@ describe("formState", () => {
1253
1332
  books: [{ id: "b:1", title: "local" }, { id: "b:2" }, { id: "b:3" }],
1254
1333
  firstName: "local",
1255
1334
  });
1335
+ // And then when our mutation results come back
1336
+ rtl_utils_1.click(r.saveData);
1337
+ // Then changedValue doesn't show our local changes anymore
1338
+ expect(JSON.parse(r.changedValue().textContent)).toEqual({
1339
+ id: "a:1",
1340
+ });
1256
1341
  });
1257
1342
  it("useFormState can accept new data while read only", async () => {
1258
1343
  // Given a component
@@ -1332,8 +1417,8 @@ describe("formState", () => {
1332
1417
  expect(r.firstName().textContent).toEqual("f2");
1333
1418
  expect(r.fullName().textContent).toEqual("f2 l2");
1334
1419
  });
1335
- it("can trigger onBlur for fields in list that were initially undefined", async () => {
1336
- const onBlur = jest.fn();
1420
+ it("can trigger auto save for fields in list that were initially undefined", async () => {
1421
+ const autoSave = jest.fn();
1337
1422
  // Given a component
1338
1423
  function TestComponent() {
1339
1424
  // When the data is initially undefined
@@ -1342,27 +1427,30 @@ describe("formState", () => {
1342
1427
  const form = formState_1.useFormState({
1343
1428
  config: authorWithBooksConfig,
1344
1429
  init: { input: data, map: (d) => d, ifUndefined: { books: [] } },
1345
- autoSave: () => onBlur(),
1430
+ autoSave,
1346
1431
  });
1347
1432
  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),
1348
1433
  jsx_runtime_1.jsx("button", { "data-testid": "add", onClick: () => form.books.add({ title: "New Book" }) }, void 0),
1349
- jsx_runtime_1.jsx("button", { "data-testid": "blur", onClick: () => form.books.rows[0].title.blur() }, void 0),
1350
- jsx_runtime_1.jsx("button", { "data-testid": "blurNew", onClick: () => form.books.rows[1].title.blur() }, void 0)] }, void 0)) }, void 0));
1434
+ jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => form.books.rows[0].title.blur() }, void 0),
1435
+ jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => form.books.rows[1].title.blur() }, void 0)] }, void 0)) }, void 0));
1351
1436
  }
1352
1437
  // Given a formState with `onBlur` set
1353
1438
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
1354
1439
  // When the data/child is now available
1355
1440
  rtl_utils_1.click(r.refreshData);
1356
1441
  // And the field is blurred
1357
- rtl_utils_1.click(r.blur);
1358
- // Then expect onBlur to triggered
1359
- expect(onBlur).toBeCalledTimes(1);
1442
+ rtl_utils_1.click(r.blurBookOne);
1443
+ // Then we don't auto-save because nothing has changed
1444
+ expect(autoSave).toBeCalledTimes(0);
1360
1445
  // And when adding a new book
1361
1446
  rtl_utils_1.click(r.add);
1447
+ // We autoSave the new row right away (because we don't have any validation rules
1448
+ // that say the new row can't be empty)
1449
+ expect(autoSave).toBeCalledTimes(1);
1362
1450
  // And the new book is blurred
1363
- rtl_utils_1.click(r.blurNew);
1364
- // Then expect onBlur to be triggered again
1365
- expect(onBlur).toBeCalledTimes(3);
1451
+ rtl_utils_1.click(r.blurBookTwo);
1452
+ // Then we auto save again
1453
+ expect(autoSave).toBeCalledTimes(2);
1366
1454
  });
1367
1455
  });
1368
1456
  class ObservableObject {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.2.6",
3
+ "version": "2.2.10",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",