@homebound/form-state 2.7.0 → 2.9.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.
@@ -1,2 +1,6 @@
1
1
  /// <reference types="react" />
2
+ import { FieldState } from "./index";
2
3
  export declare function FormStateApp(): JSX.Element;
4
+ export declare function TextField(props: {
5
+ field: FieldState<any, string | null | undefined>;
6
+ }): JSX.Element;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.FormStateApp = void 0;
3
+ exports.TextField = exports.FormStateApp = void 0;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
5
  const mobx_react_1 = require("mobx-react");
6
6
  const index_1 = require("./index");
@@ -91,3 +91,4 @@ function TextField(props) {
91
91
  jsx_runtime_1.jsx("td", Object.assign({ "data-testid": `${field.key}_errors` }, { children: field.errors }), void 0),
92
92
  jsx_runtime_1.jsx("td", Object.assign({ "data-testid": `${field.key}_original` }, { children: field.originalValue }), void 0)] }, void 0) }, void 0)] }), void 0)] }, void 0)) }, void 0));
93
93
  }
94
+ exports.TextField = TextField;
@@ -12,7 +12,6 @@ let isAutoSaving = false;
12
12
  // `pendingAutoSave` is a flag for determining if we need to immediately call `maybeAutoSave` again after the initial Promise finishes
13
13
  // This could happen if a field triggers auto-save while another field's auto-save is already in progress.
14
14
  let pendingAutoSave = false;
15
- let timerId;
16
15
  /**
17
16
  * Creates a formState instance for editing in a form.
18
17
  */
