@homebound/form-state 2.15.3 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,27 +5,69 @@
5
5
 
6
6
  form-state is a headless form state management library, built on top of mobx.
7
7
 
8
- It acts as a buffer between the canonical data/entity (i.e. the server-side data, or local Redux/GraphQL/etc. cache data) and the user's WIP data/entity that is being actively mutated in form fields.
8
+ It acts as a buffer between the canonical data (i.e. the server-side data, or your app's GraphQL/Redux/etc. global store) and the user's WIP data that is being actively mutated in form fields (which is "too chatty"/WIP to store in the global store).
9
9
 
10
10
  It also keeps track of low-level form UX details like:
11
11
 
12
- * Which form fields are dirty
13
- * Which form fields are valid/invalid
14
- * Which form fields are touched (i.e. don't show validation errors for untouched fields)
15
- * Enabling/disabling buttons/form UX based on the overall form-wide state
16
- * Submitting the form should touch (validate) all fields
17
- * Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
18
- * Queueing auto-saves if one is already in-flight
19
- * Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
20
- * Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
21
- * Building a wire payload that has only changed fields
22
- * `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
23
- * Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
24
- * Child collections can be either exhausive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
12
+ - Which form fields are dirty
13
+ - Which form fields are valid/invalid
14
+ - Which form fields are touched (i.e. don't show validation errors for untouched fields)
15
+ - Enabling/disabling buttons/form UX based on the overall form-wide state
16
+ - Submitting the form should touch (validate) all fields
17
+ - Auto-saving the form when appropriate (i.e. not on keystroke, but after blur/leaving the field)
18
+ - Queueing auto-saves if one is already in-flight
19
+ - Auto-saves in a table with per-row forms will serialize to avoid cross-child write conflicts on the backend
20
+ - Not over-writing the user's WIP/actively-focused field when auto-saved data refreshes
21
+ - Building a wire payload that has only changed fields
22
+ - `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
23
+ - Supports collections of children, i.e. a `author: { books: [...} }` will include only changed books if necessary
24
+ - Child collections can be either exhausive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
25
25
 
26
- # The Three Type/Shapes Mental Model
26
+ # Main Features
27
27
 
28
- In general when working with forms (e.g. not just form-state), there are three types/shapes of data involved:
28
+ There are two main reasons why form-state exists:
29
+
30
+ 1. Provide a `FieldState` interface for component libraries
31
+ 2. Match the "three shapes" mental model of our forms
32
+
33
+ ## FieldState Interface & "One-Line" Forms
34
+
35
+ The core abstraction that `form-state` provides is a `FieldState` interface that looks like:
36
+
37
+ ```ts
38
+ // Very simplified example
39
+ interface FieldState {
40
+ value: V
41
+ errors: string[]
42
+ valid: boolean
43
+ touched: boolean
44
+ }
45
+ ```
46
+
47
+ Which combines all the logical aspects of "a single form field" into a single object/prop.
48
+
49
+ This facilitates the "gold standard" of form libraries, which is "one line per form field", i.e:
50
+
51
+ ```tsx
52
+ function AuthorEditorComponent() {
53
+ const author = useFormState(() => /* ... */ );
54
+ return (
55
+ <FormLines>
56
+ <BoundTextField field={author.firstName} />
57
+ <BoundTextField field={author.lastName} />
58
+ <BoundSelectField field={author.city} options={...cities...} />
59
+ </FormLines>
60
+ )
61
+ }
62
+ ```
63
+
64
+ Besides great developer ergonomics (low boilerplate, very DRY code), this approach also provides a very consistent UI/UX for users, because all forms get the highly-polish behavior of `BoundTextField` for free.
65
+
66
+ (See the `BoundTextField` in [Beam](https://github.com/homebound-team/beam) for an actual implementation of this approach.)
67
+
68
+ ## The Three Shapes Mental Model
69
+
70
+ In general when working with any forms (i.e. not just `form-state`), there are three types/shapes of data involved:
29
71
 
30
72
  1. The input data/shape from the server (i.e. a GraphQL/REST query)
31
73
  2. The form data/shape that is being reactively bound to form fields (i.e. used as `<TextField value={form.firstName} onChange={(v) => form.firstName = v} />`)
@@ -33,18 +75,17 @@ In general when working with forms (e.g. not just form-state), there are three t
33
75
 
34
76
  form-state generally refers to each of these shapes as:
35
77
 
36
- * The input type
37
- * (Hrm, in retrospect "input" is an unfortunate term b/c that is what GraphQL uses for its mutation types, i.e. `input SaveAuthorInput`...we should consider changing this).
38
- * The form type
39
- * The ...third type...
40
-
41
- And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
78
+ - The input type
79
+ - (Hrm, in retrospect "input" is an unfortunate term b/c that is what GraphQL uses for its mutation types, i.e. `input SaveAuthorInput`...we should consider changing this).
80
+ - The form type
81
+ - The ...third type...
42
82
 
83
+ And then provides an API/DSL for managing the mapping between each of these in a standard/conventional manner.
43
84
 
44
85
  Admittedly (and hopefully, b/c it makes the code simpler), the differences between each of these types can often be small, i.e.:
45
86
 
46
- * The input type might have `{ author: { book: { id: "b:1" } }` but the mutation wants `{ author: { bookId: "b:1" } }`
47
- * ...have other examples...
87
+ - The input type might have `{ author: { book: { id: "b:1" } }` but the mutation wants `{ author: { bookId: "b:1" } }`
88
+ - ...have other examples...
48
89
 
49
90
  These are usually simple/mechanistic changes, but nonetheless just some boilerplate that form-state provides conventions for.
50
91
 
@@ -60,7 +101,54 @@ See the [sample](https://github.com/homebound-team/form-state/blob/main/src/Form
60
101
 
61
102
  - Undo/redo would in theory be neat and easy to do on top of the existing infra
62
103
 
63
- # Internal Implementation
104
+ # Features
105
+
106
+ ## Fragments
107
+
108
+ Normally, form-state expects all fields in the form to be inputs to the GraphQL mutation/wire call. For example, the `author.firstName` field will always be submitted to the `saveAuthor` mutation (albeit with `author.changedValue` you can have `firstName` conditionally included).
109
+
110
+ However, sometimes there is "other data" that your UX needs to render the form, which is not strictly a form field, but would be handy for the data to "just be on the form" anyway, as you're passing it in around code.
111
+
112
+ A stereotypical example of this is GraphQL fragments, where an `AuthorFragment` might have a lot of misc read-only info that you want to display next to/within your form, but is not technically editable.
113
+
114
+ In form-state, you can model with as a `Fragment`, which is set up as:
115
+
116
+ ```ts
117
+ // Your input type, likely generated from GraphQL mutation
118
+ type AuthorInput = { firstName?: string };
119
+
120
+ // Your wire data likely from your page's GraphQL query to get
121
+ // the author to edit + also "misc other data"
122
+ type AuthorFragment = { firstName: string; miscOtherData: {} };
123
+
124
+ // For your page's form state, add-in the "extra data"
125
+ type AuthorForm = AuthorInput & {
126
+ // The `Fragment` type tells form-state this is not a regular form field
127
+ data: Fragment<AuthorFragment >
128
+ };
129
+
130
+ // Tell the form config the "fragment" is not a real field
131
+ const config: ObjectConfig<AuthorForm> = {
132
+ firstName: { type: "value", rules: [require] },
133
+ data: { type: "fragment" },
134
+ }
135
+
136
+ // Now in the component...
137
+ const data = useGraphQLQuery();
138
+ const form = useFormState({
139
+ config,
140
+ init: {
141
+ input: data,
142
+ map: (d) => ({
143
+ firstName: data.author.firstName,
144
+ data: fragment(data),
145
+ }),
146
+ },
147
+ });
148
+ ```
149
+
150
+
151
+ # Internal Implementation Notes
64
152
 
65
153
  form-state keeps the "actual data" (basically a POJO of your form data) separate from the "mobx proxies that track reactivity" (the `ObjectState` interface with `.get` / `.set` / `.errors` other methods).
66
154
 
@@ -69,6 +157,3 @@ This works well b/c the "actual data" returned from `ObjectState.value` or `Fiel
69
157
  However, it does mean that form-state internally uses a few "that looks odd" tricks like `_tick.value++` to ensure code like `formState.value.firstName` will be reactive, even though the `.firstName` is not actually a proxy access (but doing `formState.firstName.value` would be).
70
158
 
71
159
  (To be clear, both `formState.firstName.value` and `formState.value.firstName` return the same value, and also have the same reactivity semantics, this is just noting that form-state's internals need to do a few extra tricks to get the latter to be reactive.)
72
-
73
-
74
-
package/dist/config.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ObjectState } from "./fields/objectField";
1
+ import { Fragment, ObjectState } from "./fields/objectField";
2
2
  import { Rule } from "./rules";
3
3
  import { Builtin } from "./utils";
4
4
  /**
@@ -8,11 +8,15 @@ import { Builtin } from "./utils";
8
8
  * the caller define field-specific behavior, i.e. validation rules.
9
9
  */
10
10
  export declare type ObjectConfig<T> = {
11
- [P in keyof OmitIf<T, Function>]: T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfig<T, T[P]> : ListFieldConfig<T, U> : ValueFieldConfig<T, T[P]> | ObjectFieldConfig<T[P]>;
11
+ [P in keyof OmitIf<T, Function>]: T[P] extends Fragment<infer V> ? FragmentFieldConfig : T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfig<T, T[P]> : ListFieldConfig<T, U> : ValueFieldConfig<T, T[P]> | ObjectFieldConfig<T[P]>;
12
12
  };
13
13
  declare type OmitIf<Base, Condition> = Pick<Base, {
14
14
  [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
15
15
  }[keyof Base]>;
16
+ /** Field configuration for an opaque value that we don't actually want to include. */
17
+ export declare type FragmentFieldConfig = {
18
+ type: "fragment";
19
+ };
16
20
  /** Field configuration for primitive values, i.e. strings/numbers/Dates/user-defined types. */
17
21
  export declare type ValueFieldConfig<T, V> = {
18
22
  type: "value";
@@ -0,0 +1,4 @@
1
+ export interface FragmentField<V> {
2
+ value: V;
3
+ }
4
+ export declare function newFragmentField<T, K extends keyof T>(parentInstance: T, key: K): FragmentField<T[K]>;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.newFragmentField = void 0;
4
+ const mobx_1 = require("mobx");
5
+ const utils_1 = require("../utils");
6
+ function newFragmentField(parentInstance, key) {
7
+ // We always return the same `instance` field from our `value` method, but
8
+ // we want to pretend that it's observable, so use a tick to force it.
9
+ const _tick = mobx_1.observable({ value: 1 });
10
+ // We steal the fragment from our parent, so that it doesn't
11
+ // accidentally end up on the wire
12
+ let value = parentInstance[key];
13
+ delete parentInstance[key];
14
+ const obj = {
15
+ get value() {
16
+ _tick.value > 0 || utils_1.fail();
17
+ return value;
18
+ },
19
+ set value(v) {
20
+ value = v;
21
+ _tick.value++;
22
+ },
23
+ };
24
+ return mobx_1.makeAutoObservable(obj, { value: false });
25
+ }
26
+ exports.newFragmentField = newFragmentField;
@@ -1,4 +1,5 @@
1
1
  import { ObjectConfig } from "../config";
2
+ import { FragmentField } from "./fragmentField";
2
3
  import { ListFieldState } from "./listField";
3
4
  import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
4
5
  import { Builtin } from "../utils";
@@ -36,9 +37,14 @@ export declare type ObjectState<T, P = any> = FieldStates<T> & FieldState<P, T>
36
37
  export declare type ObjectStateInternal<T, P = any> = ObjectState<T, P> & {
37
38
  set(value: P, opts?: InternalSetOpts): void;
38
39
  };
40
+ declare const fragmentSym: unique symbol;
41
+ export declare type Fragment<V> = V & {
42
+ [fragmentSym]: true;
43
+ };
44
+ export declare function fragment<V>(value: V): Fragment<V>;
39
45
  /** For a given input type `T`, decorate each field into the "field state" type that holds our form-relevant state, i.e. valid/touched/etc. */
40
46
  declare type FieldStates<T> = {
41
- [K in keyof T]-?: T[K] extends Array<infer U> | null | undefined ? [U] extends [Builtin] ? FieldState<T, T[K]> : ListFieldState<T, U> : T[K] extends Builtin | null | undefined ? FieldState<T, T[K]> : ObjectState<T[K], T>;
47
+ [K in keyof T]-?: T[K] extends Fragment<infer V> ? FragmentField<V> : T[K] extends Array<infer U> | null | undefined ? [U] extends [Builtin] ? FieldState<T, T[K]> : ListFieldState<T, U> : T[K] extends Builtin | null | undefined ? FieldState<T, T[K]> : ObjectState<T[K], T>;
42
48
  };
43
49
  /**
44
50
  * Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
@@ -1,10 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.newObjectState = exports.createObjectState = void 0;
3
+ exports.newObjectState = exports.createObjectState = exports.fragment = void 0;
4
4
  const mobx_1 = require("mobx");
5
+ const fragmentField_1 = require("./fragmentField");
5
6
  const listField_1 = require("./listField");
6
7
  const valueField_1 = require("./valueField");
7
8
  const utils_1 = require("../utils");
9
+ const fragmentSym = Symbol("fragment");
10
+ function fragment(value) {
11
+ return value;
12
+ }
13
+ exports.fragment = fragment;
8
14
  /**
9
15
  * Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
10
16
  *
@@ -45,6 +51,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
45
51
  }
46
52
  field = newObjectState(config.config, getObjectState, undefined, instance[key], key, maybeAutoSave);
47
53
  }
54
+ else if (config.type === "fragment") {
55
+ field = fragmentField_1.newFragmentField(instance, key);
56
+ }
48
57
  else {
49
58
  throw new Error("Unsupported");
50
59
  }
@@ -101,7 +101,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
101
101
  }
102
102
  // If the user has deleted/emptied a value that was originally set, keep it as `null`
103
103
  // so that our partial update to the backend correctly unsets it.
104
- const keepNull = !utils_1.isEmpty(this.originalValue) && utils_1.isEmpty(value);
104
+ const keepNull = !utils_1.isEmpty(this.originalValue) && utils_1.isEmpty(value) && !opts.refreshing;
105
105
  // If a list of primitives was originally undefined, coerce `[]` to `undefined`
106
106
  const coerceEmptyList = value && value instanceof Array && value.length === 0 && utils_1.isEmpty(this.originalValue);
107
107
  const newValue = keepNull ? null : utils_1.isEmpty(value) || coerceEmptyList ? undefined : value;
@@ -135,7 +135,9 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
135
135
  },
136
136
  get originalValue() {
137
137
  // A dummy check to for reactivity around our non-proxy value
138
- return _originalValueTick.value > -1 ? _originalValue : _originalValue;
138
+ const value = _originalValueTick.value > -1 ? _originalValue : _originalValue;
139
+ // Re-create the `keepNull` logic so that `.value` === `.originalValue`
140
+ return value === null ? undefined : value;
139
141
  },
140
142
  set originalValue(v) {
141
143
  _originalValue = v;
@@ -1338,6 +1338,41 @@ describe("formState", () => {
1338
1338
  // The list is again dirty
1339
1339
  expect(a1.books.dirty).toBeTruthy();
1340
1340
  });
1341
+ describe("fragments", () => {
1342
+ it("ignores changes within fragments", () => {
1343
+ const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
1344
+ let numCalcs = 0;
1345
+ mobx_1.autorun(() => {
1346
+ numCalcs++;
1347
+ noop(a.value);
1348
+ });
1349
+ expect(a.data.value).toEqual({ foo: "1" });
1350
+ expect(mobx_1.isObservable(a.data.value)).toBe(false);
1351
+ expect(numCalcs).toEqual(1);
1352
+ a.data.value = { foo: "2" };
1353
+ expect(numCalcs).toEqual(2);
1354
+ a.data.value.foo = "3";
1355
+ expect(numCalcs).toEqual(2);
1356
+ });
1357
+ it("ignores fragments in changedValue", () => {
1358
+ const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
1359
+ a.title.value = "b2";
1360
+ a.data.value = objectField_1.fragment({ foo: "2" });
1361
+ expect(a.changedValue).toMatchInlineSnapshot(`
1362
+ Object {
1363
+ "title": "b2",
1364
+ }
1365
+ `);
1366
+ });
1367
+ it("ignores fragments in value", () => {
1368
+ const a = objectField_1.createObjectState({ title: { type: "value" }, data: { type: "fragment" } }, { title: "b1", data: objectField_1.fragment({ foo: "1" }) });
1369
+ expect(a.value).toMatchInlineSnapshot(`
1370
+ Object {
1371
+ "title": "b1",
1372
+ }
1373
+ `);
1374
+ });
1375
+ });
1341
1376
  });
1342
1377
  class ObservableObject {
1343
1378
  constructor() {
@@ -1409,3 +1444,4 @@ const authorWithAddressAndBooksConfig = {
1409
1444
  function createAuthorWithAddressInputState(input) {
1410
1445
  return objectField_1.createObjectState(authorWithAddressConfig, input);
1411
1446
  }
1447
+ function noop(t) { }
@@ -91,6 +91,12 @@ function useFormState(opts) {
91
91
  return;
92
92
  }
93
93
  setLoading(form, opts);
94
+ // Note: maybe someday we want to watch the `id` value and notice if it's changing (i.e. 2 -> 3),
95
+ // and treat this more as a reset than a refresh, b/c the id changing means it's probably
96
+ // not "the cache refreshed after a mutation save" but "the cache changed b/c of changing
97
+ // rows in the table that our side panel's form has open/is focused on" (which ideally would
98
+ // be treated as a full component remount by having an `key` field somewhere in the parent
99
+ // component, but it's unlikely the user will always remember to do this).
94
100
  form.set(utils_1.initValue(config, init), { refreshing: true });
95
101
  }, [form, ...dep]);
96
102
  // Use useEffect so that we don't touch the form.init proxy during a render
@@ -528,6 +528,41 @@ describe("useFormState", () => {
528
528
  expect(result.current.autoSave.status).toBe(AutoSaveStatusProvider_1.AutoSaveStatus.DONE);
529
529
  jest.clearAllTimers();
530
530
  });
531
+ it("treats the id changing as a whole new entity instead of a delete", async () => {
532
+ const config = { firstName: { type: "value" } };
533
+ function TestComponent() {
534
+ // Given an initial author a1
535
+ const [author, setAuthor] = react_2.useState({ id: "a:1", firstName: "a1" });
536
+ const form = useFormState_1.useFormState({ config, init: { input: author, map: (a) => a } });
537
+ return (jsx_runtime_1.jsx(mobx_react_1.Observer, { children: () => (jsx_runtime_1.jsxs("div", { children: [jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "value" }, { children: String(form.firstName.value) }), void 0),
538
+ jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "dirty" }, { children: String(form.firstName.dirty) }), void 0),
539
+ jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "originalValue" }, { children: String(form.firstName.originalValue) }), void 0),
540
+ jsx_runtime_1.jsx("div", Object.assign({ "data-testid": "objectValue" }, { children: String(form.value.firstName) }), void 0),
541
+ jsx_runtime_1.jsx("div", { "data-testid": "a1", onClick: () => setAuthor({ id: "a:1", firstName: "a1" }) }, void 0),
542
+ jsx_runtime_1.jsx("div", { "data-testid": "a2", onClick: () => setAuthor({ id: "a:2", firstName: undefined }) }, void 0)] }, void 0)) }, void 0));
543
+ }
544
+ const r = await rtl_utils_1.render(jsx_runtime_1.jsx(TestComponent, {}, void 0));
545
+ // And the value is initially a1/and not dirty
546
+ expect(r.value()).toHaveTextContent("a1");
547
+ expect(r.dirty()).toHaveTextContent("false");
548
+ expect(r.originalValue()).toHaveTextContent("a1");
549
+ expect(r.objectValue()).toHaveTextContent("a1");
550
+ // When we switch to a completely separate author
551
+ rtl_utils_1.click(r.a2);
552
+ // Then it switches to the next author
553
+ expect(r.value()).toHaveTextContent("undefined");
554
+ // And the field is not dirty (which had been the case before this bug fix)
555
+ expect(r.dirty()).toHaveTextContent("false");
556
+ expect(r.originalValue()).toHaveTextContent("undefined");
557
+ expect(r.objectValue()).toHaveTextContent("undefined");
558
+ // And when we switch back to the original author
559
+ rtl_utils_1.click(r.a1);
560
+ // It again restores the value and does not think an edit was WIP
561
+ expect(r.value()).toHaveTextContent("a1");
562
+ expect(r.dirty()).toHaveTextContent("false");
563
+ expect(r.originalValue()).toHaveTextContent("a1");
564
+ expect(r.objectValue()).toHaveTextContent("a1");
565
+ });
531
566
  });
532
567
  const authorConfig = {
533
568
  id: { type: "value" },
package/dist/utils.d.ts CHANGED
@@ -35,7 +35,7 @@ export declare function isEmpty(value: any): boolean;
35
35
  *
36
36
  * - "plain" objects that have no custom prototype/i.e. are object literals
37
37
  * - objects that implement `toJSON`
38
- *
38
+ * - arrays
39
39
  */
40
40
  export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolean;
41
41
  export declare function hasToJSON(o?: unknown): o is {
package/dist/utils.js CHANGED
@@ -100,7 +100,7 @@ exports.isEmpty = isEmpty;
100
100
  *
101
101
  * - "plain" objects that have no custom prototype/i.e. are object literals
102
102
  * - objects that implement `toJSON`
103
- *
103
+ * - arrays
104
104
  */
105
105
  function areEqual(a, b, strictOrder) {
106
106
  if (is_plain_object_1.isPlainObject(a)) {
@@ -117,7 +117,7 @@ function areEqual(a, b, strictOrder) {
117
117
  }
118
118
  if (a.length !== b.length)
119
119
  return false;
120
- return a.every((a1) => b.some((b1) => fast_deep_equal_1.default(a1, b1)));
120
+ return a.every((a1) => b.some((b1) => areEqual(a1, b1)));
121
121
  }
122
122
  return a === b;
123
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.15.3",
3
+ "version": "2.16.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",