@homebound/form-state 2.5.1 → 2.7.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,9 +1,54 @@
1
1
  # form-state
2
2
 
3
- Push release.
3
+ form-state is a headless form state management library, built on top of mobx.
4
+
5
+ It acts as a buffer between the canonical data/entity (i.e. the server-side data, or local Redux/GraphQL/etc. cache data) and the user's WIP data/entity that is being actively mutated in form fields.
6
+
7
+ It also keeps track of low-level form UX details like:
8
+
9
+ * 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)
12
+ * Enabling/disabling buttons/form UX based on the overall form-wide state
13
+ * Submitting the form should touch (validate) all fields
14
+ * 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
16
+ * Building a wire payload that has only changed fields
17
+ * Handles children, i.e. a `author: { books: [...} }` will include only changed books if necessary
18
+
19
+ # The Three Type/Shapes Mental Model
20
+
21
+ In general when working with forms (e.g. not just form-state), there are three types/shapes of data involved:
22
+
23
+ 1. The input data/shape from the server (i.e. a GraphQL/REST query)
24
+ 2. The form data/shape that is being reactively bound to form fields (i.e. used as `<TextField value=form.firstName onChange=(v) => form.firstName = v />`)
25
+ 3. The mutation data/shape that will submit the change to the server (i.e. the GraphQL mutation/REST POST)
26
+
27
+ form-state generally refers to each of these shapes as:
28
+
29
+ * The input type
30
+ * (Hrm, in retrospect "input" is an unfortunate term b/c that is what GraphQL uses for its mutation types, i.e. `input SaveAuthorInput`...we should consider changing this).
31
+ * The form type
32
+ * The ...third type...
33
+
34
+ And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
35
+
36
+
37
+ Admittedly (and hopefully, b/c it makes the code simpler), the differences between each of these types can often be small, i.e.:
38
+
39
+ * The input type might have `{ author: { book: { id: "b:1" } }` but the mutation wants `{ author: { bookId: "b:1" } }`
40
+ * ...have other examples...
41
+
42
+ These are usually simple/mechanistic changes, but nonetheless just some boilerplate that form-state provides conventions for.
43
+
44
+ # Basic Usage
45
+
46
+ See the [sample](https://github.com/homebound-team/form-state/blob/main/src/FormStateApp.tsx).
4
47
 
5
48
  # Todo
6
49
 
7
50
  - Add conditional readonly logic, like `{ type: "field", readOnlyIf: i => i.isInternal.value }`
8
51
 
9
52
  - 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)
53
+
54
+ - Undo/redo would in theory be neat and easy to do on top of the existing infra
@@ -87,6 +87,23 @@ export interface SetOpts {
87
87
  /** Whether this `set` should trigger an auto-save; defaults to true. */
88
88
  autoSave?: boolean;
89
89
  }