@@ -39,7 +38,7 @@ function useFormState(opts) {
39
38
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
40
39
  isAutoSaving = "queued";
41
40
  // We use setTimeout as a cheap way to wait until the end of the current event listener
42
- timerId = setTimeout(async () => {
41
+ setTimeout(async () => {
43
42
  try {
44
43
  // We technically don't flip to in-flight until after the call in case the
45
44
  // user's autoSave function itself wants to call a .set.
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const jsx_runtime_1 = require("react/jsx-runtime");
4
4
  const rtl_utils_1 = require("@homebound/rtl-utils");
5
5
  const react_1 = require("@testing-library/react");
6
+ const mobx_1 = require("mobx");
6
7
  const mobx_react_1 = require("mobx-react");
7
8
  const react_2 = require("react");
9
+ const FormStateApp_1 = require("./FormStateApp");
8
10
  const formState_1 = require("./formState");
9
11
  const useFormState_1 = require("./useFormState");
10
12
  describe("useFormState", () => {
@@ -425,6 +427,33 @@ describe("useFormState", () => {
425
427
  expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
426
428
  expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
427
429
  });
430
+ it("calls autoSave with results of calculations in addRules", async () => {
431
+ const autoSaveStub = jest.fn();
432
+ const config = {
433
+ id: { type: "value" },
434
+ firstName: { type: "value" },
435
+ lastName: { type: "value" },
436
+ };
437
+ // Given a component that is using autoSave
438
+ function TestComponent() {
439
+ const fs = useFormState_1.useFormState({
440
+ config,
441
+ addRules(fs) {
442
+ // And also has calculated values
443
+ mobx_1.reaction(() => fs.firstName.value, (curr) => (fs.lastName.value = curr));
444
+ },
445
+ autoSave: (fs) => autoSaveStub(fs.changedValue),
446
+ init: { id: "a:1" },
447
+ });
448
+ return jsx_runtime_1.jsx(FormStateApp_1.TextField, { field: fs.firstName }, void 0);
449
+ }
450
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
451
+ // When the user sets one field
452
+ await rtl_utils_1.typeAndWait(r.firstName, "first");
453
+ // Then we only called autoSave once
454
+ expect(autoSaveStub).toBeCalledTimes(1);
455
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "first", lastName: "first" });
456
+ });
428
457
  });
429
458
  const authorConfig = {
430
459
  id: { type: "value" },
@@ -1,21 +1,41 @@
1
1
  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
+ /**
5
+ * The config to use for each form state.
6
+ *
7
+ * Should be stable/useMemo'd.
8
+ */
4
9
  config: ObjectConfig<T>;
5
- /** Fired for each individual `ObjectState` when it's had a value change. */
10
+ /**
11
+ * Fired for each individual `ObjectState` when it's had a value change.
12
+ *
13
+ * Does not need to be stable/useMemo'd.
14
+ */
6
15
  autoSave?: (state: ObjectState<T>) => Promise<void>;
7
16
  /**
8
17
  * A hook to add custom, cross-field validation rules that can be difficult to setup directly in the config DSL.
9
18
  *
19
+ * This will be called once-per `ObjectState` instance, and so is effectively a `useEffect` hook with
20
+ * a `[config, objectState]` dependency.
21
+ *
10
22
  * Does not need to be stable/useMemo'd.
11
23
  */
12
24
  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. */
25
+ /**
26
+ * Given an input to `getFormState`, returns the identity value that we'll cache that value's form state on.
27
+ *
28
+ * Does not need to be stable/useMemo'd.
29
+ */
14
30
  getId: (v: I) => string;
15
- /** Maps an input to `getObjectState` to the actual form shape `T`. */
31
+ /**
32
+ * Maps an input to `getFormState` to the actual form shape `T`.
33
+ *
34
+ * Does not need to be stable/useMemo'd.
35
+ */
16
36
  map?: (input: Exclude<I, null | undefined>) => T;
17
37
  };
18
- export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
19
- getObjectState: (input: I) => ObjectState<T>;
38
+ export declare function useFormStates<T, I = T>(opts: UseFormStatesOpts<T, I>): {
39
+ getFormState: (input: I) => ObjectState<T>;
20
40
  };
21
41
  export {};
@@ -6,7 +6,10 @@ const formState_1 = require("./formState");
6
6
  const utils_1 = require("./utils");
7
7
  function useFormStates(opts) {
8
8
  const { config, autoSave, getId, map, addRules } = opts;
9
- const objectStateCache = react_1.useMemo(() => ({}), [config]);
9
+ const objectStateCache = react_1.useMemo(() => ({}),
10
+ // Force resetting the cache if config changes
11
+ // eslint-disable-next-line react-hooks/exhaustive-deps
12
+ [config]);
10
13
  // Keep track of ObjectStates that triggered auto-save when a save was already in progress.
11
14
  const pendingAutoSaves = react_1.useRef([]);
12
15
  // Use a ref so our memo'ized `autoSave` always see the latest value
@@ -20,6 +23,13 @@ function useFormStates(opts) {
20
23
  if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
21
24
  try {
22
25
  isAutoSaving = true;
26
+ // See if we have any reactions that want to run (i.e. added by addRules hooks)
27
+ await new Promise((resolve) => setTimeout(resolve, 0));
28
+ // If a reaction re-queued our form during the ^ wait, remove it
29
+ let i = -1;
30
+ while ((i = pendingAutoSaves.current.indexOf(form)) !== -1) {
31
+ pendingAutoSaves.current.splice(i, 1);
32
+ }
23
33
  await autoSaveRef.current(form);
24
34
  }
25
35
  finally {
@@ -30,28 +40,30 @@ function useFormStates(opts) {
30
40
  }
31
41
  }
32
42
  }
33
- return {
34
- getObjectState: (input) => {
35
- const existing = objectStateCache[getId(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), {
40
- maybeAutoSave: () => maybeAutoSave(form),
41
- });
42
- if (addRules) {
43
- addRules(form);
44
- }
45
- objectStateCache[getId(input)] = [form, input];
46
- }
47
- // If the source of truth changed, then update the existing state and return it.
48
- if (existing && existing[1] !== input) {
49
- form.set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
50
- existing[1] = input;
43
+ const getFormState = react_1.useCallback((input) => {
44
+ const existing = objectStateCache[getId(input)];
45
+ let form = existing === null || existing === void 0 ? void 0 : existing[0];
46
+ // If it didn't exist, then add to the cache.
47
+ if (!form) {
48
+ form = formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
49
+ maybeAutoSave: () => maybeAutoSave(form),
50
+ });
51
+ if (addRules) {
52
+ addRules(form);
51
53
  }
52
- return form;
53
- },
54
- };
54
+ objectStateCache[getId(input)] = [form, input];
55
+ }
56
+ // If the source of truth changed, then update the existing state and return it.
57
+ if (existing && existing[1] !== input) {
58
+ form.set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
59
+ existing[1] = input;
60
+ }
61
+ return form;
62
+ },
63
+ // Allow the user to not stable-ize getId, map, addRules, and autoSave
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ [objectStateCache, config]);
66
+ return { getFormState };
55
67
  }
56
68
  exports.useFormStates = useFormStates;
57
69
  // If the user's autoSave hook makes some last-minute `.set` calls to sneak
@@ -2,8 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const jsx_runtime_1 = require("react/jsx-runtime");
4
4
  const rtl_utils_1 = require("@homebound/rtl-utils");
5
+ const mobx_1 = require("mobx");
5
6
  const react_1 = require("react");
6
7
  const formState_1 = require("./formState");
8
+ const FormStateApp_1 = require("./FormStateApp");
7
9
  const useFormStates_1 = require("./useFormStates");
8
10
  describe("useFormStates", () => {
9
11
  it("can lazily create form states", async () => {
@@ -14,12 +16,12 @@ describe("useFormStates", () => {
14
16
  }
15
17
  function TestComponent() {
16
18
  const config = { id: { type: "value" }, firstName: { type: "value" } };
17
- const { getObjectState } = useFormStates_1.useFormStates({
19
+ const { getFormState } = useFormStates_1.useFormStates({
18
20
  config,
19
21
  autoSave,
20
22
  getId: (o) => o.id,
21
23
  });
22
- return (jsx_runtime_1.jsx("div", { children: jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0) }, void 0));
24
+ return (jsx_runtime_1.jsx("div", { children: jsx_runtime_1.jsx(ChildComponent, { os: getFormState({ id: "a:1", firstName: "Brandon" }) }, void 0) }, void 0));
23
25
  }
24
26
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
25
27
  // And the child component has defined state
@@ -28,13 +30,13 @@ describe("useFormStates", () => {
28
30
  it("can update existing object state from cache with new values", async () => {
29
31
  const autoSave = jest.fn();
30
32
  const config = { id: { type: "value" }, firstName: { type: "value" } };
31
- // Given a component using `getObjectState` for lazily creating ObjectStates
33
+ // Given a component using `getFormState` for lazily creating ObjectStates
32
34
  function TestComponent() {
33
35
  const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon" });
34
- const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
36
+ const { getFormState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
35
37
  // Memoize an original for comparing the update against.
36
- const originalState = react_1.useMemo(() => getObjectState(apiData), []);
37
- const state = getObjectState(apiData);
38
+ const originalState = react_1.useMemo(() => getFormState(apiData), []);
39
+ const state = getFormState(apiData);
38
40
  return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
39
41
  jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
40
42
  jsx_runtime_1.jsx("button", { "data-testid": "updateApiData", onClick: () => setApiData((prevState) => ({ ...prevState, firstName: "Bob" })) }, void 0)] }, void 0));
@@ -57,13 +59,13 @@ describe("useFormStates", () => {
57
59
  firstName: { type: "value" },
58
60
  lastName: { type: "value" },
59
61
  };
60
- // Given a component using `getObjectState` for lazily creating ObjectStates
62
+ // Given a component using `getFormState` for lazily creating ObjectStates
61
63
  function TestComponent() {
62
64
  const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Tony", lastName: "Stark" });
63
65
  const [apiData2, setApiData2] = react_1.useState({ id: "a:2", firstName: "Steve", lastName: "Rogers" });
64
- const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
65
- const state = getObjectState(apiData);
66
- const state2 = getObjectState(apiData2);
66
+ const { getFormState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
67
+ const state = getFormState(apiData);
68
+ const state2 = getFormState(apiData2);
67
69
  async function autoSave(form) {
68
70
  autoSaveStub(form.changedValue);
69
71
  // Pretend to make an API call and update the local state
@@ -99,10 +101,9 @@ describe("useFormStates", () => {
99
101
  rtl_utils_1.click(r.focusSetAndSaveField2());
100
102
  // Awaits the promises for all methods triggered above
101
103
  await rtl_utils_1.wait();
102
- // Then expect the auto save to only have been called three times. Once with each set of changedValues.
103
- expect(autoSaveStub).toBeCalledTimes(3);
104
- expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
105
- expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
104
+ // Then expect the auto save to only have been called two times. Once with each set of changedValues.
105
+ expect(autoSaveStub).toBeCalledTimes(2);
106
+ expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo", lastName: "Bar" });
106
107
  expect(autoSaveStub).toBeCalledWith({ id: "a:2", lastName: "Bar" });
107
108
  });
108
109
  it("clears out cache if configuration changes", async () => {
@@ -117,10 +118,10 @@ describe("useFormStates", () => {
117
118
  };
118
119
  function TestComponent() {
119
120
  const [config, setConfig] = react_1.useState(originalConfig);
120
- const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
121
+ const { getFormState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
121
122
  // Memoize an original for comparing the update against.
122
- const originalState = react_1.useMemo(() => getObjectState(apiData), []);
123
- const state = getObjectState(apiData);
123
+ const originalState = react_1.useMemo(() => getFormState(apiData), []);
124
+ const state = getFormState(apiData);
124
125
  return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
125
126
  jsx_runtime_1.jsx("button", { "data-testid": "updateConfig", onClick: () => setConfig(updatedConfig) }, void 0)] }, void 0));
126
127
  }
@@ -138,19 +139,52 @@ describe("useFormStates", () => {
138
139
  const addRules = jest.fn();
139
140
  function TestComponent() {
140
141
  const config = { id: { type: "value" }, firstName: { type: "value" } };
141
- const { getObjectState } = useFormStates_1.useFormStates({
142
+ const { getFormState } = useFormStates_1.useFormStates({
142
143
  config,
143
144
  addRules,
144
145
  getId: (o) => o.id,
145
146
  });
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));
147
+ return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx(ChildComponent, { os: getFormState({ id: "a:1", firstName: "Brandon" }) }, void 0),
148
+ jsx_runtime_1.jsx(ChildComponent, { os: getFormState({ id: "a:1", firstName: "Brandon" }) }, void 0)] }, void 0));
148
149
  }
149
150
  // When we render
150
151
  const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
151
152
  // Then addRules was only called once
152
153
  expect(addRules).toHaveBeenCalledTimes(1);
153
154
  });
155
+ it("calls autoSave with results of calculations in addRules", async () => {
156
+ // Given a user wants to use auto save
157
+ const autoSave = jest.fn();
158
+ function TestComponent() {
159
+ const config = {
160
+ id: { type: "value" },
161
+ firstName: { type: "value" },
162
+ lastName: { type: "value" },
163
+ };
164
+ const { getFormState } = useFormStates_1.useFormStates({
165
+ config,
166
+ getId: (o) => o.id,
167
+ addRules(fs) {
168
+ // And they have a reactive true that calculates last name
169
+ mobx_1.reaction(() => fs.firstName.value, (curr) => {
170
+ console.log({ curr });
171
+ fs.lastName.set(curr);
172
+ });
173
+ },
174
+ async autoSave(fs) {
175
+ autoSave(fs.changedValue);
176
+ },
177
+ });
178
+ return jsx_runtime_1.jsx(FormStateApp_1.TextField, { field: getFormState({ id: "a:1", firstName: "Brandon" }).firstName }, void 0);
179
+ }
180
+ // When we render
181
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
182
+ // And update the firstName
183
+ await rtl_utils_1.typeAndWait(r.firstName(), "first");
184
+ // Then autoSave was called once with both input+calc'd values
185
+ expect(autoSave).toHaveBeenCalledTimes(1);
186
+ expect(autoSave).toHaveBeenCalledWith({ id: "a:1", firstName: "first", lastName: "first" });
187
+ });
154
188
  });
155
189
  function ChildComponent({ os }) {
156
190
  return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.7.0",
3
+ "version": "2.9.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",