@homebound/form-state 2.6.0 → 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>>;
@@ -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.6.0",
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",