@homebound/form-state 2.2.13 → 2.5.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/dist/FormStateApp.js +1 -1
- package/dist/formState.d.ts +11 -4
- package/dist/formState.js +26 -21
- package/dist/formState.test.js +118 -34
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/useFormState.d.ts +1 -13
- package/dist/useFormState.js +21 -66
- package/dist/useFormState.test.js +49 -87
- package/dist/useFormStates.d.ts +12 -0
- package/dist/useFormStates.js +58 -0
- package/dist/useFormStates.test.d.ts +1 -0
- package/dist/useFormStates.test.js +136 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.js +60 -1
- package/dist/utils.test.d.ts +1 -0
- package/dist/utils.test.js +102 -0
- package/package.json +11 -2
package/dist/FormStateApp.js
CHANGED
package/dist/formState.d.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<P, T> & {
|
|
27
27
|
/** Sets the state of fields in `state`. */
|
|
28
|
-
set(state: Partial<T
|
|
28
|
+
set(state: Partial<T>, opts?: SetOpts): void;
|
|
29
29
|
/** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
|
|
30
30
|
canSave(): boolean;
|
|
31
31
|
};
|
|
@@ -72,14 +72,21 @@ export interface FieldState<T, V> {
|
|
|
72
72
|
readonly changedValue: V;
|
|
73
73
|
/** Focuses the field. Disables changes from `ObjectState.set` calls. */
|
|
74
74
|
focus(): void;
|
|
75
|
-
/** Blur
|
|
75
|
+
/** Blur marks the field as touched and triggers an auto-save. */
|
|
76
76
|
blur(): void;
|
|
77
|
-
|
|
77
|
+
/** Triggers an auto-save; the caller (i.e. useFormState) should still dirty & valid check. */
|
|
78
|
+
maybeAutoSave(): void;
|
|
79
|
+
set(value: V, opts?: SetOpts): void;
|
|
78
80
|
/** Reverts back to the original value and resets dirty/touched. */
|
|
79
81
|
reset(): void;
|
|
80
82
|
/** Accepts the current changed value (if any) as the original and resets dirty/touched. */
|
|
81
83
|
save(): void;
|
|
82
84
|
}
|
|
85
|
+
/** Public options for our `set` command. */
|
|
86
|
+
export interface SetOpts {
|
|
87
|
+
/** Whether this `set` should trigger an auto-save; defaults to true. */
|
|
88
|
+
autoSave?: boolean;
|
|
89
|
+
}
|
|
83
90
|
/** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
|
|
84
91
|
export interface ListFieldState<T, U> extends Omit<FieldState<T, U[]>, "originalValue"> {
|
|
85
92
|
readonly rows: ReadonlyArray<ObjectState<U>>;
|
|
@@ -156,7 +163,7 @@ export declare type ObjectFieldConfig<U> = {
|
|
|
156
163
|
* individual fields as well as the top-level form/object itself.
|
|
157
164
|
*/
|
|
158
165
|
export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
|
|
159
|
-
|
|
166
|
+
maybeAutoSave?: () => void;
|
|
160
167
|
}): ObjectState<T>;
|
|
161
168
|
declare type Primitive = undefined | null | boolean | string | number | Function | Date | {
|
|
162
169
|
toJSON(): any;
|
package/dist/formState.js
CHANGED
|
@@ -12,7 +12,8 @@ const utils_1 = require("./utils");
|
|
|
12
12
|
// We pre-emptively make this a mobx action so that it's identity doesn't change when proxy-ified
|
|
13
13
|
// and breaks our ability to do `rules.some(r => r === required)`.
|
|
14
14
|
exports.required = mobx_1.action(({ value: v }) => {
|
|
15
|
-
|
|
15
|
+
const isEmptyString = typeof v === "string" ? v.trim() === "" : false;
|
|
16
|
+
return v !== undefined && v !== null && !isEmptyString ? undefined : "Required";
|
|
16
17
|
});
|
|
17
18
|
/**
|
|
18
19
|
* Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
|
|
@@ -23,10 +24,10 @@ exports.required = mobx_1.action(({ value: v }) => {
|
|
|
23
24
|
*/
|
|
24
25
|
function createObjectState(config, instance, opts = {}) {
|
|
25
26
|
const noop = () => { };
|
|
26
|
-
return newObjectState(config, undefined, instance, undefined, opts.
|
|
27
|
+
return newObjectState(config, undefined, instance, undefined, opts.maybeAutoSave || noop);
|
|
27
28
|
}
|
|
28
29
|
exports.createObjectState = createObjectState;
|
|
29
|
-
function newObjectState(config, parentState, instance, key,
|
|
30
|
+
function newObjectState(config, parentState, instance, key, maybeAutoSave) {
|
|
30
31
|
// This is what we return, but we only know it's value until we call `observable`, so
|
|
31
32
|
// we create a mutable variable to capture it so that we can create fields/call their
|
|
32
33
|
// constructors and give them a way to access it later.
|
|
@@ -42,16 +43,16 @@ function newObjectState(config, parentState, instance, key, onBlur) {
|
|
|
42
43
|
const config = _config;
|
|
43
44
|
let field;
|
|
44
45
|
if (config.type === "value") {
|
|
45
|
-
field = newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false,
|
|
46
|
+
field = newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
|
|
46
47
|
}
|
|
47
48
|
else if (config.type === "list") {
|
|
48
|
-
field = newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config,
|
|
49
|
+
field = newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
|
|
49
50
|
}
|
|
50
51
|
else if (config.type === "object") {
|
|
51
52
|
if (!instance[key]) {
|
|
52
53
|
instance[key] = {};
|
|
53
54
|
}
|
|
54
|
-
field = newObjectState(config.config, getObjectState, instance[key], key,
|
|
55
|
+
field = newObjectState(config.config, getObjectState, instance[key], key, maybeAutoSave);
|
|
55
56
|
}
|
|
56
57
|
else {
|
|
57
58
|
throw new Error("Unsupported");
|
|
@@ -185,7 +186,7 @@ function newObjectState(config, parentState, instance, key, onBlur) {
|
|
|
185
186
|
mobx_1.reaction(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
|
|
186
187
|
return proxy;
|
|
187
188
|
}
|
|
188
|
-
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly,
|
|
189
|
+
function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
|
|
189
190
|
// keep a copy here for reference equality
|
|
190
191
|
const value = parentInstance[key];
|
|
191
192
|
let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
|
|
@@ -260,14 +261,15 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
260
261
|
this._focused = true;
|
|
261
262
|
},
|
|
262
263
|
blur() {
|
|
263
|
-
|
|
264
|
-
if (this._focused) {
|
|
265
|
-
this.maybeTrim();
|
|
266
|
-
}
|
|
264
|
+
this.maybeAutoSave();
|
|
267
265
|
this._focused = false;
|
|
266
|
+
},
|
|
267
|
+
maybeAutoSave() {
|
|
268
|
+
// Now that the user is done editing the field (note maybe w/o blurring, i.e. if they hit enter), we sneak in some trim logic
|
|
269
|
+
this.maybeTrim();
|
|
268
270
|
// touched is readonly, but we're allowed to change it
|
|
269
271
|
this.touched = true;
|
|
270
|
-
|
|
272
|
+
maybeAutoSave();
|
|
271
273
|
},
|
|
272
274
|
set(value, opts = {}) {
|
|
273
275
|
if (this.readOnly && !opts.resetting && !opts.refreshing) {
|
|
@@ -298,8 +300,8 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
298
300
|
}
|
|
299
301
|
// If we're being set programmatically, i.e. we don't currently have focus,
|
|
300
302
|
// call blur to trigger any auto-saves.
|
|
301
|
-
if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed) {
|
|
302
|
-
this.
|
|
303
|
+
if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) {
|
|
304
|
+
this.maybeAutoSave();
|
|
303
305
|
}
|
|
304
306
|
},
|
|
305
307
|
reset() {
|
|
@@ -339,7 +341,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
|
|
|
339
341
|
};
|
|
340
342
|
return field;
|
|
341
343
|
}
|
|
342
|
-
function newListFieldState(parentInstance, parentState, key, rules, listConfig, config,
|
|
344
|
+
function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, maybeAutoSave) {
|
|
343
345
|
// Keep a map of "item in the parent list" -> "that item's ObjectState"
|
|
344
346
|
const rowMap = new Map();
|
|
345
347
|
const _tick = mobx_1.observable({ value: 1 });
|
|
@@ -399,7 +401,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
399
401
|
// Because we're reading from this.value, child will be the proxy version
|
|
400
402
|
let childState = rowMap.get(child);
|
|
401
403
|
if (!childState) {
|
|
402
|
-
childState = newObjectState(config, parentState, child, undefined,
|
|
404
|
+
childState = newObjectState(config, parentState, child, undefined, maybeAutoSave);
|
|
403
405
|
rowMap.set(child, childState);
|
|
404
406
|
}
|
|
405
407
|
return childState;
|
|
@@ -442,8 +444,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
442
444
|
},
|
|
443
445
|
blur() {
|
|
444
446
|
this._focused = false;
|
|
447
|
+
this.maybeAutoSave();
|
|
448
|
+
},
|
|
449
|
+
maybeAutoSave() {
|
|
445
450
|
this.touched = true;
|
|
446
|
-
|
|
451
|
+
maybeAutoSave();
|
|
447
452
|
},
|
|
448
453
|
set(values, opts = {}) {
|
|
449
454
|
if (this.readOnly && !opts.resetting && !opts.refreshing) {
|
|
@@ -462,7 +467,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
462
467
|
}
|
|
463
468
|
}
|
|
464
469
|
// If we didn't have an existing child, just make a new object state
|
|
465
|
-
childState = createObjectState(config, value, {
|
|
470
|
+
childState = createObjectState(config, value, { maybeAutoSave });
|
|
466
471
|
rowMap.set(value, childState);
|
|
467
472
|
}
|
|
468
473
|
// Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
|
|
@@ -476,12 +481,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
476
481
|
},
|
|
477
482
|
add(value, spliceIndex) {
|
|
478
483
|
// This is called by the user, so value should be a non-proxy value we should keep
|
|
479
|
-
const childState = createObjectState(config, value, {
|
|
484
|
+
const childState = createObjectState(config, value, { maybeAutoSave });
|
|
480
485
|
rowMap.set(value, childState);
|
|
481
486
|
this.ensureSet();
|
|
482
487
|
this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
|
|
483
488
|
_tick.value++;
|
|
484
|
-
|
|
489
|
+
maybeAutoSave();
|
|
485
490
|
},
|
|
486
491
|
remove(indexOrValue) {
|
|
487
492
|
this.ensureSet();
|
|
@@ -495,7 +500,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
|
|
|
495
500
|
}
|
|
496
501
|
}
|
|
497
502
|
_tick.value++;
|
|
498
|
-
|
|
503
|
+
maybeAutoSave();
|
|
499
504
|
},
|
|
500
505
|
reset() {
|
|
501
506
|
if (originalCopy) {
|
package/dist/formState.test.js
CHANGED
|
@@ -39,6 +39,21 @@ describe("formState", () => {
|
|
|
39
39
|
expect(a.title.required).toBeTruthy();
|
|
40
40
|
expect(a.isPublished.required).toBeFalsy();
|
|
41
41
|
});
|
|
42
|
+
it("trims whitespace within the required rule", () => {
|
|
43
|
+
const a = formState_1.createObjectState({ title: { type: "value", rules: [formState_1.required] } }, { title: "initial valid title" });
|
|
44
|
+
let numErrors = 0;
|
|
45
|
+
mobx_1.autorun(() => {
|
|
46
|
+
numErrors = a.title.errors.length;
|
|
47
|
+
});
|
|
48
|
+
expect(a.valid).toBeTruthy();
|
|
49
|
+
expect(numErrors).toEqual(0);
|
|
50
|
+
// When a value contains only whitespace
|
|
51
|
+
a.title.value = " ";
|
|
52
|
+
expect(a.title.valid).toBeFalsy();
|
|
53
|
+
expect(numErrors).toEqual(1);
|
|
54
|
+
expect(a.title.errors).toEqual(["Required"]);
|
|
55
|
+
expect(a.errors).toEqual(["title: Required"]);
|
|
56
|
+
});
|
|
42
57
|
it("can set values", () => {
|
|
43
58
|
const a = formState_1.createObjectState({ title: { type: "value" } }, { title: "b1" });
|
|
44
59
|
expect(a.title.value).toEqual("b1");
|
|
@@ -285,6 +300,52 @@ describe("formState", () => {
|
|
|
285
300
|
expect(a.firstName.valid).toBeTruthy();
|
|
286
301
|
expect(a.lastName.errors).toEqual(["Last name cannot be first name"]);
|
|
287
302
|
});
|
|
303
|
+
it("passes sanitized values to validation rules", () => {
|
|
304
|
+
// Given a field with a validation rule
|
|
305
|
+
const rule = jest.fn().mockReturnValue("Required");
|
|
306
|
+
const a = formState_1.createObjectState({ firstName: { type: "value", rules: [rule] } }, {});
|
|
307
|
+
expect(rule).toHaveBeenCalledTimes(0);
|
|
308
|
+
// When the field is set to empty string
|
|
309
|
+
a.firstName.value = "";
|
|
310
|
+
expect(a.firstName.errors).toEqual(["Required"]);
|
|
311
|
+
expect(rule).toHaveBeenCalledTimes(1);
|
|
312
|
+
// Then the validation rule was passed undefined
|
|
313
|
+
expect(rule).toHaveBeenCalledWith({
|
|
314
|
+
value: undefined,
|
|
315
|
+
originalValue: undefined,
|
|
316
|
+
key: "firstName",
|
|
317
|
+
object: expect.anything(),
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
it("passes trimmed values to validation rules on maybeAutoSave", () => {
|
|
321
|
+
// Given we have a required rule
|
|
322
|
+
const rule = jest.fn().mockReturnValue("Required");
|
|
323
|
+
const a = formState_1.createObjectState({ firstName: { type: "value", rules: [rule] } }, {});
|
|
324
|
+
expect(rule).toHaveBeenCalledTimes(0);
|
|
325
|
+
// When the user initially sets an empty string value
|
|
326
|
+
a.firstName.focus();
|
|
327
|
+
a.firstName.value = " ";
|
|
328
|
+
expect(a.firstName.errors).toEqual(["Required"]);
|
|
329
|
+
// Then initially the rule is called with the empty string
|
|
330
|
+
expect(rule).toHaveBeenCalledTimes(1);
|
|
331
|
+
expect(rule).toHaveBeenCalledWith({
|
|
332
|
+
value: " ",
|
|
333
|
+
originalValue: undefined,
|
|
334
|
+
key: "firstName",
|
|
335
|
+
object: expect.anything(),
|
|
336
|
+
});
|
|
337
|
+
// But then when the field maybeAutoSaves (i.e. via blur)
|
|
338
|
+
a.firstName.maybeAutoSave();
|
|
339
|
+
expect(a.firstName.errors).toEqual(["Required"]);
|
|
340
|
+
// Then the rule is called with the undefined
|
|
341
|
+
expect(rule).toHaveBeenCalledTimes(2);
|
|
342
|
+
expect(rule).toHaveBeenCalledWith({
|
|
343
|
+
value: undefined,
|
|
344
|
+
originalValue: undefined,
|
|
345
|
+
key: "firstName",
|
|
346
|
+
object: expect.anything(),
|
|
347
|
+
});
|
|
348
|
+
});
|
|
288
349
|
it("simple value changes trigger observers", () => {
|
|
289
350
|
const a = formState_1.createObjectState({
|
|
290
351
|
title: { type: "value", rules: [formState_1.required] },
|
|
@@ -330,69 +391,78 @@ describe("formState", () => {
|
|
|
330
391
|
a1.books.remove(0);
|
|
331
392
|
expect(a1.dirty).toBeFalsy();
|
|
332
393
|
});
|
|
333
|
-
it("calls
|
|
334
|
-
const
|
|
335
|
-
const a1 = createAuthorInputState({ books: [] },
|
|
394
|
+
it("calls maybeAutoSave when adding or removing to a list", () => {
|
|
395
|
+
const maybeAutoSave = jest.fn();
|
|
396
|
+
const a1 = createAuthorInputState({ books: [] }, maybeAutoSave);
|
|
336
397
|
expect(a1.books.dirty).toBeFalsy();
|
|
337
398
|
a1.books.add({ title: "t2" });
|
|
338
399
|
expect(a1.books.dirty).toBeTruthy();
|
|
339
400
|
a1.books.remove(0);
|
|
340
401
|
expect(a1.dirty).toBeFalsy();
|
|
341
|
-
expect(
|
|
402
|
+
expect(maybeAutoSave).toBeCalledTimes(2);
|
|
342
403
|
});
|
|
343
|
-
it("calls
|
|
344
|
-
const
|
|
404
|
+
it("calls maybeAutoSave when programmatically setting a value", () => {
|
|
405
|
+
const maybeAutoSave = jest.fn();
|
|
345
406
|
// Given an author listening for blur
|
|
346
|
-
const a1 = createAuthorInputState({ books: [{}] },
|
|
407
|
+
const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
|
|
347
408
|
// When we programmatically set a field that isn't focused
|
|
348
409
|
a1.firstName.value = "first";
|
|
349
|
-
// Then we call
|
|
350
|
-
expect(
|
|
410
|
+
// Then we call maybeAutoSave
|
|
411
|
+
expect(maybeAutoSave).toBeCalledTimes(1);
|
|
351
412
|
// And when we set a nested value
|
|
352
413
|
a1.books.rows[0].title.value = "title";
|
|
353
|
-
// Then we called
|
|
354
|
-
expect(
|
|
414
|
+
// Then we called maybeAutoSave again
|
|
415
|
+
expect(maybeAutoSave).toBeCalledTimes(2);
|
|
355
416
|
});
|
|
356
|
-
it("
|
|
357
|
-
const
|
|
417
|
+
it("can skip maybeAutoSave when programmatically setting a value", () => {
|
|
418
|
+
const maybeAutoSave = jest.fn();
|
|
358
419
|
// Given an author listening for blur
|
|
359
|
-
const a1 = createAuthorInputState({ books: [{}] },
|
|
420
|
+
const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
|
|
421
|
+
// When we programmatically set a field that isn't focused
|
|
422
|
+
a1.set({ firstName: "first" }, { autoSave: false });
|
|
423
|
+
// Then we don't call maybeAutoSave
|
|
424
|
+
expect(maybeAutoSave).toBeCalledTimes(0);
|
|
425
|
+
});
|
|
426
|
+
it("defers calling maybeAutoSave when setting a focused value", () => {
|
|
427
|
+
const maybeAutoSave = jest.fn();
|
|
428
|
+
// Given an author listening for blur
|
|
429
|
+
const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
|
|
360
430
|
// And the field is focused
|
|
361
431
|
a1.firstName.focus();
|
|
362
432
|
// When we we set the field
|
|
363
433
|
a1.firstName.value = "first";
|
|
364
|
-
// Then we don't call
|
|
365
|
-
expect(
|
|
434
|
+
// Then we don't call maybeAutoSave
|
|
435
|
+
expect(maybeAutoSave).toBeCalledTimes(0);
|
|
366
436
|
});
|
|
367
|
-
it("skips
|
|
368
|
-
const
|
|
437
|
+
it("skips maybeAutoSave when refreshing", () => {
|
|
438
|
+
const maybeAutoSave = jest.fn();
|
|
369
439
|
// Given an author listening for blur
|
|
370
|
-
const a1 = createAuthorInputState({ books: [{}] },
|
|
440
|
+
const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
|
|
371
441
|
// When we programmatically set a field that isn't focused
|
|
372
442
|
a1.set({ firstName: "first" }, { refreshing: true });
|
|
373
|
-
// Then we don't call
|
|
374
|
-
expect(
|
|
443
|
+
// Then we don't call maybeAutoSave
|
|
444
|
+
expect(maybeAutoSave).toBeCalledTimes(0);
|
|
375
445
|
});
|
|
376
|
-
it("skips
|
|
377
|
-
const
|
|
446
|
+
it("skips maybeAutoSave when resetting", () => {
|
|
447
|
+
const maybeAutoSave = jest.fn();
|
|
378
448
|
// Given an author listening for blur
|
|
379
|
-
const a1 = createAuthorInputState({ books: [{}] },
|
|
380
|
-
// And we called
|
|
449
|
+
const a1 = createAuthorInputState({ books: [{}] }, maybeAutoSave);
|
|
450
|
+
// And we called maybeAutoSave once
|
|
381
451
|
a1.set({ firstName: "first" });
|
|
382
|
-
expect(
|
|
452
|
+
expect(maybeAutoSave).toBeCalledTimes(1);
|
|
383
453
|
// When we reset
|
|
384
454
|
a1.reset();
|
|
385
455
|
// We don't call blur again
|
|
386
|
-
expect(
|
|
456
|
+
expect(maybeAutoSave).toBeCalledTimes(1);
|
|
387
457
|
});
|
|
388
|
-
it("skips
|
|
389
|
-
const
|
|
458
|
+
it("skips maybeAutoSave when not dirty", () => {
|
|
459
|
+
const maybeAutoSave = jest.fn();
|
|
390
460
|
// Given an author listening for blur
|
|
391
|
-
const a1 = createAuthorInputState({ firstName: "first", books: [{}] },
|
|
461
|
+
const a1 = createAuthorInputState({ firstName: "first", books: [{}] }, maybeAutoSave);
|
|
392
462
|
// When we programmatically set a field to it's existing valued
|
|
393
463
|
a1.firstName.value = "first";
|
|
394
|
-
// Then we don't call
|
|
395
|
-
expect(
|
|
464
|
+
// Then we don't call maybeAutoSave
|
|
465
|
+
expect(maybeAutoSave).toBeCalledTimes(0);
|
|
396
466
|
});
|
|
397
467
|
it("knows list of primitives are dirty", () => {
|
|
398
468
|
const a1 = formState_1.createObjectState({ favoriteColors: { type: "value" } }, {});
|
|
@@ -955,6 +1025,20 @@ describe("formState", () => {
|
|
|
955
1025
|
// Then we trim it
|
|
956
1026
|
expect(formState.firstName.value).toEqual("f");
|
|
957
1027
|
});
|
|
1028
|
+
it("trims string values on maybeAutoSave", () => {
|
|
1029
|
+
const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
|
|
1030
|
+
// Given the user is typing with spaces
|
|
1031
|
+
formState.firstName.focus();
|
|
1032
|
+
formState.firstName.set("f ");
|
|
1033
|
+
// And we initially keep the space
|
|
1034
|
+
expect(formState.firstName.value).toEqual("f ");
|
|
1035
|
+
// When the user hits Enter (which will call maybeAutoSave)
|
|
1036
|
+
formState.firstName.maybeAutoSave();
|
|
1037
|
+
// Then we trim it
|
|
1038
|
+
expect(formState.firstName.value).toEqual("f");
|
|
1039
|
+
// And the field is considered touched
|
|
1040
|
+
expect(formState.firstName.touched).toEqual(true);
|
|
1041
|
+
});
|
|
958
1042
|
it("trims empty values to undefined on blur", () => {
|
|
959
1043
|
const formState = formState_1.createObjectState(authorWithBooksConfig, { firstName: "f", lastName: "l" });
|
|
960
1044
|
// Given the user is typing with only spaces
|
|
@@ -1093,8 +1177,8 @@ const authorWithBooksConfig = {
|
|
|
1093
1177
|
},
|
|
1094
1178
|
},
|
|
1095
1179
|
};
|
|
1096
|
-
function createAuthorInputState(input,
|
|
1097
|
-
return formState_1.createObjectState(authorWithBooksConfig, input, {
|
|
1180
|
+
function createAuthorInputState(input, maybeAutoSave) {
|
|
1181
|
+
return formState_1.createObjectState(authorWithBooksConfig, input, { maybeAutoSave });
|
|
1098
1182
|
}
|
|
1099
1183
|
const authorWithAddressConfig = {
|
|
1100
1184
|
id: { type: "value" },
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.useFormState = exports.required = exports.createObjectState = void 0;
|
|
3
|
+
exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
|
|
4
4
|
var formState_1 = require("./formState");
|
|
5
5
|
Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return formState_1.createObjectState; } });
|
|
6
6
|
Object.defineProperty(exports, "required", { enumerable: true, get: function () { return formState_1.required; } });
|
|
7
7
|
var useFormState_1 = require("./useFormState");
|
|
8
8
|
Object.defineProperty(exports, "useFormState", { enumerable: true, get: function () { return useFormState_1.useFormState; } });
|
|
9
|
+
var useFormStates_1 = require("./useFormStates");
|
|
10
|
+
Object.defineProperty(exports, "useFormStates", { enumerable: true, get: function () { return useFormStates_1.useFormStates; } });
|
package/dist/useFormState.d.ts
CHANGED
|
@@ -37,21 +37,9 @@ export declare type UseFormStateOpts<T, I> = {
|
|
|
37
37
|
*
|
|
38
38
|
* Does not need to be stable/useMemo'd.
|
|
39
39
|
*/
|
|
40
|
-
autoSave?: (state: ObjectState<T>) => void
|
|
40
|
+
autoSave?: (state: ObjectState<T>) => Promise<void>;
|
|
41
41
|
};
|
|
42
42
|
/**
|
|
43
43
|
* Creates a formState instance for editing in a form.
|
|
44
44
|
*/
|
|
45
45
|
export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): ObjectState<T>;
|
|
46
|
-
/**
|
|
47
|
-
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
48
|
-
*
|
|
49
|
-
* This is useful for creating a form input from a GraphQL query result,
|
|
50
|
-
* but ignoring extra data in the query that we don't want/need in the form.
|
|
51
|
-
*
|
|
52
|
-
* (Especially because the form.value will become our mutation's input type
|
|
53
|
-
* that goes on the wire, we don't want superfluous data in it.)
|
|
54
|
-
*/
|
|
55
|
-
export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
|
|
56
|
-
[K in keyof T]: K extends keyof I ? I[K] : never;
|
|
57
|
-
};
|
package/dist/useFormState.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.useFormState = void 0;
|
|
4
4
|
const is_plain_object_1 = require("is-plain-object");
|
|
5
|
-
const mobx_1 = require("mobx");
|
|
6
5
|
const react_1 = require("react");
|
|
7
6
|
const formState_1 = require("./formState");
|
|
8
7
|
const utils_1 = require("./utils");
|
|
8
|
+
// If the user's autoSave hook makes some last-minute `.set` calls to sneak
|
|
9
|
+
// in some business logic right before their GraphQL mutation call, ignore it
|
|
10
|
+
// so that we don't infinite loop.
|
|
9
11
|
let isAutoSaving = false;
|
|
12
|
+
// `pendingAutoSave` is a flag for determining if we need to immediately call `maybeAutoSave` again after the initial Promise finishes
|
|
13
|
+
// This could happen if a field triggers auto-save while another field's auto-save is already in progress.
|
|
14
|
+
let pendingAutoSave = false;
|
|
10
15
|
/**
|
|
11
16
|
* Creates a formState instance for editing in a form.
|
|
12
17
|
*/
|
|
@@ -17,28 +22,34 @@ function useFormState(opts) {
|
|
|
17
22
|
autoSaveRef.current = autoSave;
|
|
18
23
|
const firstRunRef = react_1.useRef(true);
|
|
19
24
|
// This is a little weird, but we need to know ahead of time, before the form useMemo, if we're working with classes/mobx proxies
|
|
20
|
-
const [firstInitValue] = react_1.useState(() => initValue(config, init));
|
|
25
|
+
const [firstInitValue] = react_1.useState(() => utils_1.initValue(config, init));
|
|
21
26
|
const isWrappingMobxProxy = !is_plain_object_1.isPlainObject(firstInitValue);
|
|
22
27
|
// If they're using init.input, useMemo on it (and it might be an array), otherwise allow the identity of init be unstable
|
|
23
28
|
const dep = init && "input" in init && "map" in init ? (Array.isArray(init.input) ? init.input : [init.input]) : [];
|
|
24
29
|
const form = react_1.useMemo(() => {
|
|
25
|
-
function
|
|
26
|
-
|
|
30
|
+
async function maybeAutoSave() {
|
|
31
|
+
if (isAutoSaving) {
|
|
32
|
+
pendingAutoSave = true;
|
|
33
|
+
}
|
|
34
|
+
// Don't use canSave() because we don't want to set touched for all the fields
|
|
27
35
|
if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
|
|
28
36
|
try {
|
|
29
37
|
isAutoSaving = true;
|
|
30
|
-
autoSaveRef.current(form);
|
|
38
|
+
await autoSaveRef.current(form);
|
|
31
39
|
}
|
|
32
40
|
finally {
|
|
33
41
|
isAutoSaving = false;
|
|
42
|
+
if (pendingAutoSave) {
|
|
43
|
+
pendingAutoSave = false;
|
|
44
|
+
await maybeAutoSave();
|
|
45
|
+
}
|
|
34
46
|
}
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
|
-
const value = firstRunRef.current ? firstInitValue : initValue(config, init);
|
|
38
|
-
const form = formState_1.createObjectState(config, value, {
|
|
49
|
+
const value = firstRunRef.current ? firstInitValue : utils_1.initValue(config, init);
|
|
50
|
+
const form = formState_1.createObjectState(config, value, { maybeAutoSave });
|
|
39
51
|
form.readOnly = readOnly;
|
|
40
52
|
// The identity of `addRules` is not stable, but assume that it is for better UX.
|
|
41
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
53
|
(addRules || (() => { }))(form);
|
|
43
54
|
firstRunRef.current = true;
|
|
44
55
|
return form;
|
|
@@ -56,7 +67,7 @@ function useFormState(opts) {
|
|
|
56
67
|
firstRunRef.current = false;
|
|
57
68
|
return;
|
|
58
69
|
}
|
|
59
|
-
form.set(initValue(config, init), { refreshing: true });
|
|
70
|
+
form.set(utils_1.initValue(config, init), { refreshing: true });
|
|
60
71
|
}, [form, ...dep]);
|
|
61
72
|
// Use useEffect so that we don't touch the form.init proxy during a render
|
|
62
73
|
react_1.useEffect(() => {
|
|
@@ -65,59 +76,3 @@ function useFormState(opts) {
|
|
|
65
76
|
return form;
|
|
66
77
|
}
|
|
67
78
|
exports.useFormState = useFormState;
|
|
68
|
-
/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
|
|
69
|
-
function initValue(config, init) {
|
|
70
|
-
const initValue = init && "input" in init && "map" in init
|
|
71
|
-
? init.input
|
|
72
|
-
? init.map(init.input)
|
|
73
|
-
: init.ifUndefined || {}
|
|
74
|
-
: init || {};
|
|
75
|
-
return pickFields(config, initValue);
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
79
|
-
*
|
|
80
|
-
* This is useful for creating a form input from a GraphQL query result,
|
|
81
|
-
* but ignoring extra data in the query that we don't want/need in the form.
|
|
82
|
-
*
|
|
83
|
-
* (Especially because the form.value will become our mutation's input type
|
|
84
|
-
* that goes on the wire, we don't want superfluous data in it.)
|
|
85
|
-
*/
|
|
86
|
-
function pickFields(formConfig, instance) {
|
|
87
|
-
// If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
|
|
88
|
-
if (!is_plain_object_1.isPlainObject(instance)) {
|
|
89
|
-
return instance;
|
|
90
|
-
}
|
|
91
|
-
return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
|
|
92
|
-
const keyConfig = _keyConfig;
|
|
93
|
-
const value = instance[key];
|
|
94
|
-
if (keyConfig.type === "object") {
|
|
95
|
-
if (value) {
|
|
96
|
-
return [key, pickFields(keyConfig.config, value)];
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
return [key, value];
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
else if (keyConfig.type === "list") {
|
|
103
|
-
if (mobx_1.isObservable(value)) {
|
|
104
|
-
// If we hit an observable array, leave it as the existing proxy so the our
|
|
105
|
-
// ListFieldState will react to changes in the original array.
|
|
106
|
-
return [key, value];
|
|
107
|
-
}
|
|
108
|
-
else if (value) {
|
|
109
|
-
return [key, value.map((u) => pickFields(keyConfig.config, u))];
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
return [key, value];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
else if (keyConfig.type === "value") {
|
|
116
|
-
return [key, value];
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
return utils_1.assertNever(keyConfig);
|
|
120
|
-
}
|
|
121
|
-
}));
|
|
122
|
-
}
|
|
123
|
-
exports.pickFields = pickFields;
|
|
@@ -2,7 +2,6 @@
|
|
|
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");
|
|
6
5
|
const mobx_react_1 = require("mobx-react");
|
|
7
6
|
const react_1 = require("react");
|
|
8
7
|
const formState_1 = require("./formState");
|
|
@@ -306,15 +305,15 @@ describe("useFormState", () => {
|
|
|
306
305
|
});
|
|
307
306
|
return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "refreshData", onClick: () => setData(data2) }, void 0),
|
|
308
307
|
jsx_runtime_1.jsx("button", { "data-testid": "add", onClick: () => form.books.add({ title: "New Book" }) }, void 0),
|
|
309
|
-
jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => form.books.rows[0].title
|
|
310
|
-
jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => form.books.rows[1].title
|
|
308
|
+
jsx_runtime_1.jsx("button", { "data-testid": "blurBookOne", onClick: () => focusAndBlur(form.books.rows[0].title) }, void 0),
|
|
309
|
+
jsx_runtime_1.jsx("button", { "data-testid": "blurBookTwo", onClick: () => focusAndBlur(form.books.rows[1].title) }, void 0)] }, void 0)) }, void 0));
|
|
311
310
|
}
|
|
312
311
|
// Given a formState with `onBlur` set
|
|
313
312
|
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
314
313
|
// When the data/child is now available
|
|
315
314
|
rtl_utils_1.click(r.refreshData);
|
|
316
315
|
// And the field is blurred
|
|
317
|
-
rtl_utils_1.
|
|
316
|
+
await rtl_utils_1.clickAndWait(r.blurBookOne);
|
|
318
317
|
// Then we don't auto-save because nothing has changed
|
|
319
318
|
expect(autoSave).toBeCalledTimes(0);
|
|
320
319
|
// And when adding a new book
|
|
@@ -323,7 +322,7 @@ describe("useFormState", () => {
|
|
|
323
322
|
// that say the new row can't be empty)
|
|
324
323
|
expect(autoSave).toBeCalledTimes(1);
|
|
325
324
|
// And the new book is blurred
|
|
326
|
-
rtl_utils_1.
|
|
325
|
+
await rtl_utils_1.clickAndWait(r.blurBookTwo);
|
|
327
326
|
// Then we auto save again
|
|
328
327
|
expect(autoSave).toBeCalledTimes(2);
|
|
329
328
|
});
|
|
@@ -337,7 +336,7 @@ describe("useFormState", () => {
|
|
|
337
336
|
config,
|
|
338
337
|
init: data,
|
|
339
338
|
// And there is reactive business logic in the `autoSave` method
|
|
340
|
-
autoSave(state) {
|
|
339
|
+
async autoSave(state) {
|
|
341
340
|
state.lastName.set("l2");
|
|
342
341
|
autoSaves++;
|
|
343
342
|
},
|
|
@@ -350,75 +349,45 @@ describe("useFormState", () => {
|
|
|
350
349
|
// When autoSave didn't infinite loop
|
|
351
350
|
expect(autoSaves).toEqual(1);
|
|
352
351
|
});
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
},
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
Object {
|
|
393
|
-
"books": undefined,
|
|
394
|
-
"firstName": "a",
|
|
395
|
-
"id": undefined,
|
|
396
|
-
"lastName": undefined,
|
|
397
|
-
}
|
|
398
|
-
`);
|
|
399
|
-
});
|
|
400
|
-
it("can pick a set list field", () => {
|
|
401
|
-
const a = useFormState_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books: [{}] });
|
|
402
|
-
expect(a).toMatchInlineSnapshot(`
|
|
403
|
-
Object {
|
|
404
|
-
"books": Array [
|
|
405
|
-
Object {
|
|
406
|
-
"classification": undefined,
|
|
407
|
-
"id": undefined,
|
|
408
|
-
"title": undefined,
|
|
409
|
-
},
|
|
410
|
-
],
|
|
411
|
-
"firstName": "a",
|
|
412
|
-
"id": undefined,
|
|
413
|
-
"lastName": undefined,
|
|
414
|
-
}
|
|
415
|
-
`);
|
|
416
|
-
});
|
|
417
|
-
it("can pick a set observable list field", () => {
|
|
418
|
-
const books = mobx_1.observable([]);
|
|
419
|
-
const a = useFormState_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books });
|
|
420
|
-
expect(mobx_1.isObservable(a.books)).toEqual(true);
|
|
421
|
-
});
|
|
352
|
+
it("can queue up changes for auto save if a save is already in progress", async () => {
|
|
353
|
+
const autoSaveStub = jest.fn();
|
|
354
|
+
const config = {
|
|
355
|
+
id: { type: "value" },
|
|
356
|
+
firstName: { type: "value" },
|
|
357
|
+
lastName: { type: "value" },
|
|
358
|
+
};
|
|
359
|
+
// Given a component using `getObjectState` for lazily creating ObjectStates
|
|
360
|
+
function TestComponent() {
|
|
361
|
+
const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon", lastName: "Dow" });
|
|
362
|
+
const state = useFormState_1.useFormState({ config, autoSave, init: { input: apiData, map: (v) => v } });
|
|
363
|
+
async function autoSave(form) {
|
|
364
|
+
autoSaveStub(form.changedValue);
|
|
365
|
+
// Pretend to make an API call and update the local state
|
|
366
|
+
setApiData((prevState) => ({ ...prevState, ...form.changedValue }));
|
|
367
|
+
await Promise.resolve(1);
|
|
368
|
+
}
|
|
369
|
+
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
|
|
370
|
+
state.firstName.focus();
|
|
371
|
+
state.firstName.set("Foo");
|
|
372
|
+
state.firstName.maybeAutoSave();
|
|
373
|
+
state.firstName.blur();
|
|
374
|
+
} }, void 0),
|
|
375
|
+
jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveFieldLastName", onClick: () => {
|
|
376
|
+
state.lastName.focus();
|
|
377
|
+
state.lastName.set("Bar");
|
|
378
|
+
state.lastName.maybeAutoSave();
|
|
379
|
+
} }, void 0)] }, void 0));
|
|
380
|
+
}
|
|
381
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
382
|
+
// And triggering the auto save behavior before awaiting the initial promise to resolve so we have pending changes.
|
|
383
|
+
rtl_utils_1.click(r.focusSetAndSaveField());
|
|
384
|
+
rtl_utils_1.click(r.focusSetAndSaveFieldLastName());
|
|
385
|
+
// Awaits the promises for all methods triggered above
|
|
386
|
+
await rtl_utils_1.wait();
|
|
387
|
+
// Then expect the auto save to only have been called twice. Once with each changedValues.
|
|
388
|
+
expect(autoSaveStub).toBeCalledTimes(2);
|
|
389
|
+
expect(autoSaveStub).toBeCalledWith({ id: "a:1", firstName: "Foo" });
|
|
390
|
+
expect(autoSaveStub).toBeCalledWith({ id: "a:1", lastName: "Bar" });
|
|
422
391
|
});
|
|
423
392
|
});
|
|
424
393
|
const authorConfig = {
|
|
@@ -426,18 +395,6 @@ const authorConfig = {
|
|
|
426
395
|
firstName: { type: "value" },
|
|
427
396
|
lastName: { type: "value" },
|
|
428
397
|
};
|
|
429
|
-
const authorWithAddressConfig = {
|
|
430
|
-
id: { type: "value" },
|
|
431
|
-
firstName: { type: "value" },
|
|
432
|
-
lastName: { type: "value" },
|
|
433
|
-
address: {
|
|
434
|
-
type: "object",
|
|
435
|
-
config: {
|
|
436
|
-
street: { type: "value", rules: [formState_1.required] },
|
|
437
|
-
city: { type: "value" },
|
|
438
|
-
},
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
398
|
const authorWithBooksConfig = {
|
|
442
399
|
id: { type: "value" },
|
|
443
400
|
firstName: { type: "value" },
|
|
@@ -472,3 +429,8 @@ const authorWithAddressAndBooksConfig = {
|
|
|
472
429
|
},
|
|
473
430
|
},
|
|
474
431
|
};
|
|
432
|
+
/** Emulates a user focusing and then blurring a field. */
|
|
433
|
+
function focusAndBlur(state) {
|
|
434
|
+
state.focus();
|
|
435
|
+
state.blur();
|
|
436
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ObjectConfig, ObjectState } from "./formState";
|
|
2
|
+
export declare type ObjectStateCache<T, I> = Record<string, [ObjectState<T>, I]>;
|
|
3
|
+
declare type UseFormStatesOpts<T, I> = {
|
|
4
|
+
config: ObjectConfig<T>;
|
|
5
|
+
autoSave?: (state: ObjectState<T>) => Promise<void>;
|
|
6
|
+
getId: (v: I) => string;
|
|
7
|
+
map?: (input: Exclude<I, null | undefined>) => T;
|
|
8
|
+
};
|
|
9
|
+
export declare function useFormStates<T, I>(opts: UseFormStatesOpts<T, I>): {
|
|
10
|
+
getObjectState: (input: I) => ObjectState<T>;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useFormStates = void 0;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const formState_1 = require("./formState");
|
|
6
|
+
const utils_1 = require("./utils");
|
|
7
|
+
function useFormStates(opts) {
|
|
8
|
+
const { config, autoSave, getId, map } = opts;
|
|
9
|
+
const objectStateCache = react_1.useMemo(() => ({}), [config]);
|
|
10
|
+
// Keep track of ObjectStates that triggered auto-save when a save was already in progress.
|
|
11
|
+
const pendingAutoSaves = react_1.useRef([]);
|
|
12
|
+
// Use a ref so our memo'ized `autoSave` always see the latest value
|
|
13
|
+
const autoSaveRef = react_1.useRef(autoSave);
|
|
14
|
+
autoSaveRef.current = autoSave;
|
|
15
|
+
async function maybeAutoSave(form) {
|
|
16
|
+
if (isAutoSaving && !pendingAutoSaves.current.includes(form)) {
|
|
17
|
+
pendingAutoSaves.current.push(form);
|
|
18
|
+
}
|
|
19
|
+
// Don't use canSave() because we don't want to set touched for all the fields
|
|
20
|
+
if (autoSaveRef.current && form.dirty && form.valid && !isAutoSaving) {
|
|
21
|
+
try {
|
|
22
|
+
isAutoSaving = true;
|
|
23
|
+
await autoSaveRef.current(form);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
isAutoSaving = false;
|
|
27
|
+
if (pendingAutoSaves.current.length > 0) {
|
|
28
|
+
await maybeAutoSave(pendingAutoSaves.current.shift());
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
getObjectState: (input) => {
|
|
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), {
|
|
39
|
+
maybeAutoSave: () => maybeAutoSave(form),
|
|
40
|
+
});
|
|
41
|
+
// If it didn't exist, then add to the cache.
|
|
42
|
+
if (!existing) {
|
|
43
|
+
objectStateCache[getId(input)] = [form, input];
|
|
44
|
+
}
|
|
45
|
+
// If the source of truth changed, then update the existing state and return it.
|
|
46
|
+
if (existing && existing[1] !== input) {
|
|
47
|
+
existing[0].set(utils_1.initValue(config, map ? { map, input } : input), { refreshing: true });
|
|
48
|
+
existing[1] = input;
|
|
49
|
+
}
|
|
50
|
+
return form;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
exports.useFormStates = useFormStates;
|
|
55
|
+
// If the user's autoSave hook makes some last-minute `.set` calls to sneak
|
|
56
|
+
// in some business logic right before their GraphQL mutation call, ignore it
|
|
57
|
+
// so that we don't infinite loop.
|
|
58
|
+
let isAutoSaving = false;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4
|
+
const rtl_utils_1 = require("@homebound/rtl-utils");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const formState_1 = require("./formState");
|
|
7
|
+
const useFormStates_1 = require("./useFormStates");
|
|
8
|
+
describe("useFormStates", () => {
|
|
9
|
+
it("can lazily create form states", async () => {
|
|
10
|
+
const autoSave = jest.fn();
|
|
11
|
+
// Given a parent and child component, where the formState is created only for the child component.
|
|
12
|
+
function ChildComponent({ os }) {
|
|
13
|
+
return jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: os.firstName.value }), void 0);
|
|
14
|
+
}
|
|
15
|
+
function TestComponent() {
|
|
16
|
+
const config = { id: { type: "value" }, firstName: { type: "value" } };
|
|
17
|
+
const { getObjectState } = useFormStates_1.useFormStates({
|
|
18
|
+
config,
|
|
19
|
+
autoSave,
|
|
20
|
+
getId: (o) => o.id,
|
|
21
|
+
});
|
|
22
|
+
return (jsx_runtime_1.jsx("div", { children: jsx_runtime_1.jsx(ChildComponent, { os: getObjectState({ id: "a:1", firstName: "Brandon" }) }, void 0) }, void 0));
|
|
23
|
+
}
|
|
24
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
25
|
+
// And the child component has defined state
|
|
26
|
+
expect(r.firstName()).toHaveTextContent("Brandon");
|
|
27
|
+
});
|
|
28
|
+
it("can update existing object state from cache with new values", async () => {
|
|
29
|
+
const autoSave = jest.fn();
|
|
30
|
+
const config = { id: { type: "value" }, firstName: { type: "value" } };
|
|
31
|
+
// Given a component using `getObjectState` for lazily creating ObjectStates
|
|
32
|
+
function TestComponent() {
|
|
33
|
+
const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Brandon" });
|
|
34
|
+
const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
|
|
35
|
+
// Memoize an original for comparing the update against.
|
|
36
|
+
const originalState = react_1.useMemo(() => getObjectState(apiData), []);
|
|
37
|
+
const state = getObjectState(apiData);
|
|
38
|
+
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
|
|
39
|
+
jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "statesEqual" }, { children: JSON.stringify(state === originalState) }), void 0),
|
|
40
|
+
jsx_runtime_1.jsx("button", { "data-testid": "updateApiData", onClick: () => setApiData((prevState) => ({ ...prevState, firstName: "Bob" })) }, void 0)] }, void 0));
|
|
41
|
+
}
|
|
42
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
43
|
+
// And the initial values for the form state display
|
|
44
|
+
expect(r.firstName()).toHaveTextContent("Brandon");
|
|
45
|
+
expect(r.statesEqual()).toHaveTextContent("true");
|
|
46
|
+
// When updating the API data
|
|
47
|
+
await rtl_utils_1.clickAndWait(r.updateApiData());
|
|
48
|
+
// Then the new value is shown in the component
|
|
49
|
+
expect(r.firstName()).toHaveTextContent("Bob");
|
|
50
|
+
// And the two states are using the same reference
|
|
51
|
+
expect(r.statesEqual()).toHaveTextContent("true");
|
|
52
|
+
});
|
|
53
|
+
it("can queue up changes for auto save if a save is already in progress - works across multiple states", async () => {
|
|
54
|
+
const autoSaveStub = jest.fn();
|
|
55
|
+
const config = {
|
|
56
|
+
id: { type: "value" },
|
|
57
|
+
firstName: { type: "value" },
|
|
58
|
+
lastName: { type: "value" },
|
|
59
|
+
};
|
|
60
|
+
// Given a component using `getObjectState` for lazily creating ObjectStates
|
|
61
|
+
function TestComponent() {
|
|
62
|
+
const [apiData, setApiData] = react_1.useState({ id: "a:1", firstName: "Tony", lastName: "Stark" });
|
|
63
|
+
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);
|
|
67
|
+
async function autoSave(form) {
|
|
68
|
+
autoSaveStub(form.changedValue);
|
|
69
|
+
// Pretend to make an API call and update the local state
|
|
70
|
+
if (form.id.value === "a:1") {
|
|
71
|
+
setApiData((prevState) => ({ ...prevState, ...form.changedValue }));
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
setApiData2((prevState) => ({ ...prevState, ...form.changedValue }));
|
|
75
|
+
}
|
|
76
|
+
await Promise.resolve(1);
|
|
77
|
+
}
|
|
78
|
+
return (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "firstName" }, { children: state.firstName.value }), void 0),
|
|
79
|
+
jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField", onClick: () => {
|
|
80
|
+
state.firstName.focus();
|
|
81
|
+
state.firstName.set("Foo");
|
|
82
|
+
state.firstName.maybeAutoSave();
|
|
83
|
+
} }, void 0),
|
|
84
|
+
jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveFieldLastName", onClick: () => {
|
|
85
|
+
state.lastName.focus();
|
|
86
|
+
state.lastName.set("Bar");
|
|
87
|
+
state.lastName.maybeAutoSave();
|
|
88
|
+
} }, void 0),
|
|
89
|
+
jsx_runtime_1.jsx("button", { "data-testid": "focusSetAndSaveField2", onClick: () => {
|
|
90
|
+
state2.lastName.focus();
|
|
91
|
+
state2.lastName.set("Bar");
|
|
92
|
+
state2.lastName.maybeAutoSave();
|
|
93
|
+
} }, void 0)] }, void 0));
|
|
94
|
+
}
|
|
95
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
96
|
+
// And triggering the auto save behavior before awaiting the initial promise to resolve so we have pending changes.
|
|
97
|
+
rtl_utils_1.click(r.focusSetAndSaveField());
|
|
98
|
+
rtl_utils_1.click(r.focusSetAndSaveFieldLastName());
|
|
99
|
+
rtl_utils_1.click(r.focusSetAndSaveField2());
|
|
100
|
+
// Awaits the promises for all methods triggered above
|
|
101
|
+
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" });
|
|
106
|
+
expect(autoSaveStub).toBeCalledWith({ id: "a:2", lastName: "Bar" });
|
|
107
|
+
});
|
|
108
|
+
it("clears out cache if configuration changes", async () => {
|
|
109
|
+
const autoSave = jest.fn();
|
|
110
|
+
// Given a component with stable API data.
|
|
111
|
+
const apiData = { id: "a:1", firstName: "Brandon", lastName: "Dow" };
|
|
112
|
+
// And two sets of configurations
|
|
113
|
+
const originalConfig = { id: { type: "value" }, firstName: { type: "value" } };
|
|
114
|
+
const updatedConfig = {
|
|
115
|
+
id: { type: "value" },
|
|
116
|
+
firstName: { type: "value", rules: [formState_1.required] },
|
|
117
|
+
};
|
|
118
|
+
function TestComponent() {
|
|
119
|
+
const [config, setConfig] = react_1.useState(originalConfig);
|
|
120
|
+
const { getObjectState } = useFormStates_1.useFormStates({ config, autoSave, getId: (o) => o.id });
|
|
121
|
+
// Memoize an original for comparing the update against.
|
|
122
|
+
const originalState = react_1.useMemo(() => getObjectState(apiData), []);
|
|
123
|
+
const state = getObjectState(apiData);
|
|
124
|
+
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
|
+
jsx_runtime_1.jsx("button", { "data-testid": "updateConfig", onClick: () => setConfig(updatedConfig) }, void 0)] }, void 0));
|
|
126
|
+
}
|
|
127
|
+
// When rendered with the original configuration
|
|
128
|
+
const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
|
|
129
|
+
// Then the two form-states generated are equal
|
|
130
|
+
expect(r.statesEqual()).toHaveTextContent("true");
|
|
131
|
+
// When updating the configuration object
|
|
132
|
+
rtl_utils_1.click(r.updateConfig);
|
|
133
|
+
// Then the two form-states are no longer equal.
|
|
134
|
+
expect(r.statesEqual()).toHaveTextContent("false");
|
|
135
|
+
});
|
|
136
|
+
});
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
|
+
import { ObjectConfig } from "./formState";
|
|
1
2
|
export declare function fail(message?: string): never;
|
|
2
3
|
export declare function assertNever(x: never): never;
|
|
4
|
+
/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
|
|
5
|
+
export declare function initValue<T>(config: ObjectConfig<T>, init: any): T;
|
|
6
|
+
/**
|
|
7
|
+
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
8
|
+
*
|
|
9
|
+
* This is useful for creating a form input from a GraphQL query result,
|
|
10
|
+
* but ignoring extra data in the query that we don't want/need in the form.
|
|
11
|
+
*
|
|
12
|
+
* (Especially because the form.value will become our mutation's input type
|
|
13
|
+
* that goes on the wire, we don't want superfluous data in it.)
|
|
14
|
+
*/
|
|
15
|
+
export declare function pickFields<T, I>(formConfig: ObjectConfig<T>, instance: I): {
|
|
16
|
+
[K in keyof T]: K extends keyof I ? I[K] : never;
|
|
17
|
+
};
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.assertNever = exports.fail = void 0;
|
|
3
|
+
exports.pickFields = exports.initValue = exports.assertNever = exports.fail = void 0;
|
|
4
|
+
const is_plain_object_1 = require("is-plain-object");
|
|
5
|
+
const mobx_1 = require("mobx");
|
|
4
6
|
function fail(message) {
|
|
5
7
|
throw new Error(message || "Failed");
|
|
6
8
|
}
|
|
@@ -9,3 +11,60 @@ function assertNever(x) {
|
|
|
9
11
|
throw new Error("Unexpected object: " + x);
|
|
10
12
|
}
|
|
11
13
|
exports.assertNever = assertNever;
|
|
14
|
+
/** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
|
|
15
|
+
function initValue(config, init) {
|
|
16
|
+
const initValue = init && "input" in init && "map" in init
|
|
17
|
+
? init.input
|
|
18
|
+
? init.map(init.input)
|
|
19
|
+
: init.ifUndefined || {}
|
|
20
|
+
: init || {};
|
|
21
|
+
return pickFields(config, initValue);
|
|
22
|
+
}
|
|
23
|
+
exports.initValue = initValue;
|
|
24
|
+
/**
|
|
25
|
+
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
26
|
+
*
|
|
27
|
+
* This is useful for creating a form input from a GraphQL query result,
|
|
28
|
+
* but ignoring extra data in the query that we don't want/need in the form.
|
|
29
|
+
*
|
|
30
|
+
* (Especially because the form.value will become our mutation's input type
|
|
31
|
+
* that goes on the wire, we don't want superfluous data in it.)
|
|
32
|
+
*/
|
|
33
|
+
function pickFields(formConfig, instance) {
|
|
34
|
+
// If the caller is using classes, i.e. with their own custom observable behavior, then just use as-is
|
|
35
|
+
if (!is_plain_object_1.isPlainObject(instance)) {
|
|
36
|
+
return instance;
|
|
37
|
+
}
|
|
38
|
+
return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
|
|
39
|
+
const keyConfig = _keyConfig;
|
|
40
|
+
const value = instance[key];
|
|
41
|
+
if (keyConfig.type === "object") {
|
|
42
|
+
if (value) {
|
|
43
|
+
return [key, pickFields(keyConfig.config, value)];
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
return [key, value];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else if (keyConfig.type === "list") {
|
|
50
|
+
if (mobx_1.isObservable(value)) {
|
|
51
|
+
// If we hit an observable array, leave it as the existing proxy so the our
|
|
52
|
+
// ListFieldState will react to changes in the original array.
|
|
53
|
+
return [key, value];
|
|
54
|
+
}
|
|
55
|
+
else if (value) {
|
|
56
|
+
return [key, value.map((u) => pickFields(keyConfig.config, u))];
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
return [key, value];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (keyConfig.type === "value") {
|
|
63
|
+
return [key, value];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
return assertNever(keyConfig);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
exports.pickFields = pickFields;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const mobx_1 = require("mobx");
|
|
4
|
+
const formState_1 = require("./formState");
|
|
5
|
+
const utils_1 = require("./utils");
|
|
6
|
+
describe("utils", () => {
|
|
7
|
+
describe("pickFields", () => {
|
|
8
|
+
it("can pick a value field", () => {
|
|
9
|
+
const a = utils_1.pickFields(
|
|
10
|
+
//
|
|
11
|
+
{ firstName: { type: "value" } }, { firstName: "a", b: "ignored" });
|
|
12
|
+
expect(a).toMatchInlineSnapshot(`
|
|
13
|
+
Object {
|
|
14
|
+
"firstName": "a",
|
|
15
|
+
}
|
|
16
|
+
`);
|
|
17
|
+
});
|
|
18
|
+
it("can pick an unset object fields", () => {
|
|
19
|
+
const a = utils_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored" });
|
|
20
|
+
expect(a).toMatchInlineSnapshot(`
|
|
21
|
+
Object {
|
|
22
|
+
"address": undefined,
|
|
23
|
+
"firstName": "a",
|
|
24
|
+
"id": undefined,
|
|
25
|
+
"lastName": undefined,
|
|
26
|
+
}
|
|
27
|
+
`);
|
|
28
|
+
});
|
|
29
|
+
it("can pick a set object field", () => {
|
|
30
|
+
const a = utils_1.pickFields(authorWithAddressConfig, { firstName: "a", b: "ignored", address: {} });
|
|
31
|
+
expect(a).toMatchInlineSnapshot(`
|
|
32
|
+
Object {
|
|
33
|
+
"address": Object {
|
|
34
|
+
"city": undefined,
|
|
35
|
+
"street": undefined,
|
|
36
|
+
},
|
|
37
|
+
"firstName": "a",
|
|
38
|
+
"id": undefined,
|
|
39
|
+
"lastName": undefined,
|
|
40
|
+
}
|
|
41
|
+
`);
|
|
42
|
+
});
|
|
43
|
+
it("can pick an unset list field", () => {
|
|
44
|
+
const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored" });
|
|
45
|
+
expect(a).toMatchInlineSnapshot(`
|
|
46
|
+
Object {
|
|
47
|
+
"books": undefined,
|
|
48
|
+
"firstName": "a",
|
|
49
|
+
"id": undefined,
|
|
50
|
+
"lastName": undefined,
|
|
51
|
+
}
|
|
52
|
+
`);
|
|
53
|
+
});
|
|
54
|
+
it("can pick a set list field", () => {
|
|
55
|
+
const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books: [{}] });
|
|
56
|
+
expect(a).toMatchInlineSnapshot(`
|
|
57
|
+
Object {
|
|
58
|
+
"books": Array [
|
|
59
|
+
Object {
|
|
60
|
+
"classification": undefined,
|
|
61
|
+
"id": undefined,
|
|
62
|
+
"title": undefined,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
"firstName": "a",
|
|
66
|
+
"id": undefined,
|
|
67
|
+
"lastName": undefined,
|
|
68
|
+
}
|
|
69
|
+
`);
|
|
70
|
+
});
|
|
71
|
+
it("can pick a set observable list field", () => {
|
|
72
|
+
const books = mobx_1.observable([]);
|
|
73
|
+
const a = utils_1.pickFields(authorWithBooksConfig, { firstName: "a", b: "ignored", books });
|
|
74
|
+
expect(mobx_1.isObservable(a.books)).toEqual(true);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const authorWithAddressConfig = {
|
|
79
|
+
id: { type: "value" },
|
|
80
|
+
firstName: { type: "value" },
|
|
81
|
+
lastName: { type: "value" },
|
|
82
|
+
address: {
|
|
83
|
+
type: "object",
|
|
84
|
+
config: {
|
|
85
|
+
street: { type: "value", rules: [formState_1.required] },
|
|
86
|
+
city: { type: "value" },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const authorWithBooksConfig = {
|
|
91
|
+
id: { type: "value" },
|
|
92
|
+
firstName: { type: "value" },
|
|
93
|
+
lastName: { type: "value" },
|
|
94
|
+
books: {
|
|
95
|
+
type: "list",
|
|
96
|
+
config: {
|
|
97
|
+
id: { type: "value" },
|
|
98
|
+
title: { type: "value", rules: [formState_1.required] },
|
|
99
|
+
classification: { type: "value" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"author": "Homebound",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
"format": "prettier --loglevel warn --write \"**/*.{ts,tsx,css,md}\"",
|
|
22
22
|
"semantic-release": "semantic-release"
|
|
23
23
|
},
|
|
24
|
+
"husky": {
|
|
25
|
+
"hooks": {
|
|
26
|
+
"pre-commit": "lint-staged --allow-empty"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"lint-staged": {
|
|
30
|
+
"src/**/*.{ts,tsx,css,md}": "prettier --write"
|
|
31
|
+
},
|
|
24
32
|
"dependencies": {
|
|
25
33
|
"fast-deep-equal": "^3.1.3",
|
|
26
34
|
"is-plain-object": "^5.0.0",
|
|
@@ -59,8 +67,9 @@
|
|
|
59
67
|
"eslint-plugin-jsx-a11y": "^6.4.1",
|
|
60
68
|
"eslint-plugin-react": "^7.22.0",
|
|
61
69
|
"eslint-plugin-react-hooks": "^4.2.0",
|
|
62
|
-
"husky": "^
|
|
70
|
+
"husky": "^3.0.9",
|
|
63
71
|
"jest": "^27.0.2",
|
|
72
|
+
"lint-staged": "^10.1.2",
|
|
64
73
|
"mobx": "^6.3.2",
|
|
65
74
|
"mobx-react": "^7.2.0",
|
|
66
75
|
"prettier": "^2.2.1",
|