@homebound/form-state 2.33.0 → 3.0.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.
Files changed (57) hide show
  1. package/dist/index.d.ts +437 -9
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +1109 -14
  4. package/dist/index.js.map +1 -0
  5. package/package.json +32 -35
  6. package/dist/FormStateApp.d.ts +0 -5
  7. package/dist/FormStateApp.js +0 -54
  8. package/dist/FormStateApp.test.d.ts +0 -1
  9. package/dist/FormStateApp.test.js +0 -26
  10. package/dist/config.d.ts +0 -78
  11. package/dist/config.js +0 -2
  12. package/dist/configBuilders.d.ts +0 -67
  13. package/dist/configBuilders.js +0 -115
  14. package/dist/configBuilders.test.d.ts +0 -1
  15. package/dist/configBuilders.test.js +0 -65
  16. package/dist/fields/deepEquals.d.ts +0 -7
  17. package/dist/fields/deepEquals.js +0 -53
  18. package/dist/fields/fragmentField.d.ts +0 -4
  19. package/dist/fields/fragmentField.js +0 -58
  20. package/dist/fields/listField.d.ts +0 -11
  21. package/dist/fields/listField.js +0 -355
  22. package/dist/fields/objectField.d.ts +0 -63
  23. package/dist/fields/objectField.js +0 -277
  24. package/dist/fields/valueField.d.ts +0 -81
  25. package/dist/fields/valueField.js +0 -244
  26. package/dist/formState.stories.d.ts +0 -4
  27. package/dist/formState.stories.js +0 -12
  28. package/dist/formState.test.d.ts +0 -11
  29. package/dist/formState.test.js +0 -2109
  30. package/dist/formStateDomain.d.ts +0 -47
  31. package/dist/formStateDomain.js +0 -28
  32. package/dist/mobx-behavior.test.d.ts +0 -1
  33. package/dist/mobx-behavior.test.js +0 -51
  34. package/dist/proxies.d.ts +0 -5
  35. package/dist/proxies.js +0 -20
  36. package/dist/rules.d.ts +0 -10
  37. package/dist/rules.js +0 -11
  38. package/dist/setupTests.d.ts +0 -1
  39. package/dist/setupTests.js +0 -15
  40. package/dist/stackoverlfow.d.ts +0 -17
  41. package/dist/stackoverlfow.js +0 -17
  42. package/dist/temporal.d.ts +0 -6
  43. package/dist/temporal.js +0 -34
  44. package/dist/temporal.test.d.ts +0 -1
  45. package/dist/temporal.test.js +0 -97
  46. package/dist/useFormState.d.ts +0 -82
  47. package/dist/useFormState.js +0 -158
  48. package/dist/useFormState.test.d.ts +0 -1
  49. package/dist/useFormState.test.js +0 -787
  50. package/dist/useFormStates.d.ts +0 -68
  51. package/dist/useFormStates.js +0 -101
  52. package/dist/useFormStates.test.d.ts +0 -1
  53. package/dist/useFormStates.test.js +0 -223
  54. package/dist/utils.d.ts +0 -52
  55. package/dist/utils.js +0 -198
  56. package/dist/utils.test.d.ts +0 -1
  57. package/dist/utils.test.js +0 -102
