@homebound/form-state 2.13.0 → 2.14.0

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
@@ -1,3 +1,6 @@
1
+ ![npm](https://img.shields.io/npm/v/@homebound/form-state)
2
+ [![CircleCI](https://circleci.com/gh/homebound-team/form-state.svg?style=svg)](https://circleci.com/gh/homebound-team/form-state)
3
+
1
4
  # form-state
2
5
 
3
6
  form-state is a headless form state management library, built on top of mobx.
@@ -7,14 +10,18 @@ It acts as a buffer between the canonical data/entity (i.e. the server-side data
7
10
  It also keeps track of low-level form UX details like:
8
11
 
9
12
  * Which form fields are dirty
10
- * Which forms fields are valid/invalid
11
- * Which forms fields are touched (i.e. don't show validation errors for untouched fields)
13
+ * Which form fields are valid/invalid
14
+ * Which form fields are touched (i.e. don't show validation errors for untouched fields)
12
15
  * Enabling/disabling buttons/form UX based on the overall form-wide state
13
16
  * Submitting the form should touch (validate) all fields
14
17
  * Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
15
- * Queue and debounce auto-saves if one is already in-flight
18
+ * Queueing auto-saves if one is already in-flight
19
+ * Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
20
+ * Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
16
21
  * Building a wire payload that has only changed fields
17
- * Handles children, i.e. a `author: { books: [...} }` will include only changed books if necessary
22
+ * `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
23
+ * Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
24
+ * Child collections can be either exhausive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
18
25
 
19
26
  # The Three Type/Shapes Mental Model
20
27
 
package/dist/config.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare type ValueFieldConfig<T, V> = {
37
37
  computed?: boolean;
38
38
  /** Marks a field as being initially read-only, i.e. `field.readOnly = true/false` can change this default. */
39
39
  readOnly?: boolean;
40
+ /** Marks an array field to be order agnostic when determining changed/dirty states */
41
+ strictOrder?: false;
40
42
  };
41
43
  /** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
42
44
  export declare type ListFieldConfig<T, U> = {
@@ -29,11 +29,12 @@ function newObjectState(config, parentState, parentListState, instance, key, may
29
29
  return proxy;
30
30
  }
31
31
  const fieldStates = Object.entries(config).map(([_key, _config]) => {
32
+ var _a;
32
33
  const key = _key;
33
34
  const config = _config;
34
35
  let field;
35
36
  if (config.type === "value") {
36
- field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
37
+ field = valueField_1.newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, (_a = config.strictOrder) !== null && _a !== void 0 ? _a : true, maybeAutoSave);
37
38
  }
38
39
  else if (config.type === "list") {
39
40
  field = listField_1.newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
@@ -56,4 +56,4 @@ export interface FieldStateInternal<T, V> extends FieldState<T, V> {
56
56
  _isReadOnlyKey: boolean;
57
57
  _focused: boolean;
58
58
  }
59
- export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
59
+ export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T, T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T, T[K] | null | undefined>;
@@ -5,7 +5,7 @@ const is_plain_object_1 = require("is-plain-object");
5
5
  const mobx_1 = require("mobx");
6
6
  const rules_1 = require("../rules");
7
7
  const utils_1 = require("../utils");
8
- function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
8
+ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
9
9
  // keep a copy here for reference equality
10
10
  const value = parentInstance[key];
11
11
  let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
@@ -35,7 +35,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
35
35
  this.set(v);
36
36
  },
37
37
  get dirty() {
38
- return !utils_1.areEqual(this.originalValue, this.value);
38
+ return !utils_1.areEqual(this.originalValue, this.value, strictOrder);
39
39
  },
40
40
  /** Returns whether this field is readOnly, although if our parent is readOnly then it trumps. */
41
41
  get readOnly() {
@@ -106,7 +106,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
106
106
  const coerceEmptyList = value && value instanceof Array && value.length === 0 && utils_1.isEmpty(this.originalValue);
107
107
  const newValue = keepNull ? null : utils_1.isEmpty(value) || coerceEmptyList ? undefined : value;
108
108
  // Set the value on our parent object
109
- const changed = !utils_1.areEqual(newValue, this.value);
109
+ const changed = !utils_1.areEqual(newValue, this.value, strictOrder);
110
110
  parentInstance[key] = newValue;
111
111
  _tick.value++;
112
112
  if (opts.refreshing) {
@@ -487,6 +487,24 @@ describe("formState", () => {
487
487
  a1.favoriteColors.set([]);
488
488
  expect(a1.dirty).toBeFalsy();
489
489
  });
490
+ it("can evaluate arrays to be equal even if order of options are different from original when strictOrder = false", () => {
491
+ // Given an array field value where we define `strictOrder` as false
492
+ const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value", strictOrder: false } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
493
+ expect(a1.favoriteColors.dirty).toBeFalsy();
494
+ // When changing the order of the values
495
+ a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
496
+ // Then expect the field to not be dirty
497
+ expect(a1.dirty).toBeFalsy();
498
+ });
499
+ it("evaluates arrays to be equal based on order by default", () => {
500
+ // Given an array field value
501
+ const a1 = objectField_1.createObjectState({ favoriteColors: { type: "value" } }, { favoriteColors: [formStateDomain_1.Color.Red, formStateDomain_1.Color.Blue] });
502
+ expect(a1.favoriteColors.dirty).toBeFalsy();
503
+ // When we change the order of the values
504
+ a1.favoriteColors.set([formStateDomain_1.Color.Blue, formStateDomain_1.Color.Red]);
505
+ // Then the field should be dirty
506
+ expect(a1.dirty).toBeTruthy();
507
+ });
490
508
  it("knows originally unset fields are dirty", () => {
491
509
  // Given firstName is purposefully not set when originally initialized
492
510
  const a1 = createAuthorInputState({});
package/dist/utils.d.ts CHANGED
@@ -37,7 +37,7 @@ export declare function isEmpty(value: any): boolean;
37
37
  * - objects that implement `toJSON`
38
38
  *
39
39
  */
40
- export declare function areEqual<T>(a?: T, b?: T): boolean;
40
+ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolean;
41
41
  export declare function hasToJSON(o?: unknown): o is {
42
42
  toJSON(): void;
43
43
  };
package/dist/utils.js CHANGED
@@ -102,7 +102,7 @@ exports.isEmpty = isEmpty;
102
102
  * - objects that implement `toJSON`
103
103
  *
104
104
  */
105
- function areEqual(a, b) {
105
+ function areEqual(a, b, strictOrder) {
106
106
  if (is_plain_object_1.isPlainObject(a)) {
107
107
  return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
108
108
  }
@@ -112,7 +112,12 @@ function areEqual(a, b) {
112
112
  return fast_deep_equal_1.default(a1, b1);
113
113
  }
114
114
  if (a && b && a instanceof Array && b instanceof Array) {
115
- return fast_deep_equal_1.default(a, b);
115
+ if (strictOrder !== false) {
116
+ return fast_deep_equal_1.default(a, b);
117
+ }
118
+ if (a.length !== b.length)
119
+ return false;
120
+ return a.every((a1) => b.some((b1) => fast_deep_equal_1.default(a1, b1)));
116
121
  }
117
122
  return a === b;
118
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",