@homebound/form-state 2.20.1 → 2.22.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,7 +5,7 @@
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 (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).
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 push back into global stores).
9
9
 
10
10
  It also keeps track of low-level form UX details like:
11
11
 
@@ -21,7 +21,7 @@ It also keeps track of low-level form UX details like:
21
21
  - Building a wire payload that has only changed fields
22
22
  - `form.changedValue` will return the entity `id` + only changed fields to faciliate doing partial update APIs
23
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
24
+ - Child collections can be either exhaustive (if any child changes, submit them all) or incremental (only include changed children), to match the backend endpoint's semantics
25
25
 
26
26
  # Main Features
27
27
 
@@ -37,16 +37,17 @@ The core abstraction that `form-state` provides is a `FieldState` interface that
37
37
  ```ts
38
38
  // Very simplified example
39
39
  interface FieldState {
40
- value: V
41
- errors: string[]
42
- valid: boolean
43
- touched: boolean
40
+ // Can be used to read & write the value bound into the form field
41
+ value: V;
42
+ errors: string[];
43
+ valid: boolean;
44
+ touched: boolean;
44
45
  }
45
46
  ```
46
47
 
47
48
  Which combines all the logical aspects of "a single form field" into a single object/prop.
48
49
 
49
- This facilitates the "gold standard" of form libraries, which is "one line per form field", i.e:
50
+ This facilitates the "gold standard" of form DX, which is "one line per form field", i.e:
50
51
 
51
52
  ```tsx
52
53
  function AuthorEditorComponent() {
@@ -124,14 +125,14 @@ type AuthorFragment = { firstName: string; miscOtherData: {} };
124
125
  // For your page's form state, add-in the "extra data"
125
126
  type AuthorForm = AuthorInput & {
126
127
  // The `Fragment` type tells form-state this is not a regular form field
127
- data: Fragment<AuthorFragment >
128
+ data: Fragment<AuthorFragment>;
128
129
  };
129
130
 
130
131
  // Tell the form config the "fragment" is not a real field
131
132
  const config: ObjectConfig<AuthorForm> = {
132
133
  firstName: { type: "value", rules: [require] },
133
134
  data: { type: "fragment" },
134
- }
135
+ };
135
136
 
136
137
  // Now in the component...
137
138
  const data = useGraphQLQuery();
@@ -147,7 +148,6 @@ const form = useFormState({
147
148
  });
148
149
  ```
149
150
 
150
-
151
151
  # Internal Implementation Notes
152
152
 
153
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).
@@ -33,17 +33,15 @@ function FormStateApp() {
33
33
  }
34
34
  exports.FormStateApp = FormStateApp;
35
35
  // Configure the fields/behavior for AuthorInput's fields
36
- const formConfig = {
37
- firstName: { type: "value", rules: [index_1.required] },
38
- lastName: { type: "value", rules: [index_1.required], readOnly: true },
39
- books: {
40
- type: "list",
41
- rules: [({ value: list }) => ((list || []).length === 0 ? "Empty" : undefined)],
42
- config: {
43
- title: { type: "value", rules: [index_1.required] },
44
- },
45
- },
46
- };
36
+ const formConfig = index_1.f.config({
37
+ firstName: index_1.f.value().req(),
38
+ lastName: index_1.f.value().readOnly(),
39
+ books: index_1.f
40
+ .list({
41
+ title: index_1.f.value().req(),
42
+ })
43
+ .rule(({ value }) => (value.length === 0 ? "Empty" : undefined)),
44
+ });
47
45
  function TextField(props) {
48
46
  const { field } = props;
49
47
  // Somewhat odd: input won't update unless we use <Observer>, even though our
package/dist/config.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Fragment, ObjectState } from "./fields/objectField";
2
2
  import { Rule } from "./rules";
3
- import { Builtin } from "./utils";
3
+ import { Builtin, OmitIf } from "./utils";
4
4
  /**
5
5
  * Config rules for each field in `T` that we're editing in a form.
6
6
  *
@@ -8,17 +8,14 @@ import { Builtin } from "./utils";
8
8
  * the caller define field-specific behavior, i.e. validation rules.
9
9
  */
10
10
  export type ObjectConfig<T> = {
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]>;
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[P]> : ListFieldConfig<U> : ValueFieldConfig<T[P]> | ObjectFieldConfig<T[P]>;
12
12
  };