@@ -1,355 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.newListFieldState = newListFieldState;
7
- const mobx_1 = require("mobx");
8
- const objectField_1 = require("./objectField");
9
- const rules_1 = require("../rules");
10
- const utils_1 = require("../utils");
11
- const object_hash_1 = __importDefault(require("object-hash"));
12
- function newListFieldState(
13
- // parentCopy is objectField's deepClone-d `originalCopy` that it/we use for `dirty` checking
14
- parentCopy,
15
- // parentInstance is objectField's `instance`, that we're currently mutating
16
- parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave, deepExhaustive) {
17
- var _a;
18
- // Keep a map of "item in the parentInstance list" -> "that item's ObjectState"
19
- const rowMap = new Map();
20
- const addedRows = new Set();
21
- const _tick = (0, mobx_1.observable)({ value: 1 });
22
- const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
23
- const _childTick = (0, mobx_1.observable)({ value: 1 });
24
- // When child rows don't have ids (i.e. for new rows that aren't saved yet), we need an id-less
25
- // "clone <-> current" map to tell if we're dirty or not, i.e. whether `parentInstance[key]` has
26
- // drifted from `parentCopy[key]`
27
- const copyMap = new Map();
28
- ((_a = parentCopy[key]) !== null && _a !== void 0 ? _a : []).forEach((copy, i) => {
29
- copyMap.set(copy, parentInstance[key][i]);
30
- });
31
- // Given a child POJO (or a copy/clone of a child POJO), return its ObjectState wrapper.
32
- function getOrCreateChildState(child, opts) {
33
- var _a;
34
- let childState = rowMap.get(child);
35
- // If we're being reverted to our originalValue, i.e. values is actually
36
- // a list of copies, use the copyMap to recover the non-copy original value
37
- if (!childState && copyMap.has(child)) {
38
- childState = rowMap.get(copyMap.get(child));
39
- }
40
- // Look for an existing child (requires having an id key configured)
41
- if (!childState) {
42
- for (const [, otherState] of rowMap.entries()) {
43
- if (otherState.isSameEntity(child)) {
44
- // If we're being called from `list.rows`, we should be careful to not trigger mutations
45
- if (!(opts === null || opts === void 0 ? void 0 : opts.skipSet))
46
- otherState.set(child, opts);
47
- rowMap.set(child, otherState);
48
- return otherState;
49
- }
50
- }
51
- }
52
- // If we didn't have an existing child, just make a new object state
53
- if (!childState) {
54
- childState = (0, objectField_1.newObjectState)(config, parentState, undefined, list, child, undefined, maybeAutoSave, (_a = listConfig.update === "deep-exhaustive") !== null && _a !== void 0 ? _a : deepExhaustive);
55
- rowMap.set(child, childState);
56
- }
57
- return childState;
58
- }
59
- const list = {
60
- key: key,
61
- // Our fundamental state of wrapped Us
62
- get value() {
63
- var _a;
64
- return _tick.value > 0 && _childTick.value > 0 ? ((_a = parentInstance[key]) !== null && _a !== void 0 ? _a : []) : (0, utils_1.fail)();
65
- },
66
- _kind: "list",
67
- _focused: false,
68
- _readOnly: false,
69
- _loading: false,
70
- get readOnly() {
71
- return this._readOnly || parentState().readOnly;
72
- },
73
- set readOnly(readOnly) {
74
- this._readOnly = readOnly;
75
- },
76
- get loading() {
77
- return this._loading || parentState().loading;
78
- },
79
- set loading(loading) {
80
- this._loading = loading;
81
- },
82
- set value(v) {
83
- this.set(v);
84
- },
85
- get dirty() {
86
- return this.rows.some((r) => r.dirty) || this.hasChanged();
87
- },
88
- // Having a new entity is slightly different than being dirty, b/c merely having a placeholder-but-not-changed-yet
89
- // `isNewEntity` child should not fire autosave (which would happen if we're dirty).
90
- get hasNewEntity() {
91
- return this.rows.some((r) => r.isNewEntity);
92
- },
93
- get focused() {
94
- return this.rows.some((r) => r.focused);
95
- },
96
- get required() {
97
- return this.rules.some((rule) => rule === rules_1.required);
98
- },
99
- get isNewEntity() {
100
- return parentState().isNewEntity;
101
- },
102
- // private
103
- /** Returns whether a row has been added/removed. */
104
- hasChanged() {
105
- const [current, original] = [this.value || [], this.originalValue];
106
- if (current.length !== original.length)
107
- return true;
108
- if (strictOrder) {
109
- // With strict order, every copy[i] === original[i] must be true
110
- return original.some((e, idx) => current[idx] !== copyMap.get(e));
111
- }
112
- else {
113
- // With loose order, we just want every copy to still have its original somewhere
114
- return !original.every((e) => current.includes(copyMap.get(e)));
115
- }
116
- },
117
- // And we can derive each value's ObjectState wrapper as needed from the rowMap cache
118
- get rows() {
119
- // It's unclear why we need to access _tick.value here, b/c calling `this.value` should
120
- // transitively register us as a dependency on it
121
- if (_tick.value < 0)
122
- (0, utils_1.fail)();
123
- // Avoid using `this.value` to avoid registering `_childTick` as a dependency
124
- const value = parentInstance[key];
125
- return (value || []).map((child) => getOrCreateChildState(child, { skipSet: true }));
126
- },
127
- // TODO Should this be true when all rows are touched?
128
- get touched() {
129
- return this.rows.some((r) => r.touched) || this.hasChanged();
130
- },
131
- set touched(touched) {
132
- this.rows.forEach((r) => (r.touched = touched));
133
- },
134
- rules,
135
- get valid() {
136
- const value = this.rows;
137
- // TODO Passing `originalCopy || []` is probably not 100% right
138
- const opts = { value, key: key, originalValue: this.originalValue, object: parentState() };
139
- const collectionValid = this.rules.every((r) => r(opts) === undefined);
140
- const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
141
- return collectionValid && entriesValid;
142
- },
143
- get errors() {
144
- if (_tick.value < 0)
145
- (0, utils_1.fail)();
146
- const opts = { value: this.rows, key: key, originalValue: this.originalValue, object: parentState() };
147
- return this.rules.map((r) => r(opts)).filter(utils_1.isNotUndefined);
148
- },
149
- get changedValue() {
150
- const result = [];
151
- const hasOpKey = Object.keys(listConfig.config).includes("op");
152
- const hasLegacyOpKey = Object.keys(listConfig.config).some((key) => key === "delete" || key === "remove");
153
- const incremental = listConfig.update === "incremental" ||
154
- // Implicitly enable incremental mode if we see an op key
155
- (listConfig.update === undefined && (hasOpKey || hasLegacyOpKey));
156
- const exhaustive = !incremental;
157
- this.rows.forEach((r) => {
158
- var _a;
159
- var _b;
160
- if (exhaustive || r.dirty || r.isNewEntity) {
161
- const changed = r.changedValue;
162
- // Ensure we have an `op: include` key, following https://joist-orm.io/docs/features/partial-update-apis
163
- if (incremental && hasOpKey) {
164
- (_a = (_b = changed).op) !== null && _a !== void 0 ? _a : (_b.op = "include");
165
- }
166
- result.push(changed);
167
- }
168
- });
169
- return result;
170
- },
171
- focus() {
172
- this._focused = true;
173
- },
174
- blur() {
175
- this._focused = false;
176
- this.maybeAutoSave();
177
- },
178
- maybeAutoSave() {
179
- this.touched = true;
180
- maybeAutoSave();
181
- },
182
- set(values, opts = {}) {
183
- var _a, _b, _c, _d, _e;
184
- if (this.readOnly && !opts.resetting && !opts.refreshing) {
185
- throw new Error(`${String(key)} is currently readOnly`);
186
- }
187
- // Given the values, see if we've got existing child states, and use their
188
- // value if so. I.e. this covers revertChanges doing `set(originalValue)` and
189
- // passing us cloned rows from the parentCopy, but `getOrCreateChildState` will
190
- // use the `copyMap` to recover the non-cloned rows, to avoid promoting the clone
191
- // into a real row.
192
- if ((this.dirty || this.hasNewEntity) && opts.refreshing) {
193
- // When refreshing a dirty list, we need to preserve WIP values
194
- // Start with current list, then merge in incoming changes
195
- //
196
- // These will all be POJOs, where `currentItems` existing in our `parentInstance`, and
197
- // `incomingItems` could really be any of:
198
- // - an instance from `parentInstance[key]` that user code is passing back in
199
- // - an instance from `parentCopy[key]` that a cache refresh is passing in
200
- // - a new instance, either from a cache refresh or user code, that we haven't seen yet
201
- const currentItems = this.value || [];
202
- const incomingItems = values || [];
203
- const mergedItems = [];
204
- // Index by idKey or a hash of the object, so we don't have to n^2 merging
205
- const idKey = this.rows.length > 0 && this.rows[0].idKey;
206
- // Look at our child config, i.e. `bookConfig` and find the non-id / non-list keys
207
- const contentKeys = Object.entries(config.config)
208
- .filter(([key, cfg]) => key !== idKey && cfg.type === "value")
209
- .map(([key]) => key);
210
- const hashByContent = (item) => hashValue((0, utils_1.normalizeHashValue)(Object.fromEntries(Object.entries(item).filter(([key]) => contentKeys.includes(key)))));
211
- const hashById = (item) => {
212
- const idValue = idKey ? item[idKey] : undefined;
213
- return idValue === undefined ? hashByContent(item) : idValue;
214
- };
215
- const currentById = (0, utils_1.groupBy)(currentItems, hashById);
216
- const incomingById = (0, utils_1.groupBy)(incomingItems, hashById);
217
- // If our local instance doesn't assign in id, when we get results back from the server, strip
218
- // their id and then see if we're the same based on data. Note that this is a hueristic, and will
219
- // fail if the client-side has made additional changes to the child after submitting it to the server,
220
- // or if the server acks back more/different data than the client sent.
221
- //
222
- // Only do this if one of our client-side objects is missing an id
223
- const incomingByContent = idKey && currentItems.some((item) => !item[idKey]) && (0, utils_1.groupBy)(incomingItems, hashByContent);
224
- // Watch for deletions
225
- const hasOpKey = Object.keys(listConfig.config).includes("op");
226
- // First, process all current items
227
- for (const currentItem of currentItems) {
228
- const childState = rowMap.get(currentItem); // We'll always have a child state for `currentItems`
229
- // Try to find a matching item in the incoming data
230
- const hash = hashById(currentItem);
231
- const match = ((_b = (_a = incomingById.get(hash)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b :
232
- // Only if we don't have `book[id]` set, look for a match based on our content
233
- (!!idKey &&
234
- !currentItem[idKey] &&
235
- !!incomingByContent &&
236
- (
237
- // If we don't have an id, our `hash` variable will already be the `hashByContent`, so we can reuse it
238
- (_c = incomingByContent === null || incomingByContent === void 0 ? void 0 : incomingByContent.get(hash)) === null || _c === void 0 ? void 0 : _c[0])));
239
- if (match) {
240
- // Defer to set to handle not nuking WIP changes
241
- childState.set(match, opts);
242
- mergedItems.push(childState.value);
243
- // Once matched, we don't want this to be an addedRow anymore
244
- addedRows.delete(currentItem);
245
- }
246
- else if (!childState.dirty && !childState.isNewEntity && !addedRows.has(currentItem)) {
247
- // Local is not dirty/added, and it's not upstream, so let it get removed
248
- }
249
- else if (hasOpKey && currentItem.op === "delete") {
250
- // We were locally marked as deleted, and not finding a match is the server acking that we're gone
251
- }
252
- else if (currentItems.length === incomingItems.length &&
253
- !!idKey &&
254
- currentItem[idKey] === undefined) {
255
- // Assume our newly-assigned id is coming back
256
- }
257
- else {
258
- // If no incoming, but we're dirty (see above) and not deleted (see above), keep the WIP change
259
- mergedItems.push(currentItem);
260
- }
261
- }
262
- // Then, add any incoming items that don't exist in current
263
- for (const incomingItem of incomingItems) {
264
- // Look for both `incoming.id` and the incoming-sans-id for new entities
265
- const match = ((_d = currentById.get(hashById(incomingItem))) === null || _d === void 0 ? void 0 : _d[0]) || ((_e = currentById.get(hashByContent(incomingItem))) === null || _e === void 0 ? void 0 : _e[0]);
266
- if (!match) {
267
- // New item from server, add it
268
- const childState = getOrCreateChildState(incomingItem, opts);
269
- mergedItems.push(childState.value);
270
- }
271
- }
272
- parentInstance[key] = mergedItems;
273
- _tick.value++;
274
- // Set original to not merged...
275
- this.setOriginalValue(incomingItems);
276
- }
277
- else {
278
- parentInstance[key] = (values !== null && values !== void 0 ? values : []).map((child) => getOrCreateChildState(child, opts).value);
279
- _tick.value++;
280
- // Reset originalCopy so that our dirty checks have the right # of rows.
281
- if (opts.refreshing) {
282
- this.setOriginalValue();
283
- }
284
- }
285
- },
286
- add(value, spliceIndex) {
287
- // This is called by the user, so value should be a non-proxy value we should keep
288
- const childState = getOrCreateChildState(value);
289
- rowMap.set(value, childState);
290
- // Let `.set` know this is a new row
291
- addedRows.add(value);
292
- this.ensureSet();
293
- this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
294
- _tick.value++;
295
- maybeAutoSave();
296
- },
297
- remove(indexOrValue) {
298
- this.ensureSet();
299
- if (typeof indexOrValue === "number") {
300
- this.value.splice(indexOrValue, 1);
301
- }
302
- else {
303
- const index = this.value.findIndex((v) => v === indexOrValue);
304
- if (index > -1) {
305
- this.value.splice(index, 1);
306
- }
307
- }
308
- _tick.value++;
309
- maybeAutoSave();
310
- },
311
- revertChanges() {
312
- this.set(this.originalValue, { resetting: true });
313
- this.rows.forEach((r) => r.revertChanges());
314
- this.touched = false;
315
- },
316
- commitChanges() {
317
- // Tell each child ObjectState to have its fields commit into its parentCopy
318
- this.rows.forEach((r) => r.commitChanges());
319
- this.setOriginalValue();
320
- this.touched = false;
321
- _tick.value++;
322
- },
323
- get originalValue() {
324
- // A dummy check to for reactivity around our non-proxy value
325
- const value = _originalValueTick.value > -1 ? parentCopy[key] : parentCopy[key];
326
- return value !== null && value !== void 0 ? value : [];
327
- },
328
- // This should only be called when value === originalValue
329
- setOriginalValue(incomingItems) {
330
- // Use the rows' originalValues to update the parentCopy
331
- parentCopy[key] = incomingItems !== null && incomingItems !== void 0 ? incomingItems : this.rows.map((r) => r.originalValue);
332
- parentCopy[key].forEach((copy, i) => {
333
- copyMap.set(copy, parentInstance[key][i]);
334
- });
335
- _originalValueTick.value++;
336
- },
337
- ensureSet() {
338
- if (!parentInstance[key]) {
339
- parentInstance[key] = [];
340
- }
341
- _tick.value++;
342
- },
343
- };
344
- const proxy = (0, mobx_1.makeAutoObservable)(list, {
345
- // See other makeAutoObservable comment
346
- value: (0, mobx_1.computed)({ equals: () => false }),
347
- });
348
- // Any time a row's value changes, percolate that to our `.value` (so the callers to our
349
- // `.value` will rerun given the value they saw has deeply changed.)
350
- (0, mobx_1.reaction)(() => proxy.rows.map((r) => r.value), () => _childTick.value++);
351
- return proxy;
352
- }
353
- function hashValue(value) {
354
- return (0, object_hash_1.default)(value);
355
- }
@@ -1,63 +0,0 @@
1
- import { ObjectConfig, ObjectFieldConfig } from "../config";
2
- import { FragmentField } from "./fragmentField";
3
- import { ListFieldState } from "./listField";
4
- import { FieldState, InternalSetOpts, SetOpts } from "./valueField";
5
- import { Builtin } from "../utils";
6
- /**
7
- * Wraps a given input/on-the-wire type `T` for editing in a form.
8
- *
9
- * We basically mimic every field in `T` (i.e. `firstName`, `lastName`, etc.) but decorate them
10
- * with form-specific state like `touched`, `dirty`, `errors`, etc.
11
- *
12
- * The intent is that, after ensuring all fields are `valid`/etc., callers can take the
13
- * result of this `objectState.value` (or `objectState.originalValue` for the non-proxy version) and
14
- * have exactly the on-the-wire type `T` that they need to submit to the backend, without doing the
15
- * manual mapping of "data that was in the form controls" into "data that the backend wants".
16
- *
17
- * Note that this can be hierarchical by having a field of `ListFieldState` that
18
- * themselves each wrap an `ObjectState`, i.e.:
19
- *
20
- * ```
21
- * ObjectState for author
22
- * - firstName: FieldState
23
- * - lastName: FieldState
24
- * - rows: ListFieldState
25
- * - [0]: ObjectState for book 1
26
- * - title: FieldState
27
- * - [1]: ObjectState for book 2
28
- * - title: FieldState
29
- * ```
30
- */
31
- export type ObjectState<T> = FieldStates<T> & FieldState<T> & {
32
- /** Sets the state of fields in `state`. */
33
- set(state: Partial<T>, opts?: SetOpts): void;
34
- /** Returns whether the object can be saved, i.e. is valid, but also as a side-effect marks touched. */
35
- canSave(): boolean;
36
- };
37
- export type ObjectStateInternal<T = unknown> = ObjectState<T> & {
38
- set(value: T, opts?: InternalSetOpts): void;
39
- isSameEntity(other: T): boolean;
40
- idKey: string | undefined;
41
- };
42
- declare const fragmentSym: unique symbol;
43
- export type Fragment<V> = V & {
44
- [fragmentSym]: true;
45
- };
46
- export declare function fragment<V>(value: V): Fragment<V>;
47
- /** 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. */
48
- type FieldStates<T> = {
49
- [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[K]> : ListFieldState<U> : T[K] extends Builtin | null | undefined ? FieldState<T[K]> : ObjectState<T[K]>;
50
- };
51
- /**
52
- * Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
53
- *
54
- * The returned `ObjectState` can be used in a mobx `useLocalObservable` to drive an
55
- * interactive form that tracks the current valid/touched/etc. state of both each
56
- * individual fields as well as the top-level form/object itself.
57
- */
58
- export declare function createObjectState<T>(config: ObjectConfig<T>, instance: T, opts?: {
59
- maybeAutoSave?: () => void;
60
- }): ObjectState<T>;
61
- /** A more internal version of `createObjectState`. */
62
- export declare function newObjectState<T, P = any>(config: ObjectFieldConfig<T>, parentState: (() => ObjectState<P>) | undefined, parentInstance: P | undefined, parentListState: FieldState<any> | undefined, instance: T, key: keyof T | undefined, maybeAutoSave: () => void, deepExhaustive: boolean): ObjectState<T>;
63
- export {};