@homebound/form-state 2.8.0 → 2.10.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;
package/dist/formState.js CHANGED
@@ -24,10 +24,10 @@ exports.required = mobx_1.action(({ value: v }) => {
24
24
  */
25
25
  function createObjectState(config, instance, opts = {}) {
26
26
  const noop = () => { };
27
- return newObjectState(config, undefined, instance, undefined, opts.maybeAutoSave || noop);
27
+ return newObjectState(config, undefined, undefined, instance, undefined, opts.maybeAutoSave || noop);
28
28
  }
29
29
  exports.createObjectState = createObjectState;
30
- function newObjectState(config, parentState, instance, key, maybeAutoSave) {
30
+ function newObjectState(config, parentState, parentListState, instance, key, maybeAutoSave) {
31
31
  // This is what we return, but we only know it's value until we call `observable`, so
32
32
  // we create a mutable variable to capture it so that we can create fields/call their
33
33
  // constructors and give them a way to access it later.
@@ -52,7 +52,7 @@ function newObjectState(config, parentState, instance, key, maybeAutoSave) {
52
52
  if (!instance[key]) {
53
53
  instance[key] = {};
54
54
  }
55
- field = newObjectState(config.config, getObjectState, instance[key], key, maybeAutoSave);
55
+ field = newObjectState(config.config, getObjectState, undefined, instance[key], key, maybeAutoSave);
56
56
  }
57
57
  else {
58
58
  throw new Error("Unsupported");
@@ -93,12 +93,13 @@ function newObjectState(config, parentState, instance, key, maybeAutoSave) {
93
93
  getFields(this).forEach((f) => (f.touched = touched));
94
94
  },
95
95
  get readOnly() {
96
- return this._readOnly || this._considerReadOnly();
96
+ return (this._readOnly ||
97
+ this._considerReadOnly() ||
98
+ (parentState && parentState().readOnly) ||
99
+ !!(parentListState === null || parentListState === void 0 ? void 0 : parentListState.readOnly));
97
100
  },
98
101
  set readOnly(readOnly) {
99
102
  this._readOnly = readOnly;
100
- // Use this.readOnly so we can _considerReadOnly
101
- getFields(this).forEach((f) => (f.readOnly = this.readOnly));
102
103
  },
103
104
  get valid() {
104
105
  return getFields(this).every((f) => f.valid);
@@ -197,8 +198,6 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
197
198
  const field = {
198
199
  key,
199
200
  touched: false,
200
- /** Configuration readOnly state. Mostly used to determine original state. */
201
- _configReadOnly: readOnly,
202
201
  /** Current readOnly value. */
203
202
  _readOnly: readOnly || false,
204
203
  _focused: false,
@@ -219,25 +218,13 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
219
218
  get dirty() {
220
219
  return !areEqual(this.originalValue, this.value);
221
220
  },
221
+ /** Returns whether this field is readOnly, although if our parent is readOnly then it trumps. */
222
222
  get readOnly() {
223
- return this._readOnly;
223
+ return parentState().readOnly || this._readOnly;
224
224
  },
225
- /**
226
- * Field readOnly is only opinionated when set.
227
- * - When set to true from FormObject, accept change since true is higher priority.
228
- * - When set to false from FormObject, use original (`_configReadOnly`) value.
229
- */
225
+ /** Sets the field readOnly, but `v` will still be or-d with the parent's readOnly state. */
230
226
  set readOnly(v) {
231
- if (this._configReadOnly === undefined) {
232
- this._readOnly = v;
233
- return;
234
- }
235
- if (v) {
236
- this._readOnly = v;
237
- }
238
- else {
239
- this._readOnly = this._configReadOnly || false;
240
- }
227
+ this._readOnly = v;
241
228
  },
242
229
  // For primitive fields, the changed value is just the value.
243
230
  get changedValue() {
@@ -356,11 +343,10 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
356
343
  _focused: false,
357
344
  _readOnly: false,
358
345
  get readOnly() {
359
- return this._readOnly;
346
+ return this._readOnly || parentState().readOnly;
360
347
  },
361
348
  set readOnly(readOnly) {
362
349
  this._readOnly = readOnly;
363
- this.rows.forEach((r) => (r.readOnly = readOnly));
364
350
  },
365
351
  set value(v) {
366
352
  this.set(v);
@@ -401,7 +387,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
401
387
  // Because we're reading from this.value, child will be the proxy version
402
388
  let childState = rowMap.get(child);
403
389
  if (!childState) {
404
- childState = newObjectState(config, parentState, child, undefined, maybeAutoSave);
390
+ childState = newObjectState(config, parentState, list, child, undefined, maybeAutoSave);
405
391
  rowMap.set(child, childState);
406
392
  }
407
393
  return childState;
@@ -686,6 +686,42 @@ describe("formState", () => {
686
686
  // Then expect form
687
687
  expect(formState.title.readOnly).toBeTruthy();
688
688
  });
689
+ it("has an object-level readOnly=true override field-level readOnly=false", () => {
690
+ // Given a top-level object
691
+ const a1 = createAuthorInputState({
692
+ firstName: "a1",
693
+ lastName: "aL1",
694
+ books: [{ title: "b1", classification: formStateDomain_1.dd100 }],
695
+ });
696
+ // And it is read-only
697
+ a1.readOnly = true;
698
+ // Then it's fields are read-only
699
+ const fields = [a1, a1.firstName, a1.books, a1.books.rows[0].title, a1.books.rows[0].classification];
700
+ fields.forEach((f) => expect(f.readOnly).toBeTruthy());
701
+ // And even if a specific field tries to _not_ be read-only,
702
+ // i.e. due to a more granular business rule that happens to
703
+ // be allowed right now
704
+ a1.firstName.readOnly = false;
705
+ // Then the field-level rule is ignored, and it's still treated as read-only
706
+ expect(a1.firstName.readOnly).toBeTruthy();
707
+ });
708
+ it("has an field-level readOnly=true override object-level readOnly=false", () => {
709
+ // Given a top-level object
710
+ const a1 = createAuthorInputState({
711
+ firstName: "a1",
712
+ lastName: "aL1",
713
+ books: [{ title: "b1", classification: formStateDomain_1.dd100 }],
714
+ });
715
+ // And the top-level form is explicitly set to read-only=false
716
+ a1.readOnly = false;
717
+ // Then it's fields are not read-only
718
+ const fields = [a1, a1.firstName, a1.books, a1.books.rows[0].title, a1.books.rows[0].classification];
719
+ fields.forEach((f) => expect(f.readOnly).toBeFalsy());
720
+ // But when one of the fields opts in to readOnly
721
+ a1.firstName.readOnly = true;
722
+ // Then the field-level rule is respected
723
+ expect(a1.firstName.readOnly).toBeTruthy();
724
+ });
689
725
  it("canSave returns dirty and touches", () => {
690
726
  const a1 = formState_1.createObjectState({
691
727
  firstName: { type: "value", rules: [formState_1.required] },
@@ -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" },
@@ -34,8 +34,15 @@ declare type UseFormStatesOpts<T, I> = {
34
34
  * Does not need to be stable/useMemo'd.
35
35
  */
36
36
  map?: (input: Exclude<I, null | undefined>) => T;
37
+ /**
38
+ * Sets all `ObjectState`s to readOnly.
39
+ */
40
+ readOnly?: boolean;
37
41
  };
38
- export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
39
- getFormState: (input: I) => ObjectState<T>;
42
+ declare type UseFormStatesHook<T, I> = {
43
+ getFormState: (input: I, opts?: {
44
+ readOnly?: boolean;
45
+ }) => ObjectState<T>;
40
46
  };
47
+ export declare function useFormStates<T, I = T>(opts: UseFormStatesOpts<T, I>): UseFormStatesHook<T, I>;
41
48
  export {};
@@ -5,37 +5,48 @@ 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, addRules } = opts;
8
+ const { config, autoSave, getId, map, addRules, readOnly = false } = opts;
9
9
  const objectStateCache = react_1.useMemo(() => ({}),
10
10
  // Force resetting the cache if config changes
11
11
  // eslint-disable-next-line react-hooks/exhaustive-deps
12
12
  [config]);
13
13
  // Keep track of ObjectStates that triggered auto-save when a save was already in progress.
14
- const pendingAutoSaves = react_1.useRef([]);
14
+ const pendingAutoSaves = react_1.useRef(new Set());
15
15
  // Use a ref so our memo'ized `autoSave` always see the latest value
16
16
  const autoSaveRef = react_1.useRef(autoSave);
17
17
  autoSaveRef.current = autoSave;
18
- async function maybeAutoSave(form) {
19
- if (isAutoSaving && !pendingAutoSaves.current.includes(form)) {
20
- pendingAutoSaves.current.push(form);
21
- }
22
- // Don't use canSave() because we don't want to set touched for all the fields
23
- if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
24
- try {
25
- isAutoSaving = true;
26
- await autoSaveRef.current(form);
27
- }
28
- finally {
29
- isAutoSaving = false;
30
- if (pendingAutoSaves.current.length > 0) {
31
- await maybeAutoSave(pendingAutoSaves.current.shift());
18
+ // Use a ref b/c we're memod
19
+ const readOnlyRef = react_1.useRef(readOnly);
20
+ readOnlyRef.current = readOnly;
21
+ const getFormState = react_1.useCallback((input, opts = {}) => {
22
+ const existing = objectStateCache[getId(input)];
23
+ let form = existing === null || existing === void 0 ? void 0 : existing[0];
24
+ async function maybeAutoSave(form) {
25
+ // Don't use canSave() because we don't want to set touched for all the fields
26
+ if (autoSaveRef.current && form.dirty && form.valid) {
27
+ const { current: pending } = pendingAutoSaves;
28
+ if (isAutoSaving) {
29
+ pending.add(form);
30
+ return;
31
+ }
32
+ try {
33
+ isAutoSaving = true;
34
+ // See if we have any reactions that want to run (i.e. added by addRules hooks)
35
+ await new Promise((resolve) => setTimeout(resolve, 0));
36
+ // If a reaction re-queued our form during the ^ wait, remove it
37
+ pending.delete(form);
38
+ await autoSaveRef.current(form);
39
+ }
40
+ finally {
41
+ isAutoSaving = false;
42
+ if (pending.size > 0) {
43
+ const first = pending.values().next().value;
44
+ pending.delete(first);
45
+ await maybeAutoSave(first);
46
+ }
32
47
  }
33
48
  }
34
49
  }
35
- }
36
- const getFormState = react_1.useCallback((input) => {
37
- const existing = objectStateCache[getId(input)];
38
- let form = existing === null || existing === void 0 ? void 0 : existing[0];
39
50
  // If it didn't exist, then add to the cache.
40
51
  if (!form) {
41
52
  form = formState_1.createObjectState(config, utils_1.initValue(config, map ? { map, input } : input), {
@@ -51,6 +62,7 @@ function useFormStates(opts) {
51
62
  form.set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
52
63
  existing[1] = input;
53
64
  }
65
+ form.readOnly = readOnlyRef.current || !!opts.readOnly;
54
66
  return form;
55
67
  },
56
68
  // Allow the user to not stable-ize getId, map, addRules, and autoSave
@@ -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 () => {
@@ -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 () => {
@@ -151,7 +152,78 @@ describe("useFormStates", () => {
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 { getFormState } = useFormStates_1.useFormStates({
160
+ config,
161
+ getId: (o) => o.id,
162
+ addRules(fs) {
163
+ // And they have a reactive true that calculates last name
164
+ mobx_1.reaction(() => fs.firstName.value, (curr) => {
165
+ fs.lastName.set(curr);
166
+ });
167
+ },
168
+ async autoSave(fs) {
169
+ autoSave(fs.changedValue);
170
+ },
171
+ });
172
+ return jsx_runtime_1.jsx(FormStateApp_1.TextField, { field: getFormState({ id: "a:1", firstName: "Brandon" }).firstName }, void 0);
173
+ }
174
+ // When we render
175
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
176
+ // And update the firstName
177
+ await rtl_utils_1.typeAndWait(r.firstName(), "first");
178
+ // Then autoSave was called once with both input+calc'd values
179
+ expect(autoSave).toHaveBeenCalledTimes(1);
180
+ expect(autoSave).toHaveBeenCalledWith({ id: "a:1", firstName: "first", lastName: "first" });
181
+ });
182
+ it("can set readOnly via the hook opt", async () => {
183
+ // Given a test component
184
+ function TestComponent({ readOnly }) {
185
+ const { getFormState } = useFormStates_1.useFormStates({
186
+ config,
187
+ getId: (o) => o.id,
188
+ // And it passes readOnly directly to useFormStates
189
+ readOnly,
190
+ });
191
+ return jsx_runtime_1.jsx(ChildComponent, { os: getFormState({ id: "a:1", firstName: "Brandon" }) }, void 0);
192
+ }
193
+ // When we render
194
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, { readOnly: true }, void 0));
195
+ // Then it's read only
196
+ expect(r.firstName()).toHaveAttribute("data-readonly", "true");
197
+ // And when we rerender
198
+ await r.rerender(jsx_runtime_1.jsx(TestComponent, { readOnly: false }, void 0));
199
+ // Then it's not read only
200
+ expect(r.firstName()).toHaveAttribute("data-readonly", "false");
201
+ });
202
+ it("can set readOnly via the getFormState function", async () => {
203
+ // Given a test component
204
+ function TestComponent({ readOnly }) {
205
+ const { getFormState } = useFormStates_1.useFormStates({
206
+ config,
207
+ getId: (o) => o.id,
208
+ });
209
+ // And it passes readOnly directly to getFormState
210
+ return jsx_runtime_1.jsx(ChildComponent, { os: getFormState({ id: "a:1", firstName: "Brandon" }, { readOnly }) }, void 0);
211
+ }
212
+ // When we render
213
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, { readOnly: true }, void 0));
214
+ // Then it's read only
215
+ expect(r.firstName()).toHaveAttribute("data-readonly", "true");
216
+ // And when we rerender
217
+ await r.rerender(jsx_runtime_1.jsx(TestComponent, { readOnly: false }, void 0));
218
+ // Then it's not read only
219
+ expect(r.firstName()).toHaveAttribute("data-readonly", "false");
220
+ });
154
221
  });
222
+ const config = {
223
+ id: { type: "value" },
224
+ firstName: { type: "value" },
225
+ lastName: { type: "value" },
226
+ };
155
227
  function ChildComponent({ os }) {
156
- return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
228
+ return (jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName", "data-readonly": os.firstName.readOnly }, { children: os.firstName.value }), void 0));
157
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",