90
+ /** Internal `.set` opts for conditionally that form-state internally percolates. */
91
+ interface InternalSetOpts extends SetOpts {
92
+ /** `resetting` is the code calling `.reset()` to revert to original values. */
93
+ resetting?: boolean;
94
+ /** `refreshing` is when `useFormState` sees a new value. */
95
+ refreshing?: boolean;
96
+ }
97
+ export interface FieldStateInternal<T, V> extends FieldState<T, V> {
98
+ set(value: V, opts?: InternalSetOpts): void;
99
+ _isIdKey: boolean;
100
+ _isDeleteKey: boolean;
101
+ _isReadOnlyKey: boolean;
102
+ _focused: boolean;
103
+ }
104
+ export declare type ObjectStateInternal<T, P = any> = ObjectState<T, P> & {
105
+ set(value: P, opts?: InternalSetOpts): void;
106
+ };
90
107
  /** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
91
108
  export interface ListFieldState<T, U> extends Omit<FieldState<T, U[]>, "originalValue"> {
92
109
  readonly rows: ReadonlyArray<ObjectState<U>>;
package/dist/formState.js CHANGED
@@ -431,10 +431,22 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
431
431
  },
432
432
  get changedValue() {
433
433
  const result = [];
434
- const pushAll = listConfig.update !== "incremental";
434
+ const hasOpKey = Object.keys(listConfig.config).includes("op");
435
+ const hasLegacyOpKey = Object.keys(listConfig.config).some((key) => key === "delete" || key === "remove");
436
+ const incremental = listConfig.update === "incremental" ||
437
+ // Implicitly enable incremental mode if we see an op key
438
+ (listConfig.update === undefined && (hasOpKey || hasLegacyOpKey));
439
+ const exhaustive = !incremental;
435
440
  this.rows.forEach((r) => {
436
- if (pushAll || r.dirty) {
437
- result.push(r.changedValue);
441
+ var _a;
442
+ var _b;
443
+ if (exhaustive || r.dirty || r.isNewEntity) {
444
+ const changed = r.changedValue;
445
+ // Ensure we have an `op: include` key, following https://joist-orm.io/docs/features/partial-update-apis
446
+ if (incremental && hasOpKey) {
447
+ (_a = (_b = changed).op) !== null && _a !== void 0 ? _a : (_b.op = "include");
448
+ }
449
+ result.push(changed);
438
450
  }
439
451
  });
440
452
  return result;
@@ -512,7 +524,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
512
524
  this.rows.forEach((r) => {
513
525
  r.save();
514
526
  });
515
- originalCopy = parentInstance[key];
527
+ originalCopy = [...(parentInstance[key] || [])];
516
528
  _tick.value++;
517
529
  },
518
530
  ensureSet() {
@@ -979,6 +979,81 @@ describe("formState", () => {
979
979
  books: [{ id: "b:1", title: "t1b" }],
980
980
  });
981
981
  });
982
+ it("uses the op key as a hint to use incremental list behavior", () => {
983
+ // Given an author
984
+ const formState = formState_1.createObjectState({
985
+ id: { type: "value" },
986
+ // And the books collection is not explicitly marked as incremental
987
+ books: {
988
+ type: "list",
989
+ config: {
990
+ id: { type: "value" },
991
+ title: { type: "value", rules: [formState_1.required] },
992
+ // But it does have an op key
993
+ op: { type: "value" },
994
+ },
995
+ },
996
+ }, {
997
+ id: "a:1",
998
+ firstName: "f",
999
+ books: [
1000
+ // And the books start out as included
1001
+ { id: "b:1", title: "t1", op: "include" },
1002
+ { id: "b:2", title: "t2", op: "include" },
1003
+ ],
1004
+ });
1005
+ // And initially nothing is changed
1006
+ expect(formState.changedValue).toEqual({ id: "a:1" });
1007
+ // When we delete the 1st book
1008
+ formState.books.rows[0].op.value = "delete";
1009
+ // Then only the 1st book is included
1010
+ expect(formState.changedValue).toEqual({
1011
+ id: "a:1",
1012
+ books: [{ id: "b:1", op: "delete" }],
1013
+ });
1014
+ });
1015
+ it("defaults the incremental op key to included when not set", () => {
1016
+ // Given an author
1017
+ const formState = formState_1.createObjectState({
1018
+ id: { type: "value" },
1019
+ // And the books collection is not explicitly marked as incremental
1020
+ books: {
1021
+ type: "list",
1022
+ config: {
1023
+ id: { type: "value" },
1024
+ title: { type: "value", rules: [formState_1.required] },
1025
+ // But it does have an op key
1026
+ op: { type: "value" },
1027
+ },
1028
+ },
1029
+ }, {
1030
+ id: "a:1",
1031
+ firstName: "f",
1032
+ books: [
1033
+ // And no op keys are initially set
1034
+ { id: "b:1", title: "t1" },
1035
+ { id: "b:2", title: "t2" },
1036
+ ],
1037
+ });
1038
+ // And initially nothing is changed
1039
+ expect(formState.changedValue).toEqual({ id: "a:1" });
1040
+ // When we delete the 1st book
1041
+ formState.books.rows[0].op.value = "delete";
1042
+ // And add a 3rd book
1043
+ formState.books.add({ title: "t3" });
1044
+ // Then the 3rd book doesn't have an explicit op yet
1045
+ expect(formState.books.rows[2].op.value).toBeUndefined();
1046
+ // But when we create changedValue
1047
+ expect(formState.changedValue).toEqual({
1048
+ id: "a:1",
1049
+ books: [
1050
+ // Then the deleted book is included
1051
+ { id: "b:1", op: "delete" },
1052
+ // And the 3rd book is included w/the `op: include` flag added
1053
+ { title: "t3", op: "include" },
1054
+ ],
1055
+ });
1056
+ });
982
1057
  it("can observe value changes", () => {
983
1058
  const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f" });
984
1059
  let ticks = 0;
@@ -1145,6 +1220,23 @@ describe("formState", () => {
1145
1220
  expect(formState.books.rows[1].isNewEntity).toBeTruthy();
1146
1221
  expect(formState.books.isNewEntity).toBeFalsy();
1147
1222
  });
1223
+ it("can properly evaluate dirty lists after calling 'save'", () => {
1224
+ // Given an empty list state
1225
+ const a1 = createAuthorInputState({ books: [] });
1226
+ expect(a1.books.dirty).toBeFalsy();
1227
+ // When adding a book
1228
+ a1.books.add({ title: "t2" });
1229
+ // Then the list state is dirty
1230
+ expect(a1.books.dirty).toBeTruthy();
1231
+ // When saving the form state
1232
+ a1.save();
1233
+ // Then the list is no longer dirty
1234
+ expect(a1.books.dirty).toBeFalsy();
1235
+ // When adding a new book
1236
+ a1.books.add({ title: "t3" });
1237
+ // The list is again dirty
1238
+ expect(a1.books.dirty).toBeTruthy();
1239
+ });
1148
1240
  });
1149
1241
  class ObservableObject {
1150
1242
  constructor() {
@@ -26,6 +26,7 @@ export interface BookInput {
26
26
  classification?: DeweyDecimalClassification;
27
27
  delete?: boolean | null | undefined;
28
28
  isPublished?: boolean;
29
+ op?: "include" | "delete" | "remove";
29
30
  }
30
31
  export interface DeweyDecimalClassification {
31
32
  number: string;
@@ -2,8 +2,17 @@ import { ObjectConfig, ObjectState } from "./formState";
2
2
  export declare type ObjectStateCache<T, I> = Record<string, [ObjectState<T>, I]>;
3
3
  declare type UseFormStatesOpts<T, I> = {
4
4
  config: ObjectConfig<T>;
5
+ /** Fired for each individual `ObjectState` when it's had a value change. */
5
6
  autoSave?: (state: ObjectState<T>) => Promise<void>;
