@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 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 { firstName, firstName_dirty, firstName_touched, save } = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
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 { firstName_original, set, save } = await rtl_utils_1.render(jsx_runtime_1.jsx(FormStateApp_1.FormStateApp, {}, void 0));
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
  });
@@ -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("Currently readOnly");
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("Currently readOnly");
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
- // We check !this.readOnly b/c set will blow up, but maybe we should pass
302
- // an internal override to allow it anyway? Currently this is failing when
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("Currently readOnly");
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
  },
@@ -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("Currently readOnly");
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.0.1",
3
+ "version": "2.1.2",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",