@homebound/form-state 2.9.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/formState.js DELETED
@@ -1,573 +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.createObjectState = exports.required = void 0;
7
- const fast_deep_equal_1 = __importDefault(require("fast-deep-equal"));
8
- const is_plain_object_1 = require("is-plain-object");
9
- const mobx_1 = require("mobx");
10
- const utils_1 = require("./utils");
11
- /** A rule that validates `value` is not `undefined`, `null`, or empty string. */
12
- // We pre-emptively make this a mobx action so that it's identity doesn't change when proxy-ified
13
- // and breaks our ability to do `rules.some(r => r === required)`.
14
- exports.required = mobx_1.action(({ value: v }) => {
15
- const isEmptyString = typeof v === "string" ? v.trim() === "" : false;
16
- return v !== undefined && v !== null && !isEmptyString ? undefined : "Required";
17
- });
18
- /**
19
- * Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
20
- *
21
- * The returned `ObjectState` can be used in a mobx `useLocalObservable` to driven an
22
- * interactive form that tracks the current valid/touched/etc. state of both each
23
- * individual fields as well as the top-level form/object itself.
24
- */
25
- function createObjectState(config, instance, opts = {}) {
26
- const noop = () => { };
27
- return newObjectState(config, undefined, instance, undefined, opts.maybeAutoSave || noop);
28
- }
29
- exports.createObjectState = createObjectState;
30
- function newObjectState(config, parentState, instance, key, maybeAutoSave) {
31
- // This is what we return, but we only know it's value until we call `observable`, so
32
- // we create a mutable variable to capture it so that we can create fields/call their
33
- // constructors and give them a way to access it later.
34
- let proxy = undefined;
35
- function getObjectState() {
36
- if (!proxy) {
37
- throw new Error("Race condition");
38
- }
39
- return proxy;
40
- }
41
- const fieldStates = Object.entries(config).map(([_key, _config]) => {
42
- const key = _key;
43
- const config = _config;
44
- let field;
45
- if (config.type === "value") {
46
- field = newValueFieldState(instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id", config.isDeleteKey || false, config.isReadOnlyKey || false, config.computed || false, config.readOnly || false, maybeAutoSave);
47
- }
48
- else if (config.type === "list") {
49
- field = newListFieldState(instance, getObjectState, key, config.rules || [], config, config.config, maybeAutoSave);
50
- }
51
- else if (config.type === "object") {
52
- if (!instance[key]) {
53
- instance[key] = {};
54
- }
55
- field = newObjectState(config.config, getObjectState, instance[key], key, maybeAutoSave);
56
- }
57
- else {
58
- throw new Error("Unsupported");
59
- }
60
- return [key, field];
61
- });
62
- // We always return the same `instance` field from our `value` method, but
63
- // we want to pretend that it's observable, so use a tick to force it.
64
- const _tick = mobx_1.observable({ value: 1 });
65
- const fieldNames = Object.keys(config);
66
- function getFields(proxyThis) {
67
- return fieldNames.map((name) => proxyThis[name]);
68
- }
69
- const obj = {
70
- ...Object.fromEntries(fieldStates),
71
- key,
72
- get value() {
73
- _tick.value > 0 || utils_1.fail();
74
- return instance;
75
- },
76
- set value(value) {
77
- this.set(value);
78
- },
79
- // private
80
- _readOnly: false,
81
- _considerDeleted() {
82
- const deleteField = getFields(this).find((f) => f._isDeleteKey);
83
- return !!(deleteField === null || deleteField === void 0 ? void 0 : deleteField.value);
84
- },
85
- _considerReadOnly() {
86
- const readOnlyField = getFields(this).find((f) => f._isReadOnlyKey);
87
- return !!(readOnlyField === null || readOnlyField === void 0 ? void 0 : readOnlyField.value);
88
- },
89
- get touched() {
90
- return getFields(this).some((f) => f.touched);
91
- },
92
- set touched(touched) {
93
- getFields(this).forEach((f) => (f.touched = touched));
94
- },
95
- get readOnly() {
96
- return this._readOnly || this._considerReadOnly();
97
- },
98
- set readOnly(readOnly) {
99
- this._readOnly = readOnly;
100
- // Use this.readOnly so we can _considerReadOnly
101
- getFields(this).forEach((f) => (f.readOnly = this.readOnly));
102
- },
103
- get valid() {
104
- return getFields(this).every((f) => f.valid);
105
- },
106
- get errors() {
107
- return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
108
- },
109
- get dirty() {
110
- return getFields(this).some((f) => f.dirty);
111
- },
112
- get isNewEntity() {
113
- const idField = getFields(this).find((f) => f._isIdKey);
114
- // If we're a line item w/o an immediate id field, look in our parent
115
- if (!idField && parentState) {
116
- return parentState().isNewEntity;
117
- }
118
- // If there is no id field, assume we're not new
119
- return idField !== undefined && (idField.value === null || idField.value === undefined);
120
- },
121
- canSave() {
122
- this.touched = true;
123
- return this.valid;
124
- },
125
- // Accepts new values in bulk, i.e. when setting the form initial state from the backend.
126
- set(value, opts = {}) {
127
- if (this.readOnly && !opts.resetting && !opts.refreshing) {
128
- throw new Error(`${key || "formState"} is currently readOnly`);
129
- }
130
- getFields(this).forEach((field) => {
131
- if (field.key in value) {
132
- field.set(value[field.key], opts);
133
- }
134
- });
135
- },
136
- // Resets all fields back to their original values
137
- reset() {
138
- getFields(this).forEach((f) => f.reset());
139
- },
140
- // Saves all current values into _originalValue
141
- save() {
142
- getFields(this).forEach((f) => f.save());
143
- },
144
- // Create a result that is only populated with changed keys
145
- get changedValue() {
146
- const result = {};
147
- getFields(this).forEach((f) => {
148
- if (f.dirty ||
149
- // If the caller used useFormState.ifUndefined to provide some default values, then those keys may not
150
- // look dirty, but if we're new we should include them anyway.
151
- (this.isNewEntity &&
152
- // Unless they're undefined anyway
153
- f.value !== undefined &&
154
- // And unless they're empty sub-objects
155
- !(f.value instanceof Object && Object.entries(f.changedValue).length === 0))) {
156
- result[f.key] = f.changedValue;
157
- }
158
- });
159
- // Ensure we always have the id for updates to work
160
- const idField = getFields(this).find((f) => f._isIdKey);
161
- if (idField && idField.value !== undefined) {
162
- result[idField.key] = idField.value;
163
- }
164
- return result;
165
- },
166
- get originalValue() {
167
- return instance;
168
- },
169
- // An internal helper method to see if `other` is for "the same entity" as our current row
170
- isSameEntity(other) {
171
- const idField = getFields(this).find((f) => f._isIdKey);
172
- if (!idField) {
173
- return false;
174
- }
175
- return this[idField.key].value === other[idField.key];
176
- },
177
- };
178
- proxy = mobx_1.makeAutoObservable(obj, {
179
- // Use custom equality on `value` that is _never_ equals. This sounds weird, but
180
- // because our `value` is always the same `instance` that was passed to `newObjectState`,
181
- // to mobx this looks like the value never changes, and it will never invoke observers
182
- // even with our tick-based hacks.
183
- value: mobx_1.computed({ equals: () => false }),
184
- });
185
- // Any time a field changes, percolate that change up to us
186
- mobx_1.reaction(() => getFields(proxy).map((f) => f.value), () => _tick.value++);
187
- return proxy;
188
- }
189
- function newValueFieldState(parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, computed, readOnly, maybeAutoSave) {
190
- // keep a copy here for reference equality
191
- const value = parentInstance[key];
192
- let _originalValue = value === null ? undefined : is_plain_object_1.isPlainObject(value) ? mobx_1.toJS(value) : value;
193
- // Because we read/write the value directly back into parentInstance[key],
194
- // which itself is not a proxy, we use this as our "value changed" trigger.
195
- const _tick = mobx_1.observable({ value: 1 });
196
- const _originalValueTick = mobx_1.observable({ value: 1 });
197
- const field = {
198
- key,
199
- touched: false,
200
- /** Configuration readOnly state. Mostly used to determine original state. */
201
- _configReadOnly: readOnly,
202
- /** Current readOnly value. */
203
- _readOnly: readOnly || false,
204
- _focused: false,
205
- _isIdKey: isIdKey,
206
- _isDeleteKey: isDeleteKey,
207
- _isReadOnlyKey: isReadOnlyKey,
208
- rules,
209
- get value() {
210
- const value = _tick.value > 0 ? parentInstance[key] : utils_1.fail();
211
- // Re-create the `keepNull` logic on sets but for our initial read where our
212
- // originalValue is null (empty) but we want to expose it as undefined for
213
- // consistency of "empty-ness" to our UI components.
214
- return value === null && isEmpty(_originalValue) ? undefined : value;
215
- },
216
- set value(v) {
217
- this.set(v);
218
- },
219
- get dirty() {
220
- return !areEqual(this.originalValue, this.value);
221
- },
222
- get readOnly() {
223
- return this._readOnly;
224
- },
225
- /**
226
- * Field readOnly is only opinionated when set.
227
- * - When set to true from FormObject, accept change since true is higher priority.
228
- * - When set to false from FormObject, use original (`_configReadOnly`) value.
229
- */
230
- set readOnly(v) {
231
- if (this._configReadOnly === undefined) {
232
- this._readOnly = v;
233
- return;
234
- }
235
- if (v) {
236
- this._readOnly = v;
237
- }
238
- else {
239
- this._readOnly = this._configReadOnly || false;
240
- }
241
- },
242
- // For primitive fields, the changed value is just the value.
243
- get changedValue() {
244
- return this.value;
245
- },
246
- get valid() {
247
- const opts = { value: this.value, key: key, originalValue: this.originalValue, object: parentState() };
248
- return this.rules.every((r) => r(opts) === undefined);
249
- },
250
- get errors() {
251
- const opts = { value: this.value, key: key, originalValue: this.originalValue, object: parentState() };
252
- return this.rules.map((r) => r(opts)).filter(isNotUndefined);
253
- },
254
- get required() {
255
- return this.rules.some((rule) => rule === exports.required);
256
- },
257
- get isNewEntity() {
258
- return parentState().isNewEntity;
259
- },
260
- focus() {
261
- this._focused = true;
262
- },
263
- blur() {
264
- this.maybeAutoSave();
265
- this._focused = false;
266
- },
267
- maybeAutoSave() {
268
- // Now that the user is done editing the field (note maybe w/o blurring, i.e. if they hit enter), we sneak in some trim logic
269
- this.maybeTrim();
270
- // touched is readonly, but we're allowed to change it
271
- this.touched = true;
272
- maybeAutoSave();
273
- },
274
- set(value, opts = {}) {
275
- if (this.readOnly && !opts.resetting && !opts.refreshing) {
276
- throw new Error(`${key} is currently readOnly`);
277
- }
278
- if (opts.refreshing && this.dirty && this.value !== value) {
279
- // Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
280
- // matches the incoming value (value), b/c if it does we should accept it and reset originalValue
281
- // so that we're not longer dirty.
282
- return;
283
- }
284
- else if (computed && (opts.resetting || opts.refreshing)) {
285
- // Computeds can't be either reset or refreshed
286
- return;
287
- }
288
- // If the user has deleted/emptied a value that was originally set, keep it as `null`
289
- // so that our partial update to the backend correctly unsets it.
290
- const keepNull = !isEmpty(this.originalValue) && isEmpty(value);
291
- // If a list of primitives was originally undefined, coerce `[]` to `undefined`
292
- const coerceEmptyList = value && value instanceof Array && value.length === 0 && isEmpty(this.originalValue);
293
- const newValue = keepNull ? null : isEmpty(value) || coerceEmptyList ? undefined : value;
294
- // Set the value on our parent object
295
- const changed = !areEqual(newValue, this.value);
296
- parentInstance[key] = newValue;
297
- _tick.value++;
298
- if (opts.refreshing) {
299
- this.originalValue = newValue;
300
- }
301
- // If we're being set programmatically, i.e. we don't currently have focus,
302
- // call blur to trigger any auto-saves.
303
- if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) {
304
- this.maybeAutoSave();
305
- }
306
- },
307
- reset() {
308
- if (!computed) {
309
- this.set(this.originalValue, { resetting: true });
310
- }
311
- this.touched = false;
312
- },
313
- save() {
314
- if (is_plain_object_1.isPlainObject(this.originalValue)) {
315
- this.originalValue = mobx_1.toJS(this.value);
316
- }
317
- else {
318
- this.originalValue = this.value;
319
- }
320
- this.touched = false;
321
- },
322
- get originalValue() {
323
- // A dummy check to for reactivity around our non-proxy value
324
- return _originalValueTick.value > -1 ? _originalValue : _originalValue;
325
- },
326
- set originalValue(v) {
327
- _originalValue = v;
328
- _originalValueTick.value++;
329
- },
330
- maybeTrim() {
331
- if (typeof this.value === "string") {
332
- let newValue = this.value.trim();
333
- if (newValue === "") {
334
- newValue = undefined;
335
- }
336
- if (newValue !== this.value) {
337
- this.set(newValue);
338
- }
339
- }
340
- },
341
- };
342
- return field;
343
- }
344
- function newListFieldState(parentInstance, parentState, key, rules, listConfig, config, maybeAutoSave) {
345
- // Keep a map of "item in the parent list" -> "that item's ObjectState"
346
- const rowMap = new Map();
347
- const _tick = mobx_1.observable({ value: 1 });
348
- // this is for dirty checking, not object identity
349
- let originalCopy = [...(parentInstance[key] || [])];
350
- const list = {
351
- key: key,
352
- // Our fundamental state of wrapped Us
353
- get value() {
354
- return _tick.value > 0 ? parentInstance[key] : utils_1.fail();
355
- },
356
- _focused: false,
357
- _readOnly: false,
358
- get readOnly() {
359
- return this._readOnly;
360
- },
361
- set readOnly(readOnly) {
362
- this._readOnly = readOnly;
363
- this.rows.forEach((r) => (r.readOnly = readOnly));
364
- },
365
- set value(v) {
366
- this.set(v);
367
- },
368
- get dirty() {
369
- return this.rows.some((r) => r.dirty) || this.hasNewItems();
370
- },
371
- get required() {
372
- return this.rules.some((rule) => rule === exports.required);
373
- },
374
- get isNewEntity() {
375
- return parentState().isNewEntity;
376
- },
377
- // private
378
- hasNewItems() {
379
- const [current, original] = [this.value || [], originalCopy || []];
380
- // Instead of relying on just object identities, we look up each child's state
381
- // in rowMap, because we already dedup/check object identity (i.e. look for id fields)
382
- // when create object states.
383
- const a = current.every((e) => {
384
- const state = rowMap.get(e);
385
- return original.some((e) => rowMap.get(e) === state);
386
- });
387
- const b = original.every((e) => {
388
- const state = rowMap.get(e);
389
- return current.some((e) => rowMap.get(e) === state);
390
- });
391
- const isSame = a && b;
392
- return !isSame;
393
- },
394
- // And we can derive each value's ObjectState wrapper as needed from the rowMap cache
395
- get rows() {
396
- // It's unclear why we need to access _tick.value here, b/c calling `this.value` should
397
- // transitively register us as a dependency on it
398
- if (_tick.value < 0)
399
- utils_1.fail();
400
- return (this.value || []).map((child) => {
401
- // Because we're reading from this.value, child will be the proxy version
402
- let childState = rowMap.get(child);
403
- if (!childState) {
404
- childState = newObjectState(config, parentState, child, undefined, maybeAutoSave);
405
- rowMap.set(child, childState);
406
- }
407
- return childState;
408
- });
409
- },
410
- // TODO Should this be true when all rows are touched?
411
- get touched() {
412
- return this.rows.some((r) => r.touched) || this.hasNewItems();
413
- },
414
- set touched(touched) {
415
- this.rows.forEach((r) => (r.touched = touched));
416
- },
417
- rules,
418
- get valid() {
419
- const value = this.rows;
420
- // TODO Passing `originalCopy || []` is probably not 100% right
421
- const opts = { value, key: key, originalValue: originalCopy || [], object: parentState() };
422
- const collectionValid = this.rules.every((r) => r(opts) === undefined);
423
- const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
424
- return collectionValid && entriesValid;
425
- },
426
- get errors() {
427
- if (_tick.value < 0)
428
- utils_1.fail();
429
- const opts = { value: this.rows, key: key, originalValue: originalCopy || [], object: parentState() };
430
- return this.rules.map((r) => r(opts)).filter(isNotUndefined);
431
- },
432
- get changedValue() {
433
- const result = [];
434
- const hasOpKey = Object.keys(listConfig.config).includes("op");
435
- const hasLegacyOpKey = Object.keys(listConfig.config).some((key) => key === "delete" || key === "remove");
436
- const incremental = listConfig.update === "incremental" ||
437
- // Implicitly enable incremental mode if we see an op key
438
- (listConfig.update === undefined && (hasOpKey || hasLegacyOpKey));
439
- const exhaustive = !incremental;
440
- this.rows.forEach((r) => {
441
- var _a;
442
- var _b;
443
- if (exhaustive || r.dirty || r.isNewEntity) {
444
- const changed = r.changedValue;
445
- // Ensure we have an `op: include` key, following https://joist-orm.io/docs/features/partial-update-apis
446
- if (incremental && hasOpKey) {
447
- (_a = (_b = changed).op) !== null && _a !== void 0 ? _a : (_b.op = "include");
448
- }
449
- result.push(changed);
450
- }
451
- });
452
- return result;
453
- },
454
- focus() {
455
- this._focused = true;
456
- },
457
- blur() {
458
- this._focused = false;
459
- this.maybeAutoSave();
460
- },
461
- maybeAutoSave() {
462
- this.touched = true;
463
- maybeAutoSave();
464
- },
465
- set(values, opts = {}) {
466
- if (this.readOnly && !opts.resetting && !opts.refreshing) {
467
- throw new Error(`${key} is currently readOnly`);
468
- }
469
- // We should be passed values that are non-proxies.
470
- parentInstance[key] = values.map((value) => {
471
- let childState = rowMap.get(value);
472
- if (!childState) {
473
- // Look for an existing child (requires having an id key configured)
474
- for (const [, otherState] of rowMap.entries()) {
475
- if (otherState.isSameEntity(value)) {
476
- otherState.set(value, opts);
477
- rowMap.set(value, otherState);
478
- return otherState.value;
479
- }
480
- }
481
- // If we didn't have an existing child, just make a new object state
482
- childState = createObjectState(config, value, { maybeAutoSave });
483
- rowMap.set(value, childState);
484
- }
485
- // Return the already-observable'd value so that our `parent.value[key] = values` doesn't re-proxy things
486
- return childState.value;
487
- });
488
- // Reset originalCopy so that our dirty checks have the right # of rows.
489
- if (opts.refreshing) {
490
- originalCopy = [...(parentInstance[key] || [])];
491
- }
492
- _tick.value++;
493
- },
494
- add(value, spliceIndex) {
495
- // This is called by the user, so value should be a non-proxy value we should keep
496
- const childState = createObjectState(config, value, { maybeAutoSave });
497
- rowMap.set(value, childState);
498
- this.ensureSet();
499
- this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
500
- _tick.value++;
501
- maybeAutoSave();
502
- },
503
- remove(indexOrValue) {
504
- this.ensureSet();
505
- if (typeof indexOrValue === "number") {
506
- this.value.splice(indexOrValue, 1);
507
- }
508
- else {
509
- const index = this.value.findIndex((v) => v === indexOrValue);
510
- if (index > -1) {
511
- this.value.splice(index, 1);
512
- }
513
- }
514
- _tick.value++;
515
- maybeAutoSave();
516
- },
517
- reset() {
518
- if (originalCopy) {
519
- this.set(originalCopy, { resetting: true });
520
- this.rows.forEach((r) => r.reset());
521
- }
522
- },
523
- save() {
524
- this.rows.forEach((r) => {
525
- r.save();
526
- });
527
- originalCopy = [...(parentInstance[key] || [])];
528
- _tick.value++;
529
- },
530
- ensureSet() {
531
- if (!parentInstance[key]) {
532
- parentInstance[key] = [];
533
- }
534
- _tick.value++;
535
- },
536
- };
537
- return mobx_1.makeAutoObservable(list, {
538
- // See other makeAutoObservable comment
539
- value: mobx_1.computed({ equals: () => false }),
540
- });
541
- }
542
- function isNotUndefined(value) {
543
- return value !== undefined;
544
- }
545
- function isEmpty(value) {
546
- return value === undefined || value === null || value === "";
547
- }
548
- /**
549
- * An equals that does deep-ish equality.
550
- *
551
- * We only do non-identity equals for:
552
- *
553
- * - "plain" objects that have no custom prototype/i.e. are object literals
554
- * - objects that implement `toJSON`
555
- *
556
- */
557
- function areEqual(a, b) {
558
- if (is_plain_object_1.isPlainObject(a)) {
559
- return fast_deep_equal_1.default(mobx_1.toJS(a), mobx_1.toJS(b));
560
- }
561
- if (hasToJSON(a) || hasToJSON(b)) {
562
- const a1 = hasToJSON(a) ? a.toJSON() : a;
563
- const b1 = hasToJSON(b) ? b.toJSON() : b;
564
- return fast_deep_equal_1.default(a1, b1);
565
- }
566
- if (a && b && a instanceof Array && b instanceof Array) {
567
- return fast_deep_equal_1.default(a, b);
568
- }
569
- return a === b;
570
- }
571
- function hasToJSON(o) {
572
- return !!(o && typeof o === "object" && "toJSON" in o);
573
- }