7
+ /**
8
+ * A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
9
+ *
10
+ * Does not need to be stable/useMemo'd.
11
+ */
12
+ addRules?: (state: ObjectState<T>) => void;
13
+ /** Given an input to `getObjectState`, returns the identity value that we'll cache that value's form state on. */
6
14
  getId: (v: I) => string;
15
+ /** Maps an input to `getObjectState` to the actual form shape `T`. */
7
16
  map?: (input: Exclude<I, null | undefined>) => T;
8
17
  };
9
18
  export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
@@ -5,7 +5,7 @@ const react_1 = require("react");
5
5
  const formState_1 = require("./formState");
6
6
  const utils_1 = require("./utils");
7
7
  function useFormStates(opts) {
8
- const { config, autoSave, getId, map } = opts;
8
+ const { config, autoSave, getId, map, addRules } = opts;
9
9
  const objectStateCache = react_1.useMemo(() => ({}), [config]);
10
10
  // Keep track of ObjectStates that triggered auto-save when a save was already in progress.
11
11
  const pendingAutoSaves = react_1.useRef([]);
@@ -33,18 +33,20 @@ function useFormStates(opts) {
33
33
  return {
34
34
  getObjectState: (input) => {
35
35
  const existing = objectStateCache[getId(input)];
36
- const form = existing
37
- ? existing[0]
38
- : formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
36
+ let form = existing === null || existing === void 0 ? void 0 : existing[0];
37
+ // If it didn't exist, then add to the cache.
38
+ if (!form) {
39
+ form = formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
39
40
  maybeAutoSave: () => maybeAutoSave(form),
40
41
  });
41
- // If it didn't exist, then add to the cache.
42
- if (!existing) {
42
+ if (addRules) {
43
+ addRules(form);
44
+ }
43
45
  objectStateCache[getId(input)] = [form, input];
44
46
  }
45
47
  // If the source of truth changed, then update the existing state and return it.
46
48
  if (existing && existing[1] !== input) {
47
- existing[0].set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
49
+ form.set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
48
50
  existing[1] = input;
49
51
  }
50
52
  return form;
@@ -133,4 +133,25 @@ describe("useFormStates", () => {
133
133
  // Then the two form-states are no longer equal.
134
134
  expect(r.statesEqual()).toHaveTextContent("false");
135
135
  });
136
+ it("calls addRules once per form state", async () => {
137
+ // Given a user wants to use addRules
138
+ const addRules = jest.fn();
139
+ function TestComponent() {
140
+ const config = { id: { type: "value" }, firstName: { type: "value" } };
141
+ const { getObjectState } = useFormStates_1.useFormStates({
142
+ config,
143
+ addRules,
144
+ getId: (o) => o.id,
145
+ });
146
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0),
147
+ jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0)] }, void 0));
148
+ }
149
+ // When we render
150
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
151
+ // Then addRules was only called once
152
+ expect(addRules).toHaveBeenCalledTimes(1);
153
+ });
136
154
  });
155
+ function ChildComponent({ os }) {
156
+ return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
157
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -41,6 +41,7 @@
41
41
  "devDependencies": {
42
42
  "@babel/core": "^7.13.1",
43
43
  "@homebound/rtl-utils": "^2.59.2",
44
+ "@semantic-release/exec": "^5.0.0",
44
45
  "@semantic-release/git": "^9.0.0",
45
46
  "@storybook/addon-essentials": "^6.2.9",
46
47
  "@storybook/addon-info": "^5.3.21",