13
- type OmitIf<Base, Condition> = Pick<Base, {
14
- [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
15
- }[keyof Base]>;
16
13
  /** Field configuration for an opaque value that we don't actually want to include. */
17
14
  export type FragmentFieldConfig = {
18
15
  type: "fragment";
19
16
  };
20
17
  /** Field configuration for primitive values, i.e. strings/numbers/Dates/user-defined types. */
21
- export type ValueFieldConfig<T, V> = {
18
+ export type ValueFieldConfig<V> = {
22
19
  type: "value";
23
20
  rules?: Rule<V | null | undefined>[];
24
21
  /**
@@ -45,7 +42,7 @@ export type ValueFieldConfig<T, V> = {
45
42
  strictOrder?: false;
46
43
  };
47
44
  /** Field configuration for list values, i.e. `U` is `Book` in a form with `books: Book[]`. */
48
- export type ListFieldConfig<T, U> = {
45
+ export type ListFieldConfig<U> = {
49
46
  type: "list";
50
47
  /** Rules that can run on the full list of children. */
51
48
  rules?: Rule<readonly ObjectState<U>[]>[];
@@ -71,4 +68,3 @@ export type ObjectFieldConfig<U> = {
71
68
  /** Config for the child's form state, i.e. each book. */
72
69
  config: ObjectConfig<U>;
73
70
  };
74
- export {};
@@ -0,0 +1,60 @@
1
+ import { FragmentFieldConfig, ListFieldConfig, ObjectConfig, ValueFieldConfig } from "./config";
2
+ import { Fragment, ObjectState } from "./fields/objectField";
3
+ import { Rule } from "./rules";
4
+ import { Builtin, OmitIf } from "./utils";
5
+ /**
6
+ * Provides a Zod-ish API for building form configs.
7
+ */
8
+ export declare const f: {
9
+ /** Creates the top-level config for a form, like an author or book. */
10
+ config<T>(fields: ObjectConfigBuilderFields<T>): ObjectConfig<T>;
11
+ /** Creates the config DSL for an object, like an author or book. */
12
+ object<T_1>(fields: ObjectConfigBuilderFields<T_1>): ObjectConfigBuilder<T_1>;
13
+ /** Creates the config DSL for an list of objects, like an author's list of books. */
14
+ list<U>(fields: ObjectConfigBuilderFields<U>): ListFieldConfigBuilder<U>;
15
+ /** Creates the config DSL for a primitive value, like a first name or phone number. */
16
+ value<V>(): ValueFieldConfigBuilder<V>;
17
+ /** A shorthand for creating a computed value. */
18
+ computed<V_1>(): ValueFieldConfigBuilder<V_1>;
19
+ };
20
+ /**
21
+ * Defines the fields that should be passed into `f.object({ ... })` for fhe form type `T`.
22
+ *
23
+ * This is basically a map of `key` in `T` to `f.value()`, `f.list()`, or `f.object()`
24
+ * DSL objects that define the runtime structure/fields of the form.
25
+ */
26
+ export type ObjectConfigBuilderFields<T> = {
27
+ [P in keyof OmitIf<T, Function>]: T[P] extends Fragment<infer V> ? FragmentFieldConfigBuilder : T[P] extends Array<infer U> | null | undefined ? U extends Builtin ? ValueFieldConfigBuilder<T[P]> : ListFieldConfigBuilder<U> : ValueFieldConfigBuilder<T[P]> | ObjectConfigBuilder<T[P]>;
28
+ };
29
+ export interface FragmentFieldConfigBuilder {
30
+ build(): FragmentFieldConfig;
31
+ }
32
+ /** Provides a fluent DSL for building up an object's config. */
33
+ export declare class ObjectConfigBuilder<T> {
34
+ private config;
35
+ constructor(fields: ObjectConfigBuilderFields<T>);
36
+ build(): ObjectConfig<T>;
37
+ }
38
+ /** Provides a fluent DSL for building up a value's config. */
39
+ export declare class ValueFieldConfigBuilder<V> {
40
+ private config;
41
+ /** Marks the field as required. */
42
+ req(): this;
43
+ /** Marks the field as read only. */
44
+ readOnly(): this;
45
+ /** Appends `rules` to the field's validation rules. */
46
+ rules(rules: Rule<V | null | undefined>[]): this;
47
+ /** Marks the field as computed. */
48
+ computed(): this;
49
+ build(): ValueFieldConfig<V>;
50
+ }
51
+ /** Provides a fluent DSL for building up a list's config. */
52
+ export declare class ListFieldConfigBuilder<U> {
53
+ private config;
54
+ constructor(fields: ObjectConfigBuilderFields<U>);
55
+ /** Appends `rule` to the field's validation rules. */
56
+ rule(rule: Rule<readonly ObjectState<U>[]>): this;
57
+ /** Appends `rules` to the field's validation rules. */
58
+ rules(rules: Rule<readonly ObjectState<U>[]>[]): this;
59
+ build(): ListFieldConfig<U>;
60
+ }
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ListFieldConfigBuilder = exports.ValueFieldConfigBuilder = exports.ObjectConfigBuilder = exports.f = void 0;
4
+ const rules_1 = require("./rules");
5
+ /**
6
+ * Provides a Zod-ish API for building form configs.
7
+ */
8
+ exports.f = {
9
+ /** Creates the top-level config for a form, like an author or book. */
10
+ config(fields) {
11
+ return new ObjectConfigBuilder(fields).build();
12
+ },
13
+ /** Creates the config DSL for an object, like an author or book. */
14
+ object(fields) {
15
+ return new ObjectConfigBuilder(fields);
16
+ },
17
+ /** Creates the config DSL for an list of objects, like an author's list of books. */
18
+ list(fields) {
19
+ return new ListFieldConfigBuilder(fields);
20
+ },
21
+ /** Creates the config DSL for a primitive value, like a first name or phone number. */
22
+ value() {
23
+ return new ValueFieldConfigBuilder();
24
+ },
25
+ /** A shorthand for creating a computed value. */
26
+ computed() {
27
+ return this.value().computed();
28
+ },
29
+ };
30
+ /** Provides a fluent DSL for building up an object's config. */
31
+ class ObjectConfigBuilder {
32
+ constructor(fields) {
33
+ this.config = {};
34
+ for (const [key, value] of Object.entries(fields)) {
35
+ this.config[key] = value.build();
36
+ }
37
+ }
38
+ build() {
39
+ return this.config;
40
+ }
41
+ }
42
+ exports.ObjectConfigBuilder = ObjectConfigBuilder;
43
+ /** Provides a fluent DSL for building up a value's config. */
44
+ class ValueFieldConfigBuilder {
45
+ constructor() {
46
+ this.config = { type: "value", rules: [] };
47
+ }
48
+ /** Marks the field as required. */
49
+ req() {
50
+ this.config.rules = [rules_1.required];
51
+ return this;
52
+ }
53
+ /** Marks the field as read only. */
54
+ readOnly() {
55
+ this.config.readOnly = true;
56
+ return this;
57
+ }
58
+ /** Appends `rules` to the field's validation rules. */
59
+ rules(rules) {
60
+ var _a;
61
+ var _b;
62
+ ((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(...rules);
63
+ return this;
64
+ }
65
+ /** Marks the field as computed. */
66
+ computed() {
67
+ this.config.computed = true;
68
+ return this;
69
+ }
70
+ build() {
71
+ return this.config;
72
+ }
73
+ }
74
+ exports.ValueFieldConfigBuilder = ValueFieldConfigBuilder;
75
+ /** Provides a fluent DSL for building up a list's config. */
76
+ class ListFieldConfigBuilder {
77
+ constructor(fields) {
78
+ const config = {};
79
+ for (const [key, value] of Object.entries(fields)) {
80
+ config[key] = value.build();
81
+ }
82
+ this.config = { type: "list", rules: [], config };
83
+ }
84
+ /** Appends `rule` to the field's validation rules. */
85
+ rule(rule) {
86
+ var _a;
87
+ var _b;
88
+ ((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(rule);
89
+ return this;
90
+ }
91
+ /** Appends `rules` to the field's validation rules. */
92
+ rules(rules) {
93
+ var _a;
94
+ var _b;
95
+ ((_a = (_b = this.config).rules) !== null && _a !== void 0 ? _a : (_b.rules = [])).push(...rules);
96
+ return this;
97
+ }
98
+ build() {
99
+ return this.config;
100
+ }
101
+ }
102
+ exports.ListFieldConfigBuilder = ListFieldConfigBuilder;
@@ -3,9 +3,9 @@ import { ObjectState } from "./objectField";
3
3
  import { FieldState } from "./valueField";
4
4
  import { Rule } from "../rules";
5
5
  /** Form state for list of children, i.e. `U` is a `Book` in a form with a `books: Book[]`. */
6
- export interface ListFieldState<U> extends Omit<FieldState<U[]>, "originalValue"> {
6
+ export interface ListFieldState<U> extends FieldState<U[]> {
7
7
  readonly rows: ReadonlyArray<ObjectState<U>>;
8
8
  add(value: U, index?: number): void;
9
9
  remove(indexOrValue: number | U): void;
10
10
  }
11
- export declare function newListFieldState<T, K extends keyof T, U>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<T, U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
11
+ export declare function newListFieldState<T, K extends keyof T, U>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<readonly ObjectState<U>[]>[], listConfig: ListFieldConfig<U>, config: ObjectConfig<U>, strictOrder: boolean, maybeAutoSave: () => void): ListFieldState<U>;
@@ -5,13 +5,20 @@ const mobx_1 = require("mobx");
5
5
  const objectField_1 = require("./objectField");
6
6
  const rules_1 = require("../rules");
7
7
  const utils_1 = require("../utils");
8
- function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
8
+ function newListFieldState(parentCopy, parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave) {
9
+ var _a;
9
10
  // Keep a map of "item in the parent list" -> "that item's ObjectState"
10
11
  const rowMap = new Map();
11
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
13
+ const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
12
14
  const _childTick = (0, mobx_1.observable)({ value: 1 });
13
- // this is for dirty checking, not object identity
14
- let originalCopy = [...(parentInstance[key] || [])];
15
+ // When child rows don't have ids (i.e. for new rows that aren't saved yet), we need an id-less
16
+ // "clone <-> current" map to tell if we're dirty or not, i.e. whether `parentInstance[key]` has
17
+ // drifted from `parentCopy[key]`
18
+ const copyMap = new Map();
19
+ ((_a = parentCopy[key]) !== null && _a !== void 0 ? _a : []).forEach((copy, i) => {
20
+ copyMap.set(copy, parentInstance[key][i]);
21
+ });
15
22
  const list = {
16
23
  key: key,
17
24
  // Our fundamental state of wrapped Us
@@ -46,22 +53,19 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
46
53
  return parentState().isNewEntity;
47
54
  },
48
55
  // private
56
+ /** Returns whether a row has been added/removed. */
49
57
  hasChanged() {
50
- const [current, original] = [this.value || [], originalCopy || []];
51
- if (current.length !== original.length) {
58
+ const [current, original] = [this.value || [], this.originalValue];
59
+ if (current.length !== original.length)
52
60
  return true;
53
- }
54
61
  if (strictOrder) {
55
- return original.some((e, idx) => {
56
- const originalState = rowMap.get(e);
57
- const currentState = rowMap.get(current[idx]);
58
- return originalState !== currentState;
59
- });
62
+ // With strict order, every copy[i] === original[i] must be true
63
+ return original.some((e, idx) => current[idx] !== copyMap.get(e));
64
+ }
65
+ else {
66
+ // With loose order, we just want every copy to still have its original somewhere
67
+ return !original.every((e) => current.includes(copyMap.get(e)));
60
68
  }
61
- return !original.every((e) => {
62
- const state = rowMap.get(e);
63
- return current.some((e) => rowMap.get(e) === state);
64
- });
65
69
  },
66
70
  // And we can derive each value's ObjectState wrapper as needed from the rowMap cache
67
71
  get rows() {
@@ -92,7 +96,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
92
96
  get valid() {
93
97
  const value = this.rows;
94
98
  // TODO Passing `originalCopy || []` is probably not 100% right
95
- const opts = { value, key: key, originalValue: originalCopy || [], object: parentState() };
99
+ const opts = { value, key: key, originalValue: this.originalValue, object: parentState() };
96
100
  const collectionValid = this.rules.every((r) => r(opts) === undefined);
97
101
  const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
98
102
  return collectionValid && entriesValid;
@@ -100,7 +104,7 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
100
104
  get errors() {
101
105
  if (_tick.value < 0)
102
106
  (0, utils_1.fail)();
103
- const opts = { value: this.rows, key: key, originalValue: originalCopy || [], object: parentState() };
107
+ const opts = { value: this.rows, key: key, originalValue: this.originalValue, object: parentState() };
104
108
  return this.rules.map((r) => r(opts)).filter(utils_1.isNotUndefined);
105
109
  },
106
110
  get changedValue() {
@@ -143,6 +147,11 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
143
147
  // We should be passed values that are non-proxies.
144
148
  parentInstance[key] = values.map((value) => {
145
149
  let childState = rowMap.get(value);
150
+ // If we're being reverted to our originalValue, i.e. values is actually
151
+ // a list of copies, use the copyMap to recover our child states
152
+ if (!childState && copyMap.has(value)) {
153
+ childState = rowMap.get(copyMap.get(value));
154
+ }
146
155
  if (!childState) {
147
156
  // Look for an existing child (requires having an id key configured)
148
157
  for (const [, otherState] of rowMap.entries()) {
@@ -159,11 +168,12 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
159
168
  // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
160
169
  return childState.value;
161
170
  });
171
+ // Make sure to tick first so that `setOriginalValue` sees the latest `rows`
172
+ _tick.value++;
162
173
  // Reset originalCopy so that our dirty checks have the right # of rows.
163
174
  if (opts.refreshing) {
164
- originalCopy = [...(parentInstance[key] || [])];
175
+ this.setOriginalValue();
165
176
  }
166
- _tick.value++;
167
177
  },
168
178
  add(value, spliceIndex) {
169
179
  // This is called by the user, so value should be a non-proxy value we should keep
@@ -189,16 +199,31 @@ function newListFieldState(parentInstance, parentState, key, rules, listConfig,
189
199
  maybeAutoSave();
190
200
  },
191
201
  revertChanges() {
192
- if (originalCopy) {
193
- this.set(originalCopy, { resetting: true });
194
- this.rows.forEach((r) => r.revertChanges());
195
- }
202
+ this.set(this.originalValue, { resetting: true });
203
+ this.rows.forEach((r) => r.revertChanges());
204
+ this.touched = false;
196
205
  },
197
206
  commitChanges() {
207
+ // Tell each child ObjectState to have its fields commit into its parentCopy
198
208
  this.rows.forEach((r) => r.commitChanges());
199
- originalCopy = [...(parentInstance[key] || [])];
209
+ this.setOriginalValue();
210
+ this.touched = false;
200
211
  _tick.value++;
201
212
  },
213
+ get originalValue() {
214
+ // A dummy check to for reactivity around our non-proxy value
215
+ const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
216
+ return value !== null && value !== void 0 ? value : [];
217
+ },
218
+ // This should only be called when value === originalValue
219
+ setOriginalValue() {
220
+ // Use the rows' originalValues to update the parentCopy
221
+ parentCopy[key] = this.rows.map((r) => r.originalValue);
222
+ parentCopy[key].forEach((copy, i) => {
223
+ copyMap.set(copy, parentInstance[key][i]);
224
+ });
225
+ _originalValueTick.value++;
226
+ },
202
227
  ensureSet() {
203
228
  if (!parentInstance[key]) {
204
229
  parentInstance[key] = [];
@@ -36,6 +36,7 @@ export type ObjectState<T> = FieldStates<T> & FieldState<T> & {
36
36
  };
37
37
  export type ObjectStateInternal<T> = ObjectState<T> & {
38
38
  set(value: T, opts?: InternalSetOpts): void;
39
+ isSameEntity(other: T): boolean;
39
40
  };
40
41
  declare const fragmentSym: unique symbol;
41
42
  export type Fragment<V> = V & {
@@ -34,20 +34,26 @@ function newObjectState(config, parentState, parentListState, instance, key, may
34
34
  }
35
35
  return proxy;
36
36
  }
37
+ // We directly mutate `instance` as the user edits the form, so keep a deep copy of the POJO.
38
+ // If the user passed us `instance` that was a mobx store/class, this originalValue won't really
39
+ // be a true match (i.e. pass instanceof), but it should be good enough
40
+ const originalCopy = (0, utils_1.deepClone)(instance);
37
41
  const objectConfig = config;
38
42
  const fieldStates = Object.entries(config).map(([_key, _config]) => {
39
- var _a, _b;
43
+ var _a, _b, _c, _d;
40
44
  const key = _key;
41
45
  const config = _config;
42
46
  let field;
43
47
  if (config.type === "value") {
44
- field = (0, valueField_1.newValueFieldState)(instance, getObjectState, key, config.rules || [], config.isIdKey ||
48
+ field = (0, valueField_1.newValueFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config.isIdKey ||
45
49
  // Default the id key to "id" unless some other field has isIdKey set
46
50
  (key === "id" &&
47
- !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, (_a = config.strictOrder) !== null && _a !== void 0 ? _a : true, maybeAutoSave);
51
+ !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey)), config.isDeleteKey || false, config.isReadOnlyKey || false, (_a = config.computed) !== null && _a !== void 0 ? _a :
52
+ // If instance is a mobx class, we can detect computeds as they won't be enumerable
53
+ ((0, mobx_1.isObservable)(instance) ? !(key in originalCopy) : false), (_b = config.readOnly) !== null && _b !== void 0 ? _b : false, (_c = config.strictOrder) !== null && _c !== void 0 ? _c : true, maybeAutoSave);
48
54
  }
49
55
  else if (config.type === "list") {
50
- field = (0, listField_1.newListFieldState)(instance, getObjectState, key, config.rules || [], config, config.config, (_b = config.strictOrder) !== null && _b !== void 0 ? _b : true, maybeAutoSave);
56
+ field = (0, listField_1.newListFieldState)(originalCopy, instance, getObjectState, key, config.rules || [], config, config.config, (_d = config.strictOrder) !== null && _d !== void 0 ? _d : true, maybeAutoSave);
51
57
  }
52
58
  else if (config.type === "object") {
53
59
  if (!instance[key]) {
@@ -176,7 +182,8 @@ function newObjectState(config, parentState, parentListState, instance, key, may
176
182
  return result;
177
183
  },
178
184
  get originalValue() {
179
- return instance;
185
+ getFields(proxy).map((f) => f.originalValue);
186
+ return originalCopy;
180
187
  },
181
188
  // An internal helper method to see if `other` is for "the same entity" as our current row
182
189
  isSameEntity(other) {
@@ -184,7 +191,9 @@ function newObjectState(config, parentState, parentListState, instance, key, may
184
191
  if (!idField) {
185
192
  return false;
186
193
  }
187
- return this[idField.key].value === other[idField.key];
194
+ // If the otherIdValue is undefined, it's a new entity so can't be the same as us
195
+ const otherIdValue = other[idField.key];
196
+ return otherIdValue !== undefined && otherIdValue === this[idField.key].value;
188
197
  },
189
198
  };
190
199
  proxy = (0, mobx_1.makeAutoObservable)(obj, {
@@ -193,6 +202,7 @@ function newObjectState(config, parentState, parentListState, instance, key, may
193
202
  // to mobx this looks like the value never changes, and it will never invoke observers
194
203
  // even with our tick-based hacks.
195
204
  value: (0, mobx_1.computed)({ equals: () => false }),
205
+ originalValue: (0, mobx_1.computed)({ equals: () => false }),
196
206
  });
197
207
  // Any time a field changes, percolate that change up to us
198
208
  (0, mobx_1.reaction)(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
@@ -11,7 +11,9 @@ import { Rule } from "../rules";
11
11
  * i.e. text boxes, can always be cleared out/deleted.
12
12
  */
13
13
  export interface FieldState<V> {
14
+ /** The key in the parent object, i.e. `firstName` in `author: { firstName: string }`. */
14
15
  readonly key: string;
16
+ /** The current value of the field. */
15
17
  value: V;
16
18
  readonly originalValue: V;
17
19
  touched: boolean;
@@ -69,4 +71,4 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
69
71
  _isReadOnlyKey: boolean;
70
72
  _focused: boolean;
71
73
  }
72
- export declare function newValueFieldState<T, K extends keyof T>(parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T[K] | null | undefined>;
74
+ export declare function newValueFieldState<T, K extends keyof T>(parentCopy: T, parentInstance: T, parentState: () => ObjectState<T>, key: K, rules: Rule<T[K] | null | undefined>[], isIdKey: boolean, isDeleteKey: boolean, isReadOnlyKey: boolean, computed: boolean, readOnly: boolean, strictOrder: boolean, maybeAutoSave: () => void): FieldState<T[K] | null | undefined>;
@@ -6,10 +6,7 @@ const mobx_1 = require("mobx");
6
6
  const proxies_1 = require("../proxies");
7
7
  const rules_1 = require("../rules");
8
8
  const utils_1 = require("../utils");
9
- function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
10
- // keep a copy here for reference equality
11
- const value = parentInstance[key];
12
- let _originalValue = value === null ? undefined : (0, is_plain_object_1.isPlainObject)(value) ? (0, mobx_1.toJS)(value) : value;
9
+ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, strictOrder, maybeAutoSave) {
13
10
  // Because we read/write the value directly back into parentInstance[key],
14
11
  // which itself is not a proxy, we use this as our "value changed" trigger.
15
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
@@ -31,7 +28,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
31
28
  // Re-create the `keepNull` logic on sets but for our initial read where our
32
29
  // originalValue is null (empty) but we want to expose it as undefined for
33
30
  // consistency of "empty-ness" to our UI components.
34
- return value === null && (0, utils_1.isEmpty)(_originalValue) ? undefined : value;
31
+ return value === null && (0, utils_1.isEmpty)(parentCopy[key]) ? undefined : value;
35
32
  },
36
33
  set value(v) {
37
34
  this.set(v);
@@ -94,7 +91,7 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
94
91
  if (opts.refreshing && this.dirty && this.value !== value) {
95
92
  // Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
96
93
  // matches the incoming value (value), b/c if it does we should accept it and reset originalValue
97
- // so that we're not longer dirty.
94
+ // so that we're no longer dirty.
98
95
  return;
99
96
  }
100
97
  else if (computed && (opts.resetting || opts.refreshing)) {
@@ -140,12 +137,14 @@ function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, is
140
137
  },
141
138
  get originalValue() {
142
139
  // A dummy check to for reactivity around our non-proxy value
143
- const value = _originalValueTick.value > -1 ? _originalValue : _originalValue;
140
+ const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
144
141
  // Re-create the `keepNull` logic so that `.value` === `.originalValue`
145
142
  return value === null ? undefined : value;
146
143
  },
147
144
  set originalValue(v) {
148
- _originalValue = v;
145
+ const canSkip = v === undefined && !(key in parentCopy);
146
+ if (!canSkip)
147
+ parentCopy[key] = v;
149
148
  _originalValueTick.value++;
150
149
  },
151
150
  maybeTrim() {
@@ -1 +1,11 @@
1
- export {};
1
+ export declare class ObservableObject {
2
+ firstName: string | undefined;
3
+ lastName: string | undefined;
4
+ age?: number | undefined;
5
+ constructor();
6
+ get fullName(): string;
7
+ set fullName(fullName: string);
8
+ toInput(): {
9
+ firstName: string | undefined;
10
+ };
11
+ }
@@ -1,55 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ObservableObject = void 0;
3
4
  const mobx_1 = require("mobx");
5
+ const configBuilders_1 = require("./configBuilders");
4
6
  const objectField_1 = require("./fields/objectField");
5
7
  const formStateDomain_1 = require("./formStateDomain");
6
8
  const rules_1 = require("./rules");
7
- describe("mobx behavior", () => {
8
- it("mobx batches if in actions", () => {
9
- const a = (0, mobx_1.observable)({ name: "a1" });
10
- const b = (0, mobx_1.observable)({ name: "b1" });
11
- const map = new mobx_1.ObservableMap();
12
- map.set("a", a);
13
- map.set("b", b);
14
- let runs = 0;
15
- (0, mobx_1.autorun)(() => {
16
- noop([...map.values()].filter((v) => v.name.includes("1")));
17
- runs++;
18
- });
19
- expect(runs).toBe(1);
20
- // When we change both a and b
21
- (0, mobx_1.runInAction)(() => {
22
- a.name = "a11";
23
- b.name = "b2";
24
- });
25
- // Then the reaction waited and only ran once
26
- expect(runs).toBe(2);
27
- });
28
- it("mobx can watch undefined keys", () => {
29
- const a = new ObservableObject();
30
- const b = new ObservableObject();
31
- const map = new mobx_1.ObservableMap();
32
- map.set("a", a);
33
- map.set("b", b);
34
- let runs = 0;
35
- (0, mobx_1.autorun)(() => {
36
- noop([...map.values()].map((a) => a.age));
37
- runs++;
38
- });
39
- expect(runs).toBe(1);
40
- b.age = 1;
41
- expect(runs).toBe(2);
42
- });
43
- it("mobx lists maintain observable identity", () => {
44
- // given a parent observable
45
- const a = (0, mobx_1.observable)({ list: [] });
46
- // if we observable-ize a value being pushing it on the list
47
- const c1 = (0, mobx_1.observable)({});
48
- a.list.push(c1);
49
- // then we get identify equality on the list lookups
50
- expect(a.list[0] === c1).toEqual(true);
51
- });
52
- });
53
9
  describe("formState", () => {
54
10
  it("can create a simple object", () => {
55
11
  const a = (0, objectField_1.createObjectState)({ title: { type: "value", rules: [rules_1.required] } }, {});
@@ -156,7 +112,8 @@ describe("formState", () => {
156
112
  const a1 = { firstName: "a1" };
157
113
  const state = createAuthorInputState(a1);
158
114
  state.firstName.set("a2");
159
- expect(state.originalValue === a1).toEqual(true);
115
+ expect(state.value === a1).toEqual(true);
116
+ expect(state.originalValue !== a1).toEqual(true);
160
117
  expect(a1.firstName).toEqual("a2");
161
118
  });
162
119
  it("maintains object identity of lists", () => {
@@ -166,11 +123,11 @@ describe("formState", () => {
166
123
  const state = createAuthorInputState(a1);
167
124
  const b2 = { title: "t2" };
168
125
  state.books.add(b2);
169
- expect(state.originalValue.books === a1.books).toEqual(true);
126
+ expect(state.value.books === a1.books).toEqual(true);
170
127
  expect(state.books.value.length).toEqual(2);
171
128
  expect((_a = a1.books) === null || _a === void 0 ? void 0 : _a.length).toEqual(2);
172
- expect(state.books.rows[0].originalValue === b1).toEqual(true);
173
- expect(state.books.rows[1].originalValue === b2).toEqual(true);
129
+ expect(state.books.rows[0].value === b1).toEqual(true);
130
+ expect(state.books.rows[1].value === b2).toEqual(true);
174
131
  expect(a1.books[1] === b2).toEqual(true);
175
132
  });
176
133
  it("can add items anywhere within a list", () => {
@@ -185,14 +142,14 @@ describe("formState", () => {
185
142
  });
186
143
  const b2 = { title: "t2" };
187
144
  state.books.add(b2, 0);
188
- expect(state.books.rows[0].originalValue === b2).toEqual(true);
189
- expect(state.books.rows[1].originalValue === b1).toEqual(true);
145
+ expect(state.books.rows[0].value === b2).toEqual(true);
146
+ expect(state.books.rows[1].value === b1).toEqual(true);
190
147
  expect(ticks).toEqual(2);
191
148
  const b3 = { title: "t3" };
192
149
  state.books.add(b3, 1);
193
- expect(state.books.rows[0].originalValue === b2).toEqual(true);
194
- expect(state.books.rows[1].originalValue === b3).toEqual(true);
195
- expect(state.books.rows[2].originalValue === b1).toEqual(true);
150
+ expect(state.books.rows[0].value === b2).toEqual(true);
151
+ expect(state.books.rows[1].value === b3).toEqual(true);
152
+ expect(state.books.rows[2].value === b1).toEqual(true);
196
153
  expect(ticks).toEqual(3);
197
154
  });
198
155
  it("list value can observe changes", () => {
@@ -208,6 +165,34 @@ describe("formState", () => {
208
165
  state.books.add({ title: "t2" }, 0);
209
166
  expect(ticks).toEqual(2);
210
167
  });
168
+ it("list originalValue can observe changes", () => {
169
+ const b1 = { title: "t1" };
170
+ const a1 = { firstName: "a1", books: [b1] };
171
+ const state = createAuthorInputState(a1);
172
+ let books;
173
+ let ticks = 0;
174
+ (0, mobx_1.autorun)(() => {
175
+ books = state.books.originalValue;
176
+ ticks++;
177
+ });
178
+ state.books.add({ title: "t2" }, 0);
179
+ expect(ticks).toEqual(1);
180
+ state.commitChanges();
181
+ expect(ticks).toEqual(2);
182
+ });
183
+ it("state originalValue can observe changes", () => {
184
+ const a1 = { firstName: "a1", books: [] };
185
+ const state = createAuthorInputState(a1);
186
+ let ticks = 0;
187
+ (0, mobx_1.autorun)(() => {
188
+ noop(state.originalValue);
189
+ ticks++;
190
+ });
191
+ state.books.add({ title: "t2" }, 0);
192
+ expect(ticks).toEqual(1);
193
+ state.commitChanges();
194
+ expect(ticks).toEqual(2);
195
+ });
211
196
  it("maintains unknown fields", () => {
212
197
  // Given the form is not directly editing id fields
213
198
  const config = {
@@ -221,9 +206,8 @@ describe("formState", () => {
221
206
  state.firstName.set("a2");
222
207
  state.books.add({ title: "t2" });
223
208
  // When we get back the originalValue
224
- const a2 = state.originalValue;
225
209
  // Then it has the ids and the new values
226
- expect(a2).toMatchObject({
210
+ expect(state.value).toMatchObject({
227
211
  id: "1",
228
212
  books: [{ id: "2", title: "t1" }, { title: "t2" }],
229
213
  });
@@ -644,8 +628,8 @@ describe("formState", () => {
644
628
  firstName: "a1",
645
629
  lastName: "aL1",
646
630
  books: [
647
- { title: "b1", classification: formStateDomain_1.dd100 },
648
- { title: "b2", classification: formStateDomain_1.dd100 },
631
+ { id: "b:1", title: "b1", classification: formStateDomain_1.dd100 },
632
+ { id: "b:2", title: "b2", classification: formStateDomain_1.dd100 },
649
633
  ],
650
634
  });
651
635
  // And some values have been changed
@@ -722,7 +706,7 @@ describe("formState", () => {
722
706
  a1.firstName.value = "";
723
707
  // Then we keep it as null
724
708
  expect(a1.firstName.value).toBeNull();
725
- expect(a1.originalValue.firstName).toBeNull();
709
+ expect(a1.originalValue.firstName).toBe("asdf");
726
710
  expect(a1.firstName.dirty).toBeTruthy();
727
711
  });
728
712
  it("initializes null values to be undefined", () => {
@@ -968,6 +952,15 @@ describe("formState", () => {
968
952
  // Throw away assertion, test is making sure ^ line compiles
969
953
  expect(config).toBeDefined();
970
954
  });
955
+ it("supports observable objects with helper methods in the config DSL", () => {
956
+ const config = configBuilders_1.f.config({
957
+ firstName: configBuilders_1.f.value(),
958
+ lastName: configBuilders_1.f.value(),
959
+ fullName: configBuilders_1.f.computed(),
960
+ });
961
+ // Throw away assertion, test is making sure ^ line compiles
962
+ expect(config).toBeDefined();
963
+ });
971
964
  it("can reset observable objects with computeds", () => {
972
965
  const formState = (0, objectField_1.createObjectState)({
973
966
  firstName: { type: "value" },
@@ -1111,6 +1104,45 @@ describe("formState", () => {
1111
1104
  id: "a:1",
1112
1105
  books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }],
1113
1106
  });
1107
+ // And add a 3rd new book
1108
+ formState.books.add({ title: "t3" });
1109
+ // Then we get the author id (for updates) and all 3 books
1110
+ expect(formState.changedValue).toEqual({
1111
+ id: "a:1",
1112
+ books: [{ id: "b:1", title: "t1b" }, { id: "b:2" }, { title: "t3" }],
1113
+ });
1114
+ // And we can still get the original value
1115
+ expect(formState.originalValue).toEqual({
1116
+ id: "a:1",
1117
+ firstName: "f",
1118
+ books: [
1119
+ { id: "b:1", title: "t1" },
1120
+ { id: "b:2", title: "t2" },
1121
+ ],
1122
+ });
1123
+ // And the books.originalValue as well
1124
+ expect(formState.books.originalValue).toEqual([
1125
+ { id: "b:1", title: "t1" },
1126
+ { id: "b:2", title: "t2" },
1127
+ ]);
1128
+ // And when we commit changes
1129
+ formState.commitChanges();
1130
+ // Then our originalValue reflects the commit
1131
+ expect(formState.originalValue).toEqual({
1132
+ id: "a:1",
1133
+ firstName: "f",
1134
+ books: [
1135
+ { id: "b:1", title: "t1b" },
1136
+ { id: "b:2", title: "t2" },
1137
+ { id: undefined, title: "t3" },
1138
+ ],
1139
+ });
1140
+ // And the books.originalValue as well
1141
+ expect(formState.books.originalValue).toEqual([
1142
+ { id: "b:1", title: "t1b" },
1143
+ { id: "b:2", title: "t2" },
1144
+ { title: "t3" },
1145
+ ]);
1114
1146
  });
1115
1147
  it("can return only changed but incremental list fields", () => {
1116
1148
  // Given an author
@@ -1512,19 +1544,17 @@ class ObservableObject {
1512
1544
  return { firstName: this.firstName };
1513
1545
  }
1514
1546
  }
1515
- const authorWithBooksConfig = {
1516
- id: { type: "value" },
1517
- firstName: { type: "value" },
1518
- lastName: { type: "value" },
1519
- books: {
1520
- type: "list",
1521
- config: {
1522
- id: { type: "value" },
1523
- title: { type: "value", rules: [rules_1.required] },
1524
- classification: { type: "value" },
1525
- },
1526
- },
1527
- };
1547
+ exports.ObservableObject = ObservableObject;
1548
+ const authorWithBooksConfig = configBuilders_1.f.config({
1549
+ id: configBuilders_1.f.value(),
1550
+ firstName: configBuilders_1.f.value(),
1551
+ lastName: configBuilders_1.f.value(),
1552
+ books: configBuilders_1.f.list({
1553
+ id: configBuilders_1.f.value(),
1554
+ title: configBuilders_1.f.value().req(),
1555
+ classification: configBuilders_1.f.value(),
1556
+ }),
1557
+ });
1528
1558
  function createAuthorInputState(input, maybeAutoSave) {
1529
1559
  return (0, objectField_1.createObjectState)(authorWithBooksConfig, input, { maybeAutoSave });
1530
1560
  }
@@ -1540,27 +1570,42 @@ const authorWithAddressConfig = {
1540
1570
  },
1541
1571
  },
1542
1572
  };
1543
- const authorWithAddressAndBooksConfig = {
1544
- id: { type: "value" },
1545
- firstName: { type: "value" },
1546
- lastName: { type: "value" },
1547
- address: {
1548
- type: "object",
1549
- config: {
1550
- id: { type: "value" },
1551
- street: { type: "value", rules: [rules_1.required] },
1552
- city: { type: "value" },
1553
- },
1554
- },
1555
- books: {
1556
- type: "list",
1557
- config: {
1558
- id: { type: "value" },
1559
- title: { type: "value", rules: [rules_1.required] },
1560
- classification: { type: "value" },
1561
- },
1562
- },
1563
- };
1573
+ const authorWithAddressAndBooksConfig = configBuilders_1.f.config({
1574
+ id: configBuilders_1.f.value(),
1575
+ firstName: configBuilders_1.f.value(),
1576
+ lastName: configBuilders_1.f.value(),
1577
+ address: configBuilders_1.f.object({
1578
+ id: configBuilders_1.f.value(),
1579
+ street: configBuilders_1.f.value().rules([rules_1.required]),
1580
+ city: configBuilders_1.f.value(),
1581
+ }),
1582
+ books: configBuilders_1.f.list({
1583
+ id: configBuilders_1.f.value(),
1584
+ title: configBuilders_1.f.value(),
1585
+ classification: configBuilders_1.f.value(),
1586
+ }),
1587
+ });
1588
+ // const authorWithAddressAndBooksConfig: ObjectConfig<AuthorInput> = {
1589
+ // id: { type: "value" },
1590
+ // firstName: { type: "value" },
1591
+ // lastName: { type: "value" },
1592
+ // address: {
1593
+ // type: "object",
1594
+ // config: {
1595
+ // id: { type: "value" },
1596
+ // street: { type: "value", rules: [required] },
1597
+ // city: { type: "value" },
1598
+ // },
1599
+ // },
1600
+ // books: {
1601
+ // type: "list",
1602
+ // config: {
1603
+ // id: { type: "value" },
1604
+ // title: { type: "value", rules: [required] },
1605
+ // classification: { type: "value" },
1606
+ // },
1607
+ // },
1608
+ // };
1564
1609
  function createAuthorWithAddressInputState(input) {
1565
1610
  return (0, objectField_1.createObjectState)(authorWithAddressConfig, input);
1566
1611
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { ObjectConfig } from "./config";
2
+ export { f } from "./configBuilders";
2
3
  export { ListFieldState } from "./fields/listField";
3
4
  export { ObjectState, createObjectState } from "./fields/objectField";
4
5
  export { FieldState, ValueAdapter } from "./fields/valueField";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = void 0;
3
+ exports.useFormStates = exports.useFormState = exports.required = exports.createObjectState = exports.f = void 0;
4
+ var configBuilders_1 = require("./configBuilders");
5
+ Object.defineProperty(exports, "f", { enumerable: true, get: function () { return configBuilders_1.f; } });
4
6
  var objectField_1 = require("./fields/objectField");
5
7
  Object.defineProperty(exports, "createObjectState", { enumerable: true, get: function () { return objectField_1.createObjectState; } });
6
8
  var rules_1 = require("./rules");
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const mobx_1 = require("mobx");
4
+ const formState_test_1 = require("./formState.test");
5
+ describe("mobx behavior", () => {
6
+ it("mobx batches if in actions", () => {
7
+ const a = (0, mobx_1.observable)({ name: "a1" });
8
+ const b = (0, mobx_1.observable)({ name: "b1" });
9
+ const map = new mobx_1.ObservableMap();
10
+ map.set("a", a);
11
+ map.set("b", b);
12
+ let runs = 0;
13
+ (0, mobx_1.autorun)(() => {
14
+ noop([...map.values()].filter((v) => v.name.includes("1")));
15
+ runs++;
16
+ });
17
+ expect(runs).toBe(1);
18
+ // When we change both a and b
19
+ (0, mobx_1.runInAction)(() => {
20
+ a.name = "a11";
21
+ b.name = "b2";
22
+ });
23
+ // Then the reaction waited and only ran once
24
+ expect(runs).toBe(2);
25
+ });
26
+ it("mobx can watch undefined keys", () => {
27
+ const a = new formState_test_1.ObservableObject();
28
+ const b = new formState_test_1.ObservableObject();
29
+ const map = new mobx_1.ObservableMap();
30
+ map.set("a", a);
31
+ map.set("b", b);
32
+ let runs = 0;
33
+ (0, mobx_1.autorun)(() => {
34
+ noop([...map.values()].map((a) => a.age));
35
+ runs++;
36
+ });
37
+ expect(runs).toBe(1);
38
+ b.age = 1;
39
+ expect(runs).toBe(2);
40
+ });
41
+ it("mobx lists maintain observable identity", () => {
42
+ // given a parent observable
43
+ const a = (0, mobx_1.observable)({ list: [] });
44
+ // if we observable-ize a value being pushing it on the list
45
+ const c1 = (0, mobx_1.observable)({});
46
+ a.list.push(c1);
47
+ // then we get identify equality on the list lookups
48
+ expect(a.list[0] === c1).toEqual(true);
49
+ });
50
+ });
51
+ function noop(t) { }
@@ -0,0 +1,17 @@
1
+ declare const c: {
2
+ value<V>(): ValueConfig<V>;
3
+ };
4
+ type Config<T> = {
5
+ [P in keyof T]: ValueConfig<T[P]>;
6
+ };
7
+ declare function config<T>(object: Config<T>): void;
8
+ interface ValueConfig<V> {
9
+ req(): ValueConfig<V>;
10
+ readOnly(): ValueConfig<V>;
11
+ validate(fn: (value: V) => boolean): ValueConfig<V>;
12
+ }
13
+ type Author = {
14
+ name: string;
15
+ address: string;
16
+ city: string;
17
+ };
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ // Create a Zod-style fluent DSL for building config
3
+ const c = {
4
+ value() {
5
+ return null;
6
+ },
7
+ };
8
+ function config(object) { }
9
+ // Try and configure the author
10
+ config({
11
+ // correctly inferred as c.value<string> --> ValueConfig<string>
12
+ name: c.value(),
13
+ // inferred only as c.value<unknown> --> ValueConfig<unknown>
14
+ address: c.value().req(),
15
+ // compile error, v is implicitly typed as any
16
+ city: c.value().validate((v) => v.length > 0),
17
+ });
@@ -64,7 +64,9 @@ function useFormStates(opts) {
64
64
  }
65
65
  // If the source of truth changed, then update the existing state and return it.
66
66
  if (existing && existing[1] !== input) {
67
- form.set((0, utils_1.initValue)(config, map ? { map, input } : input), { refreshing: true });
67
+ form.set((0, utils_1.initValue)(config, map ? { map, input } : input), {
68
+ refreshing: true,
69
+ });
68
70
  existing[1] = input;
69
71
  }
70
72
  form.readOnly = readOnlyRef.current || !!opts.readOnly;
package/dist/utils.d.ts CHANGED
@@ -8,6 +8,9 @@ export type Primitive = undefined | null | boolean | string | number | Function
8
8
  export type DeepRequired<T> = T extends Primitive ? T : {
9
9
  [P in keyof Required<T>]: T[P] extends Array<infer U> ? Array<DeepRequired<U>> : T[P] extends ReadonlyArray<infer U2> ? ReadonlyArray<DeepRequired<U2>> : DeepRequired<T[P]>;
10
10
  };
11
+ export type OmitIf<Base, Condition> = Pick<Base, {
12
+ [Key in keyof Base]: Base[Key] extends Condition ? never : Key;
13
+ }[keyof Base]>;
11
14
  export declare function fail(message?: string): never;
12
15
  export declare function assertNever(x: never): never;
13
16
  /** Introspects the `init` prop to see if has a `map` function/etc. and returns the form value. */
@@ -41,3 +44,5 @@ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolea
41
44
  export declare function hasToJSON(o?: unknown): o is {
42
45
  toJSON(): void;
43
46
  };
47
+ /** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
48
+ export declare function deepClone<T>(obj: T, map?: WeakMap<WeakKey, any>): T;
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
6
+ exports.deepClone = exports.hasToJSON = exports.areEqual = exports.isEmpty = exports.isNotUndefined = exports.pickFields = exports.isQuery = exports.isInput = exports.initValue = exports.assertNever = exports.fail = void 0;
7
7
  const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
8
8
  const is_plain_object_1 = require("is-plain-object");
9
9
  const mobx_1 = require("mobx");
@@ -126,3 +126,18 @@ function hasToJSON(o) {
126
126
  return !!(o && typeof o === "object" && "toJSON" in o);
127
127
  }
128
128
  exports.hasToJSON = hasToJSON;
129
+ /** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
130
+ function deepClone(obj, map = new WeakMap()) {
131
+ if (obj && typeof obj === "object" && ((0, is_plain_object_1.isPlainObject)(obj) || Array.isArray(obj) || (0, mobx_1.isObservable)(obj))) {
132
+ if (map.has(obj))
133
+ return map.get(obj);
134
+ const result = Array.isArray(obj) ? [] : {};
135
+ map.set(obj, result);
136
+ Object.assign(result, ...Object.keys(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
137
+ return result;
138
+ }
139
+ else {
140
+ return obj;
141
+ }
142
+ }
143
+ exports.deepClone = deepClone;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.20.1",
3
+ "version": "2.22.0",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",