@homebound/form-state 2.33.0 → 3.1.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 +6 -6
- package/dist/index.d.ts +450 -9
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1118 -14
- package/dist/index.js.map +1 -0
- package/package.json +42 -50
- package/skills/form-state/SKILL.md +9 -9
- package/dist/FormStateApp.d.ts +0 -5
- package/dist/FormStateApp.js +0 -54
- package/dist/FormStateApp.test.d.ts +0 -1
- package/dist/FormStateApp.test.js +0 -26
- package/dist/config.d.ts +0 -78
- package/dist/config.js +0 -2
- package/dist/configBuilders.d.ts +0 -67
- package/dist/configBuilders.js +0 -115
- package/dist/configBuilders.test.d.ts +0 -1
- package/dist/configBuilders.test.js +0 -65
- package/dist/fields/deepEquals.d.ts +0 -7
- package/dist/fields/deepEquals.js +0 -53
- package/dist/fields/fragmentField.d.ts +0 -4
- package/dist/fields/fragmentField.js +0 -58
- package/dist/fields/listField.d.ts +0 -11
- package/dist/fields/listField.js +0 -355
- package/dist/fields/objectField.d.ts +0 -63
- package/dist/fields/objectField.js +0 -277
- package/dist/fields/valueField.d.ts +0 -81
- package/dist/fields/valueField.js +0 -244
- package/dist/formState.stories.d.ts +0 -4
- package/dist/formState.stories.js +0 -12
- package/dist/formState.test.d.ts +0 -11
- package/dist/formState.test.js +0 -2109
- package/dist/formStateDomain.d.ts +0 -47
- package/dist/formStateDomain.js +0 -28
- package/dist/mobx-behavior.test.d.ts +0 -1
- package/dist/mobx-behavior.test.js +0 -51
- package/dist/proxies.d.ts +0 -5
- package/dist/proxies.js +0 -20
- package/dist/rules.d.ts +0 -10
- package/dist/rules.js +0 -11
- package/dist/setupTests.d.ts +0 -1
- package/dist/setupTests.js +0 -15
- package/dist/stackoverlfow.d.ts +0 -17
- package/dist/stackoverlfow.js +0 -17
- package/dist/temporal.d.ts +0 -6
- package/dist/temporal.js +0 -34
- package/dist/temporal.test.d.ts +0 -1
- package/dist/temporal.test.js +0 -97
- package/dist/useFormState.d.ts +0 -82
- package/dist/useFormState.js +0 -158
- package/dist/useFormState.test.d.ts +0 -1
- package/dist/useFormState.test.js +0 -787
- package/dist/useFormStates.d.ts +0 -68
- package/dist/useFormStates.js +0 -101
- package/dist/useFormStates.test.d.ts +0 -1
- package/dist/useFormStates.test.js +0 -223
- package/dist/utils.d.ts +0 -52
- package/dist/utils.js +0 -198
- package/dist/utils.test.d.ts +0 -1
- package/dist/utils.test.js +0 -102
package/dist/index.js
CHANGED
|
@@ -1,14 +1,1118 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { action, computed, createAtom, isObservable, makeAutoObservable, reaction, toJS } from "mobx";
|
|
2
|
+
import { isPlainObject } from "is-plain-object";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import hash from "object-hash";
|
|
5
|
+
//#region src/proxies.ts
|
|
6
|
+
/**
|
|
7
|
+
* Creates a new combined object with keys in `overrides` taking precedence, and then
|
|
8
|
+
* any other keys falling back to `delegate`.
|
|
9
|
+
*/
|
|
10
|
+
function newDelegateProxy(delegate, overrides) {
|
|
11
|
+
function pickTarget(key) {
|
|
12
|
+
return Reflect.has(overrides, key) ? overrides : delegate;
|
|
13
|
+
}
|
|
14
|
+
return new Proxy(delegate, {
|
|
15
|
+
get(object, key) {
|
|
16
|
+
return Reflect.get(pickTarget(key), key);
|
|
17
|
+
},
|
|
18
|
+
set(object, key, value) {
|
|
19
|
+
return Reflect.set(pickTarget(key), key, value);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/rules.ts
|
|
25
|
+
/** A rule that validates `value` is not `undefined`, `null`, or empty string. */
|
|
26
|
+
const required = action(({ value: v }) => {
|
|
27
|
+
const isEmptyString = typeof v === "string" ? v.trim() === "" : false;
|
|
28
|
+
return v !== void 0 && v !== null && !isEmptyString ? void 0 : "Required";
|
|
29
|
+
});
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/temporal.ts
|
|
32
|
+
const temporalPlainDateTag = "Temporal.PlainDate";
|
|
33
|
+
const temporalZonedDateTimeTag = "Temporal.ZonedDateTime";
|
|
34
|
+
/** Detects the Temporal value types that form-state treats as builtins. */
|
|
35
|
+
function isSupportedTemporal(value) {
|
|
36
|
+
if (!value || typeof value !== "object") return false;
|
|
37
|
+
const record = value;
|
|
38
|
+
return isSupportedTemporalTag(record[Symbol.toStringTag]) && typeof record.equals === "function" && typeof record.toJSON === "function";
|
|
39
|
+
}
|
|
40
|
+
/** Compares supported Temporal values with Temporal's `.equals(...)` semantics. */
|
|
41
|
+
function areSupportedTemporalValuesEqual(a, b) {
|
|
42
|
+
const aIsTemporal = isSupportedTemporal(a);
|
|
43
|
+
const bIsTemporal = isSupportedTemporal(b);
|
|
44
|
+
if (!aIsTemporal && !bIsTemporal) return;
|
|
45
|
+
if (!aIsTemporal || !bIsTemporal || a[Symbol.toStringTag] !== b[Symbol.toStringTag]) return false;
|
|
46
|
+
if (isTemporalPlainDate(a)) return a.equals(b);
|
|
47
|
+
return a.equals(b);
|
|
48
|
+
}
|
|
49
|
+
function isSupportedTemporalTag(value) {
|
|
50
|
+
return value === temporalPlainDateTag || value === temporalZonedDateTimeTag;
|
|
51
|
+
}
|
|
52
|
+
/** Narrows a supported Temporal value to `Temporal.PlainDate` by its well-known tag. */
|
|
53
|
+
function isTemporalPlainDate(value) {
|
|
54
|
+
return value[Symbol.toStringTag] === temporalPlainDateTag;
|
|
55
|
+
}
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/fields/deepEquals.ts
|
|
58
|
+
/**
|
|
59
|
+
* Our own `deepEquals` because either `fast-deep-equals` or `dequal` or etc actually
|
|
60
|
+
* handle cyclic data structures, despite ChatGTP's assertions/hallucinations.
|
|
61
|
+
*
|
|
62
|
+
* Ported from https://github.com/KoryNunn/cyclic-deep-equal which is ISC.
|
|
63
|
+
*/
|
|
64
|
+
function deepEquals(a, b, visited = /* @__PURE__ */ new Set()) {
|
|
65
|
+
const temporalEquals = areSupportedTemporalValuesEqual(a, b);
|
|
66
|
+
if (temporalEquals !== void 0) return temporalEquals;
|
|
67
|
+
const aType = typeof a;
|
|
68
|
+
if (aType !== typeof b) return false;
|
|
69
|
+
if (a == null || b == null || !(aType === "object" || aType === "function")) {
|
|
70
|
+
if (aType === "number" && isNaN(a) && isNaN(b)) return true;
|
|
71
|
+
return a === b;
|
|
72
|
+
}
|
|
73
|
+
if (hasToJSON(a) || hasToJSON(b)) return deepEquals(hasToJSON(a) ? a.toJSON() : a, hasToJSON(b) ? b.toJSON() : b, visited);
|
|
74
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
75
|
+
const aKeys = Object.keys(a), bKeys = Object.keys(b);
|
|
76
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
77
|
+
let equal = true;
|
|
78
|
+
for (const key of aKeys) {
|
|
79
|
+
if (!(key in b)) {
|
|
80
|
+
equal = false;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (a[key] && a[key] instanceof Object) {
|
|
84
|
+
if (visited.has(a[key])) continue;
|
|
85
|
+
visited.add(a[key]);
|
|
86
|
+
}
|
|
87
|
+
if (!deepEquals(a[key], b[key], visited)) {
|
|
88
|
+
equal = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return equal;
|
|
93
|
+
}
|
|
94
|
+
/** Returns whether `o` is an object with a `toJSON` method, i.e. a Date or a custom value type. */
|
|
95
|
+
function hasToJSON(o) {
|
|
96
|
+
return !!(o && typeof o === "object" && "toJSON" in o);
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/useFormState.ts
|
|
100
|
+
/**
|
|
101
|
+
* Creates a formState instance for editing in a form.
|
|
102
|
+
*/
|
|
103
|
+
function useFormState(opts) {
|
|
104
|
+
const { config, init, addRules, readOnly = false, loading, autoSave } = opts;
|
|
105
|
+
const autoSaveRef = useRef(autoSave);
|
|
106
|
+
autoSaveRef.current = autoSave;
|
|
107
|
+
const initRef = useRef(init);
|
|
108
|
+
initRef.current = init;
|
|
109
|
+
const firstRunRef = useRef(true);
|
|
110
|
+
const [firstInitValue] = useState(() => initValue(config, init));
|
|
111
|
+
const isWrappingMobxProxy = !isPlainObject(firstInitValue);
|
|
112
|
+
const dep = isInput(init) ? init.onlyOnce ? [] : makeArray(init.input) : isQuery(init) ? [init.query.data, init.query.loading] : [];
|
|
113
|
+
const form = useMemo(() => {
|
|
114
|
+
let isAutoSaving = false;
|
|
115
|
+
let pendingAutoSave = false;
|
|
116
|
+
function maybeAutoSave() {
|
|
117
|
+
if (isAutoSaving === "in-flight") pendingAutoSave = true;
|
|
118
|
+
else if (isAutoSaving === "queued") return;
|
|
119
|
+
if (autoSaveRef.current && form.dirty && !isAutoSaving) {
|
|
120
|
+
if (!form.valid) {
|
|
121
|
+
console.debug("Skipping auto-save b/c form is invalid: ", form.errors);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
isAutoSaving = "queued";
|
|
125
|
+
setTimeout(async () => {
|
|
126
|
+
try {
|
|
127
|
+
form._isAutoSaving = true;
|
|
128
|
+
const promise = autoSaveRef.current(form);
|
|
129
|
+
isAutoSaving = "in-flight";
|
|
130
|
+
await promise;
|
|
131
|
+
} finally {
|
|
132
|
+
isAutoSaving = false;
|
|
133
|
+
form._isAutoSaving = false;
|
|
134
|
+
if (pendingAutoSave) {
|
|
135
|
+
pendingAutoSave = false;
|
|
136
|
+
setTimeout(maybeAutoSave, 0);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, 0);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const value = firstRunRef.current ? firstInitValue : initValue(config, init);
|
|
143
|
+
const form = createObjectState(config, value, { maybeAutoSave });
|
|
144
|
+
form.update = function update(input) {
|
|
145
|
+
const init = initRef.current;
|
|
146
|
+
const updateInit = isInput(init) ? {
|
|
147
|
+
...init,
|
|
148
|
+
input
|
|
149
|
+
} : isQuery(init) ? {
|
|
150
|
+
input,
|
|
151
|
+
map: init.map,
|
|
152
|
+
ifUndefined: init.ifUndefined
|
|
153
|
+
} : { input };
|
|
154
|
+
form.set(initValue(config, updateInit), { refreshing: true });
|
|
155
|
+
};
|
|
156
|
+
form.readOnly = readOnly;
|
|
157
|
+
setLoading(form, opts);
|
|
158
|
+
(addRules || (() => {}))(form);
|
|
159
|
+
firstRunRef.current = true;
|
|
160
|
+
return form;
|
|
161
|
+
}, [config, ...isWrappingMobxProxy ? dep : []]);
|
|
162
|
+
const prevFormRef = useRef(void 0);
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (prevFormRef.current && prevFormRef.current !== form) prevFormRef.current.dispose();
|
|
165
|
+
prevFormRef.current = form;
|
|
166
|
+
}, [form]);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (firstRunRef.current || isWrappingMobxProxy) {
|
|
169
|
+
firstRunRef.current = false;
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
setLoading(form, opts);
|
|
173
|
+
form.set(initValue(config, init), { refreshing: true });
|
|
174
|
+
}, [form, ...dep]);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
form.readOnly = readOnly;
|
|
177
|
+
if (loading !== void 0) form.loading = loading;
|
|
178
|
+
}, [
|
|
179
|
+
form,
|
|
180
|
+
readOnly,
|
|
181
|
+
loading
|
|
182
|
+
]);
|
|
183
|
+
return form;
|
|
184
|
+
}
|
|
185
|
+
function setLoading(form, opts) {
|
|
186
|
+
const { loading, init } = opts;
|
|
187
|
+
if (loading !== void 0) form.loading = loading;
|
|
188
|
+
else if (isInput(init) && !init.ifUndefined) form.loading = init.input === void 0;
|
|
189
|
+
else if (isQuery(init)) form.loading = init.query.loading;
|
|
190
|
+
}
|
|
191
|
+
function makeArray(input) {
|
|
192
|
+
return Array.isArray(input) ? input : [input];
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
195
|
+
//#region src/utils.ts
|
|
196
|
+
function assertNever(x) {
|
|
197
|
+
throw new Error("Unexpected object: " + x);
|
|
198
|
+
}
|
|
199
|
+
/** Introspects the `init` prop to see if it has a `map` function/etc. and returns the form value. */
|
|
200
|
+
function initValue(config, init) {
|
|
201
|
+
let value;
|
|
202
|
+
if (isInput(init)) value = init.input ? init.map ? init.map(init.input) : init.input : init.ifUndefined;
|
|
203
|
+
else if (isQuery(init)) value = init.query.data ? init.map(init.query.data) : init.ifUndefined;
|
|
204
|
+
else if (init === void 0) {} else throw new Error("init must have an input or query key");
|
|
205
|
+
return pickFields(config, value ?? {});
|
|
206
|
+
}
|
|
207
|
+
function isInput(init) {
|
|
208
|
+
return !!init && typeof init === "object" && "input" in init;
|
|
209
|
+
}
|
|
210
|
+
function isQuery(init) {
|
|
211
|
+
return !!init && typeof init === "object" && "query" in init && "map" in init;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Picks ony fields out of `instance` that are in the `formConfig`.
|
|
215
|
+
*
|
|
216
|
+
* This is useful for creating a form input from a GraphQL query result,
|
|
217
|
+
* but ignoring extra data in the query that we don't want/need in the form.
|
|
218
|
+
*
|
|
219
|
+
* (Especially because the form.value will become our mutation's input type
|
|
220
|
+
* that goes on the wire, we don't want superfluous data in it.)
|
|
221
|
+
*/
|
|
222
|
+
function pickFields(formConfig, instance) {
|
|
223
|
+
if (!isPlainObject(instance)) return instance;
|
|
224
|
+
return Object.fromEntries(Object.entries(formConfig).map(([key, _keyConfig]) => {
|
|
225
|
+
const keyConfig = _keyConfig;
|
|
226
|
+
const value = instance[key];
|
|
227
|
+
if (keyConfig.type === "object") {
|
|
228
|
+
if (value) return [key, pickFields(keyConfig.config, value)];
|
|
229
|
+
else return [key, value];
|
|
230
|
+
} else if (keyConfig.type === "list") {
|
|
231
|
+
if (isObservable(value)) return [key, value];
|
|
232
|
+
else if (value) return [key, value.map((u) => pickFields(keyConfig.config, u))];
|
|
233
|
+
else return [key, value];
|
|
234
|
+
} else if (keyConfig.type === "value") return [key, value];
|
|
235
|
+
else if (keyConfig.type === "fragment") return [key, value];
|
|
236
|
+
else return assertNever(keyConfig);
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
function isNotUndefined(value) {
|
|
240
|
+
return value !== void 0;
|
|
241
|
+
}
|
|
242
|
+
function isEmpty(value) {
|
|
243
|
+
return value === void 0 || value === null || value === "";
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* An equals that does deep-ish equality.
|
|
247
|
+
*
|
|
248
|
+
* We only do non-identity equals for:
|
|
249
|
+
*
|
|
250
|
+
* - "plain" objects that have no custom prototype/i.e. are object literals
|
|
251
|
+
* - objects that implement `toJSON`
|
|
252
|
+
* - arrays
|
|
253
|
+
*/
|
|
254
|
+
function areEqual(a, b, strictOrder) {
|
|
255
|
+
const temporalEquals = areSupportedTemporalValuesEqual(a, b);
|
|
256
|
+
if (temporalEquals !== void 0) return temporalEquals;
|
|
257
|
+
if (isPlainObject(a)) return deepEquals(toJS(a), toJS(b));
|
|
258
|
+
if (hasToJSON(a) || hasToJSON(b)) return deepEquals(hasToJSON(a) ? a.toJSON() : a, hasToJSON(b) ? b.toJSON() : b);
|
|
259
|
+
if (a && b && a instanceof Array && b instanceof Array) {
|
|
260
|
+
if (strictOrder !== false) return deepEquals(a, b);
|
|
261
|
+
if (a.length !== b.length) return false;
|
|
262
|
+
return a.every((a1) => b.some((b1) => areEqual(a1, b1)));
|
|
263
|
+
}
|
|
264
|
+
return a === b;
|
|
265
|
+
}
|
|
266
|
+
/** Normalizes values into stable object-hash-safe data using our existing `toJSON` semantics. */
|
|
267
|
+
function normalizeHashValue(value, seen = /* @__PURE__ */ new WeakMap()) {
|
|
268
|
+
if (hasToJSON(value)) return normalizeHashValue(value.toJSON(), seen);
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
if (seen.has(value)) return seen.get(value);
|
|
271
|
+
const result = [];
|
|
272
|
+
seen.set(value, result);
|
|
273
|
+
value.forEach((entry) => result.push(normalizeHashValue(entry, seen)));
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
if (value && typeof value === "object" && isPlainObject(value)) {
|
|
277
|
+
if (seen.has(value)) return seen.get(value);
|
|
278
|
+
const result = {};
|
|
279
|
+
seen.set(value, result);
|
|
280
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
281
|
+
result[key] = normalizeHashValue(entry, seen);
|
|
282
|
+
});
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
288
|
+
function deepClone(obj, map = /* @__PURE__ */ new WeakMap()) {
|
|
289
|
+
if (obj && typeof obj === "object" && (isPlainObject(obj) || Array.isArray(obj) || isObservable(obj))) {
|
|
290
|
+
if (map.has(obj)) return map.get(obj);
|
|
291
|
+
const result = Array.isArray(obj) ? [] : {};
|
|
292
|
+
map.set(obj, result);
|
|
293
|
+
Object.assign(result, ...getAllPropertyNames(obj).map((key) => ({ [key]: deepClone(obj[key], map) })));
|
|
294
|
+
return result;
|
|
295
|
+
} else return obj;
|
|
296
|
+
}
|
|
297
|
+
function groupBy(list, fn, valueFn) {
|
|
298
|
+
const result = /* @__PURE__ */ new Map();
|
|
299
|
+
list.forEach((o) => {
|
|
300
|
+
const group = fn(o);
|
|
301
|
+
if (!result.has(group)) result.set(group, []);
|
|
302
|
+
result.get(group).push(valueFn === void 0 ? o : valueFn(o));
|
|
303
|
+
});
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
/** Returns all property names, including mobx computeds (non-enumerable) & inherited. */
|
|
307
|
+
function getAllPropertyNames(obj) {
|
|
308
|
+
const proto = Object.getPrototypeOf(obj);
|
|
309
|
+
const inherited = proto && proto !== Object.prototype && !Array.isArray(obj) ? getAllPropertyNames(proto) : [];
|
|
310
|
+
return [...new Set(Object.getOwnPropertyNames(obj).concat(inherited))];
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/fields/valueField.ts
|
|
314
|
+
function newValueFieldState(parentCopy, parentInstance, parentState, key, rules, isIdKey, isDeleteKey, isReadOnlyKey, isLocalOnly, computed, readOnly, strictOrder, maybeAutoSave) {
|
|
315
|
+
const valueAtom = createAtom(`${String(key)}.value`);
|
|
316
|
+
const originalValueAtom = createAtom(`${String(key)}.originalValue`);
|
|
317
|
+
let hasInflightChangedValue = false;
|
|
318
|
+
let lastChangedValue = void 0;
|
|
319
|
+
let disposeStoreReaction = void 0;
|
|
320
|
+
const field = {
|
|
321
|
+
key,
|
|
322
|
+
touched: false,
|
|
323
|
+
/** Current readOnly value. */
|
|
324
|
+
_readOnly: readOnly || false,
|
|
325
|
+
_loading: false,
|
|
326
|
+
_kind: "value",
|
|
327
|
+
_computed: computed,
|
|
328
|
+
_focused: false,
|
|
329
|
+
_isIdKey: isIdKey,
|
|
330
|
+
_isDeleteKey: isDeleteKey,
|
|
331
|
+
_isReadOnlyKey: isReadOnlyKey,
|
|
332
|
+
_isLocalOnly: isLocalOnly,
|
|
333
|
+
rules,
|
|
334
|
+
get value() {
|
|
335
|
+
valueAtom.reportObserved();
|
|
336
|
+
const value = parentInstance[key];
|
|
337
|
+
return value === null && isEmpty(parentCopy[key]) ? void 0 : value;
|
|
338
|
+
},
|
|
339
|
+
set value(v) {
|
|
340
|
+
this.set(v);
|
|
341
|
+
},
|
|
342
|
+
get focused() {
|
|
343
|
+
return this._focused;
|
|
344
|
+
},
|
|
345
|
+
get dirty() {
|
|
346
|
+
return !areEqual(this.originalValue, this.value, strictOrder);
|
|
347
|
+
},
|
|
348
|
+
/** Returns whether this field is readOnly, although if our parent is readOnly then it trumps. */
|
|
349
|
+
get readOnly() {
|
|
350
|
+
return parentState().readOnly || this._readOnly;
|
|
351
|
+
},
|
|
352
|
+
/** Sets the field readOnly, but `loading` will still be or-d with the parent's readOnly state. */
|
|
353
|
+
set readOnly(readOnly) {
|
|
354
|
+
this._readOnly = readOnly;
|
|
355
|
+
},
|
|
356
|
+
/** Returns whether this field is loading, or if our parent is loading. */
|
|
357
|
+
get loading() {
|
|
358
|
+
return parentState().loading || this._loading;
|
|
359
|
+
},
|
|
360
|
+
/** Sets the field loading, but `loading` will still be or-d with the parent's loading state. */
|
|
361
|
+
set loading(loading) {
|
|
362
|
+
this._loading = loading;
|
|
363
|
+
},
|
|
364
|
+
get changedValue() {
|
|
365
|
+
hasInflightChangedValue = true;
|
|
366
|
+
lastChangedValue = this.value;
|
|
367
|
+
if (this.value === void 0 && this.originalValue !== void 0) return null;
|
|
368
|
+
return this.value;
|
|
369
|
+
},
|
|
370
|
+
get valid() {
|
|
371
|
+
return this.errors.length === 0;
|
|
372
|
+
},
|
|
373
|
+
get errors() {
|
|
374
|
+
const opts = {
|
|
375
|
+
value: this.value,
|
|
376
|
+
key,
|
|
377
|
+
originalValue: this.originalValue,
|
|
378
|
+
object: parentState()
|
|
379
|
+
};
|
|
380
|
+
return this.rules.map((r) => r(opts)).filter(isNotUndefined);
|
|
381
|
+
},
|
|
382
|
+
get required() {
|
|
383
|
+
return this.rules.some((rule) => rule === required);
|
|
384
|
+
},
|
|
385
|
+
get isNewEntity() {
|
|
386
|
+
return parentState().isNewEntity;
|
|
387
|
+
},
|
|
388
|
+
focus() {
|
|
389
|
+
this._focused = true;
|
|
390
|
+
},
|
|
391
|
+
blur() {
|
|
392
|
+
this.maybeAutoSave();
|
|
393
|
+
this._focused = false;
|
|
394
|
+
},
|
|
395
|
+
maybeAutoSave() {
|
|
396
|
+
this.maybeTrim();
|
|
397
|
+
this.touched = true;
|
|
398
|
+
maybeAutoSave();
|
|
399
|
+
},
|
|
400
|
+
set(value, opts = {}) {
|
|
401
|
+
if (this.readOnly && !opts.resetting && !opts.refreshing) throw new Error(`${String(key)} is currently readOnly`);
|
|
402
|
+
if (opts.refreshing && this.dirty) {
|
|
403
|
+
const isAckingUnset = this.value === null && (value === null || value === void 0);
|
|
404
|
+
const acceptServerAck = hasInflightChangedValue && this.value === lastChangedValue && value !== this.originalValue;
|
|
405
|
+
if (!areEqual(this.value, value, strictOrder) && !isAckingUnset && !acceptServerAck) return;
|
|
406
|
+
} else if (computed && (opts.resetting || opts.refreshing)) return;
|
|
407
|
+
const keepNull = !isEmpty(this.originalValue) && isEmpty(value) && !opts.refreshing;
|
|
408
|
+
const coerceEmptyList = value && value instanceof Array && value.length === 0 && isEmpty(this.originalValue);
|
|
409
|
+
const newValue = keepNull ? null : isEmpty(value) || coerceEmptyList ? void 0 : value;
|
|
410
|
+
hasInflightChangedValue = false;
|
|
411
|
+
const changed = !areEqual(newValue, this.value, strictOrder);
|
|
412
|
+
parentInstance[key] = newValue;
|
|
413
|
+
valueAtom.reportChanged();
|
|
414
|
+
if (opts.refreshing) this.originalValue = newValue;
|
|
415
|
+
if (!this._focused && !opts.refreshing && !opts.resetting && this.dirty && changed && opts.autoSave !== false) this.maybeAutoSave();
|
|
416
|
+
},
|
|
417
|
+
adapt(adapter) {
|
|
418
|
+
return adapt(this, adapter);
|
|
419
|
+
},
|
|
420
|
+
revertChanges() {
|
|
421
|
+
if (!computed) this.set(this.originalValue, { resetting: true });
|
|
422
|
+
this.touched = false;
|
|
423
|
+
},
|
|
424
|
+
commitChanges() {
|
|
425
|
+
if (isPlainObject(this.originalValue)) this.originalValue = toJS(this.value);
|
|
426
|
+
else this.originalValue = this.value;
|
|
427
|
+
this.touched = false;
|
|
428
|
+
},
|
|
429
|
+
dispose() {
|
|
430
|
+
disposeStoreReaction?.();
|
|
431
|
+
disposeStoreReaction = void 0;
|
|
432
|
+
},
|
|
433
|
+
get originalValue() {
|
|
434
|
+
originalValueAtom.reportObserved();
|
|
435
|
+
const value = parentCopy[key];
|
|
436
|
+
return value === null ? void 0 : value;
|
|
437
|
+
},
|
|
438
|
+
set originalValue(v) {
|
|
439
|
+
if (!(v === void 0 && !(key in parentCopy))) parentCopy[key] = v;
|
|
440
|
+
originalValueAtom.reportChanged();
|
|
441
|
+
},
|
|
442
|
+
maybeTrim() {
|
|
443
|
+
if (typeof this.value === "string") {
|
|
444
|
+
let newValue = this.value.trim();
|
|
445
|
+
if (newValue === "") newValue = void 0;
|
|
446
|
+
if (newValue !== this.value) this.set(newValue);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
if (isObservable(parentInstance)) disposeStoreReaction = reaction(() => parentInstance[key], () => {
|
|
451
|
+
if (!parentState().focused) maybeAutoSave();
|
|
452
|
+
});
|
|
453
|
+
return field;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Returns a proxy that looks exactly like the original `field`, in terms of valid/touched/errors/etc., but
|
|
457
|
+
* has any methods that use `V` overridden to use be `V2`.
|
|
458
|
+
*
|
|
459
|
+
* Note that `V2` can be a new type, like string -> number, or just a transformation on the same
|
|
460
|
+
* type, i.e. feet -> inches where both are `number`s.
|
|
461
|
+
*/
|
|
462
|
+
function adapt(field, adapter) {
|
|
463
|
+
return newDelegateProxy(field, {
|
|
464
|
+
rules: [],
|
|
465
|
+
get value() {
|
|
466
|
+
return adapter.toValue(field.value);
|
|
467
|
+
},
|
|
468
|
+
set value(v) {
|
|
469
|
+
field.value = adapter.fromValue(v);
|
|
470
|
+
},
|
|
471
|
+
set: (v) => {
|
|
472
|
+
field.value = adapter.fromValue(v);
|
|
473
|
+
},
|
|
474
|
+
get changedValue() {
|
|
475
|
+
return this.value;
|
|
476
|
+
},
|
|
477
|
+
adapt(adapter) {
|
|
478
|
+
return adapt(this, adapter);
|
|
479
|
+
},
|
|
480
|
+
get originalValue() {
|
|
481
|
+
return adapter.toValue(field.originalValue);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
//#endregion
|
|
486
|
+
//#region src/fields/fragmentField.ts
|
|
487
|
+
function newFragmentField(parentInstance, key) {
|
|
488
|
+
const valueAtom = createAtom(`${key}.value`);
|
|
489
|
+
let value = parentInstance[key];
|
|
490
|
+
delete parentInstance[key];
|
|
491
|
+
return makeAutoObservable({
|
|
492
|
+
key,
|
|
493
|
+
_isIdKey: false,
|
|
494
|
+
_isDeleteKey: false,
|
|
495
|
+
_isReadOnlyKey: false,
|
|
496
|
+
_isLocalOnly: false,
|
|
497
|
+
touched: false,
|
|
498
|
+
valid: true,
|
|
499
|
+
readOnly: true,
|
|
500
|
+
required: false,
|
|
501
|
+
loading: false,
|
|
502
|
+
dirty: false,
|
|
503
|
+
focused: false,
|
|
504
|
+
originalValue: void 0,
|
|
505
|
+
changedValue: void 0,
|
|
506
|
+
errors: [],
|
|
507
|
+
rules: [],
|
|
508
|
+
isNewEntity: false,
|
|
509
|
+
focus: () => {},
|
|
510
|
+
blur: () => {},
|
|
511
|
+
maybeAutoSave: () => {},
|
|
512
|
+
commitChanges: () => {},
|
|
513
|
+
revertChanges: () => {},
|
|
514
|
+
dispose: () => {},
|
|
515
|
+
get value() {
|
|
516
|
+
if (key in parentInstance) {
|
|
517
|
+
value = parentInstance[key];
|
|
518
|
+
delete parentInstance[key];
|
|
519
|
+
}
|
|
520
|
+
valueAtom.reportObserved();
|
|
521
|
+
return value;
|
|
522
|
+
},
|
|
523
|
+
set value(v) {
|
|
524
|
+
value = v;
|
|
525
|
+
valueAtom.reportChanged();
|
|
526
|
+
},
|
|
527
|
+
set(value) {
|
|
528
|
+
this.value = value;
|
|
529
|
+
},
|
|
530
|
+
adapt(value) {
|
|
531
|
+
throw new Error("FragmentField does not support adapt");
|
|
532
|
+
}
|
|
533
|
+
}, { value: false });
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region src/fields/listField.ts
|
|
537
|
+
function newListFieldState(parentCopy, parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeAutoSave, deepExhaustive) {
|
|
538
|
+
const rowMap = /* @__PURE__ */ new Map();
|
|
539
|
+
const addedRows = /* @__PURE__ */ new Set();
|
|
540
|
+
const valueAtom = createAtom(`${String(key)}.value`);
|
|
541
|
+
const originalValueAtom = createAtom(`${String(key)}.originalValue`);
|
|
542
|
+
const copyMap = /* @__PURE__ */ new Map();
|
|
543
|
+
(parentCopy[key] ?? []).forEach((copy, i) => {
|
|
544
|
+
copyMap.set(copy, parentInstance[key][i]);
|
|
545
|
+
});
|
|
546
|
+
function getOrCreateChildState(child, opts) {
|
|
547
|
+
let childState = rowMap.get(child);
|
|
548
|
+
if (!childState && copyMap.has(child)) childState = rowMap.get(copyMap.get(child));
|
|
549
|
+
if (!childState) {
|
|
550
|
+
for (const [, otherState] of rowMap.entries()) if (otherState.isSameEntity(child)) {
|
|
551
|
+
if (!opts?.skipSet) otherState.set(child, opts);
|
|
552
|
+
rowMap.set(child, otherState);
|
|
553
|
+
return otherState;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (!childState) {
|
|
557
|
+
childState = newObjectState(config, parentState, void 0, list, child, void 0, maybeAutoSave, listConfig.update === "deep-exhaustive");
|
|
558
|
+
rowMap.set(child, childState);
|
|
559
|
+
}
|
|
560
|
+
return childState;
|
|
561
|
+
}
|
|
562
|
+
const list = {
|
|
563
|
+
key,
|
|
564
|
+
get value() {
|
|
565
|
+
this.rows.forEach((r) => r.value);
|
|
566
|
+
valueAtom.reportObserved();
|
|
567
|
+
return parentInstance[key] ?? [];
|
|
568
|
+
},
|
|
569
|
+
_kind: "list",
|
|
570
|
+
_focused: false,
|
|
571
|
+
_readOnly: false,
|
|
572
|
+
_loading: false,
|
|
573
|
+
get readOnly() {
|
|
574
|
+
return this._readOnly || parentState().readOnly;
|
|
575
|
+
},
|
|
576
|
+
set readOnly(readOnly) {
|
|
577
|
+
this._readOnly = readOnly;
|
|
578
|
+
},
|
|
579
|
+
get loading() {
|
|
580
|
+
return this._loading || parentState().loading;
|
|
581
|
+
},
|
|
582
|
+
set loading(loading) {
|
|
583
|
+
this._loading = loading;
|
|
584
|
+
},
|
|
585
|
+
set value(v) {
|
|
586
|
+
this.set(v);
|
|
587
|
+
},
|
|
588
|
+
get dirty() {
|
|
589
|
+
return this.rows.some((r) => r.dirty) || this.hasChanged();
|
|
590
|
+
},
|
|
591
|
+
get hasNewEntity() {
|
|
592
|
+
return this.rows.some((r) => r.isNewEntity);
|
|
593
|
+
},
|
|
594
|
+
get focused() {
|
|
595
|
+
return this.rows.some((r) => r.focused);
|
|
596
|
+
},
|
|
597
|
+
get required() {
|
|
598
|
+
return this.rules.some((rule) => rule === required);
|
|
599
|
+
},
|
|
600
|
+
get isNewEntity() {
|
|
601
|
+
return parentState().isNewEntity;
|
|
602
|
+
},
|
|
603
|
+
/** Returns whether a row has been added/removed. */
|
|
604
|
+
hasChanged() {
|
|
605
|
+
const [current, original] = [this.value || [], this.originalValue];
|
|
606
|
+
if (current.length !== original.length) return true;
|
|
607
|
+
if (strictOrder) return original.some((e, idx) => current[idx] !== copyMap.get(e));
|
|
608
|
+
else return !original.every((e) => current.includes(copyMap.get(e)));
|
|
609
|
+
},
|
|
610
|
+
get rows() {
|
|
611
|
+
valueAtom.reportObserved();
|
|
612
|
+
return (parentInstance[key] || []).map((child) => getOrCreateChildState(child, { skipSet: true }));
|
|
613
|
+
},
|
|
614
|
+
get touched() {
|
|
615
|
+
return this.rows.some((r) => r.touched) || this.hasChanged();
|
|
616
|
+
},
|
|
617
|
+
set touched(touched) {
|
|
618
|
+
this.rows.forEach((r) => r.touched = touched);
|
|
619
|
+
},
|
|
620
|
+
rules,
|
|
621
|
+
get valid() {
|
|
622
|
+
const collectionValid = this.errors.length === 0;
|
|
623
|
+
const entriesValid = this.rows.filter((r) => !r._considerDeleted()).every((r) => r.valid);
|
|
624
|
+
return collectionValid && entriesValid;
|
|
625
|
+
},
|
|
626
|
+
/** Returns the errors from our own collection-level rules (not the rows' errors). */
|
|
627
|
+
get errors() {
|
|
628
|
+
valueAtom.reportObserved();
|
|
629
|
+
const opts = {
|
|
630
|
+
value: this.rows,
|
|
631
|
+
key,
|
|
632
|
+
originalValue: this.originalValue,
|
|
633
|
+
object: parentState()
|
|
634
|
+
};
|
|
635
|
+
return this.rules.map((r) => r(opts)).filter(isNotUndefined);
|
|
636
|
+
},
|
|
637
|
+
get changedValue() {
|
|
638
|
+
const result = [];
|
|
639
|
+
const hasOpKey = Object.keys(listConfig.config).includes("op");
|
|
640
|
+
const hasLegacyOpKey = Object.keys(listConfig.config).some((key) => key === "delete" || key === "remove");
|
|
641
|
+
const incremental = listConfig.update === "incremental" || listConfig.update === void 0 && (hasOpKey || hasLegacyOpKey);
|
|
642
|
+
const exhaustive = !incremental;
|
|
643
|
+
this.rows.forEach((r) => {
|
|
644
|
+
if (exhaustive || r.dirty || r.isNewEntity) {
|
|
645
|
+
const changed = r.changedValue;
|
|
646
|
+
if (incremental && hasOpKey) changed.op ??= "include";
|
|
647
|
+
result.push(changed);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
return result;
|
|
651
|
+
},
|
|
652
|
+
focus() {
|
|
653
|
+
this._focused = true;
|
|
654
|
+
},
|
|
655
|
+
blur() {
|
|
656
|
+
this._focused = false;
|
|
657
|
+
this.maybeAutoSave();
|
|
658
|
+
},
|
|
659
|
+
maybeAutoSave() {
|
|
660
|
+
this.touched = true;
|
|
661
|
+
maybeAutoSave();
|
|
662
|
+
},
|
|
663
|
+
set(values, opts = {}) {
|
|
664
|
+
if (this.readOnly && !opts.resetting && !opts.refreshing) throw new Error(`${String(key)} is currently readOnly`);
|
|
665
|
+
if ((this.dirty || this.hasNewEntity) && opts.refreshing) {
|
|
666
|
+
const currentItems = this.value || [];
|
|
667
|
+
const incomingItems = values || [];
|
|
668
|
+
const mergedItems = [];
|
|
669
|
+
const idKey = this.rows.length > 0 && this.rows[0].idKey;
|
|
670
|
+
const contentKeys = Object.entries(config.config).filter(([key, cfg]) => key !== idKey && cfg.type === "value").map(([key]) => key);
|
|
671
|
+
const hashByContent = (item) => hashValue(normalizeHashValue(Object.fromEntries(Object.entries(item).filter(([key]) => contentKeys.includes(key)))));
|
|
672
|
+
const hashById = (item) => {
|
|
673
|
+
const idValue = idKey ? item[idKey] : void 0;
|
|
674
|
+
return idValue === void 0 ? hashByContent(item) : idValue;
|
|
675
|
+
};
|
|
676
|
+
const currentById = groupBy(currentItems, hashById);
|
|
677
|
+
const incomingById = groupBy(incomingItems, hashById);
|
|
678
|
+
const incomingByContent = idKey && currentItems.some((item) => !item[idKey]) && groupBy(incomingItems, hashByContent);
|
|
679
|
+
const hasOpKey = Object.keys(listConfig.config).includes("op");
|
|
680
|
+
for (const currentItem of currentItems) {
|
|
681
|
+
const childState = rowMap.get(currentItem);
|
|
682
|
+
const hash = hashById(currentItem);
|
|
683
|
+
const match = incomingById.get(hash)?.[0] ?? (!!idKey && !currentItem[idKey] && !!incomingByContent && incomingByContent?.get(hash)?.[0]);
|
|
684
|
+
if (match) {
|
|
685
|
+
childState.set(match, opts);
|
|
686
|
+
mergedItems.push(childState.value);
|
|
687
|
+
addedRows.delete(currentItem);
|
|
688
|
+
} else if (!childState.dirty && !childState.isNewEntity && !addedRows.has(currentItem)) {} else if (hasOpKey && currentItem.op === "delete") {} else if (currentItems.length === incomingItems.length && !!idKey && currentItem[idKey] === void 0) {} else mergedItems.push(currentItem);
|
|
689
|
+
}
|
|
690
|
+
for (const incomingItem of incomingItems) if (!(currentById.get(hashById(incomingItem))?.[0] || currentById.get(hashByContent(incomingItem))?.[0])) {
|
|
691
|
+
const childState = getOrCreateChildState(incomingItem, opts);
|
|
692
|
+
mergedItems.push(childState.value);
|
|
693
|
+
}
|
|
694
|
+
parentInstance[key] = mergedItems;
|
|
695
|
+
valueAtom.reportChanged();
|
|
696
|
+
this.setOriginalValue(incomingItems);
|
|
697
|
+
} else {
|
|
698
|
+
parentInstance[key] = (values ?? []).map((child) => getOrCreateChildState(child, opts).value);
|
|
699
|
+
valueAtom.reportChanged();
|
|
700
|
+
if (opts.refreshing) this.setOriginalValue();
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
add(value, spliceIndex) {
|
|
704
|
+
const childState = getOrCreateChildState(value);
|
|
705
|
+
rowMap.set(value, childState);
|
|
706
|
+
addedRows.add(value);
|
|
707
|
+
this.ensureSet();
|
|
708
|
+
this.value.splice(typeof spliceIndex === "number" ? spliceIndex : this.value.length, 0, childState.value);
|
|
709
|
+
valueAtom.reportChanged();
|
|
710
|
+
maybeAutoSave();
|
|
711
|
+
},
|
|
712
|
+
remove(indexOrValue) {
|
|
713
|
+
this.ensureSet();
|
|
714
|
+
if (typeof indexOrValue === "number") this.value.splice(indexOrValue, 1);
|
|
715
|
+
else {
|
|
716
|
+
const index = this.value.findIndex((v) => v === indexOrValue);
|
|
717
|
+
if (index > -1) this.value.splice(index, 1);
|
|
718
|
+
}
|
|
719
|
+
valueAtom.reportChanged();
|
|
720
|
+
maybeAutoSave();
|
|
721
|
+
},
|
|
722
|
+
revertChanges() {
|
|
723
|
+
this.set(this.originalValue, { resetting: true });
|
|
724
|
+
this.rows.forEach((r) => r.revertChanges());
|
|
725
|
+
this.touched = false;
|
|
726
|
+
},
|
|
727
|
+
commitChanges() {
|
|
728
|
+
this.rows.forEach((r) => r.commitChanges());
|
|
729
|
+
this.setOriginalValue();
|
|
730
|
+
this.touched = false;
|
|
731
|
+
valueAtom.reportChanged();
|
|
732
|
+
},
|
|
733
|
+
/** Disposes every row's ObjectState, including rows that were removed from the list. */
|
|
734
|
+
dispose() {
|
|
735
|
+
new Set(rowMap.values()).forEach((row) => row.dispose());
|
|
736
|
+
},
|
|
737
|
+
get originalValue() {
|
|
738
|
+
originalValueAtom.reportObserved();
|
|
739
|
+
return parentCopy[key] ?? [];
|
|
740
|
+
},
|
|
741
|
+
setOriginalValue(incomingItems) {
|
|
742
|
+
parentCopy[key] = incomingItems ?? this.rows.map((r) => r.originalValue);
|
|
743
|
+
parentCopy[key].forEach((copy, i) => {
|
|
744
|
+
copyMap.set(copy, parentInstance[key][i]);
|
|
745
|
+
});
|
|
746
|
+
originalValueAtom.reportChanged();
|
|
747
|
+
},
|
|
748
|
+
ensureSet() {
|
|
749
|
+
if (!parentInstance[key]) parentInstance[key] = [];
|
|
750
|
+
valueAtom.reportChanged();
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
return makeAutoObservable(list, { value: computed({ equals: () => false }) });
|
|
754
|
+
}
|
|
755
|
+
function hashValue(value) {
|
|
756
|
+
return hash(value);
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/fields/objectField.ts
|
|
760
|
+
function fragment(value) {
|
|
761
|
+
return value;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Creates a new `ObjectState` for a given form object `T` given config rules in `config`.
|
|
765
|
+
*
|
|
766
|
+
* The returned `ObjectState` can be used in a mobx `useLocalObservable` to drive an
|
|
767
|
+
* interactive form that tracks the current valid/touched/etc. state of both each
|
|
768
|
+
* individual fields as well as the top-level form/object itself.
|
|
769
|
+
*/
|
|
770
|
+
function createObjectState(config, instance, opts = {}) {
|
|
771
|
+
const noop = () => {};
|
|
772
|
+
return newObjectState({
|
|
773
|
+
type: "object",
|
|
774
|
+
config
|
|
775
|
+
}, void 0, void 0, void 0, instance, void 0, opts.maybeAutoSave || noop, false);
|
|
776
|
+
}
|
|
777
|
+
/** A more internal version of `createObjectState`. */
|
|
778
|
+
function newObjectState(config, parentState, parentInstance, parentListState, instance, key, maybeAutoSave, deepExhaustive) {
|
|
779
|
+
let proxy = void 0;
|
|
780
|
+
function getObjectState() {
|
|
781
|
+
if (!proxy) throw new Error("Race condition");
|
|
782
|
+
return proxy;
|
|
783
|
+
}
|
|
784
|
+
const originalCopy = deepClone(instance);
|
|
785
|
+
const objectConfig = config.config;
|
|
786
|
+
const fieldStates = Object.entries(objectConfig).map(([_key, _config]) => {
|
|
787
|
+
const key = _key;
|
|
788
|
+
const config = _config;
|
|
789
|
+
let field;
|
|
790
|
+
if (config.type === "value") field = newValueFieldState(originalCopy, instance, getObjectState, key, config.rules || [], config.isIdKey || key === "id" && !("isIdKey" in config) && !Object.entries(objectConfig).some(([other, c]) => other !== key && c.isIdKey), config.isDeleteKey || false, config.isReadOnlyKey || false, config.isLocalOnly || false, config.computed ?? (isObservable(instance) ? !(key in originalCopy) : false), config.readOnly ?? false, config.strictOrder ?? true, maybeAutoSave);
|
|
791
|
+
else if (config.type === "list") field = newListFieldState(originalCopy, instance, getObjectState, key, config.rules || [], config, {
|
|
792
|
+
type: "object",
|
|
793
|
+
config: config.config
|
|
794
|
+
}, config.strictOrder ?? true, maybeAutoSave, deepExhaustive);
|
|
795
|
+
else if (config.type === "object") {
|
|
796
|
+
if (!instance[key]) instance[key] = {};
|
|
797
|
+
field = newObjectState(config, getObjectState, instance, void 0, instance[key], key, maybeAutoSave, deepExhaustive);
|
|
798
|
+
} else if (config.type === "fragment") field = newFragmentField(instance, key);
|
|
799
|
+
else throw new Error(`Invalid type value ${config.type}`);
|
|
800
|
+
return [key, field];
|
|
801
|
+
});
|
|
802
|
+
const fieldNames = Object.keys(objectConfig);
|
|
803
|
+
function getFields(proxyThis) {
|
|
804
|
+
return fieldNames.map((name) => proxyThis[name]);
|
|
805
|
+
}
|
|
806
|
+
const obj = {
|
|
807
|
+
...Object.fromEntries(fieldStates),
|
|
808
|
+
key,
|
|
809
|
+
get value() {
|
|
810
|
+
getFields(this).forEach((f) => f.value);
|
|
811
|
+
return instance;
|
|
812
|
+
},
|
|
813
|
+
set value(value) {
|
|
814
|
+
this.set(value);
|
|
815
|
+
},
|
|
816
|
+
_kind: "object",
|
|
817
|
+
_readOnly: false,
|
|
818
|
+
_loading: false,
|
|
819
|
+
_isAutoSaving: false,
|
|
820
|
+
_considerDeleted() {
|
|
821
|
+
return !!getFields(this).find((f) => f._isDeleteKey)?.value;
|
|
822
|
+
},
|
|
823
|
+
_considerReadOnly() {
|
|
824
|
+
return !!getFields(this).find((f) => f._isReadOnlyKey)?.value;
|
|
825
|
+
},
|
|
826
|
+
get focused() {
|
|
827
|
+
return getFields(this).some((f) => f.focused);
|
|
828
|
+
},
|
|
829
|
+
get touched() {
|
|
830
|
+
return getFields(this).some((f) => f.touched);
|
|
831
|
+
},
|
|
832
|
+
set touched(touched) {
|
|
833
|
+
getFields(this).forEach((f) => f.touched = touched);
|
|
834
|
+
},
|
|
835
|
+
get readOnly() {
|
|
836
|
+
return this._readOnly || this._considerReadOnly() || parentState && parentState().readOnly || !!parentListState?.readOnly;
|
|
837
|
+
},
|
|
838
|
+
set readOnly(readOnly) {
|
|
839
|
+
this._readOnly = readOnly;
|
|
840
|
+
},
|
|
841
|
+
get loading() {
|
|
842
|
+
return this._loading || parentState && parentState().loading || !!parentListState?.loading;
|
|
843
|
+
},
|
|
844
|
+
set loading(loading) {
|
|
845
|
+
this._loading = loading;
|
|
846
|
+
},
|
|
847
|
+
get valid() {
|
|
848
|
+
return getFields(this).every((f) => {
|
|
849
|
+
return f.valid;
|
|
850
|
+
});
|
|
851
|
+
},
|
|
852
|
+
get errors() {
|
|
853
|
+
return getFields(this).flatMap((f) => f.errors.map((e) => `${f.key}: ${e}`));
|
|
854
|
+
},
|
|
855
|
+
get dirty() {
|
|
856
|
+
return getFields(this).some((f) => f.dirty && !f._isLocalOnly) || this.isUnset() && !areEqual(this.value, {});
|
|
857
|
+
},
|
|
858
|
+
get isNewEntity() {
|
|
859
|
+
const idField = getFields(this).find((f) => f._isIdKey);
|
|
860
|
+
if (!idField && parentState) return parentState().isNewEntity;
|
|
861
|
+
return idField !== void 0 && (idField.value === null || idField.value === void 0);
|
|
862
|
+
},
|
|
863
|
+
canSave() {
|
|
864
|
+
this.touched = true;
|
|
865
|
+
return this.valid;
|
|
866
|
+
},
|
|
867
|
+
set(value, opts = {}) {
|
|
868
|
+
if (this.readOnly && !opts.resetting && !opts.refreshing) throw new Error(`${String(key) || "formState"} is currently readOnly`);
|
|
869
|
+
if (value && this.isUnset()) parentInstance[key] = instance;
|
|
870
|
+
if (!value && parentInstance) parentInstance[key] = void 0;
|
|
871
|
+
getFields(this).forEach((field) => {
|
|
872
|
+
if (value && typeof value === "object" && field.key in value) field.set(value[field.key], opts);
|
|
873
|
+
});
|
|
874
|
+
},
|
|
875
|
+
revertChanges() {
|
|
876
|
+
getFields(this).forEach((f) => f.revertChanges());
|
|
877
|
+
},
|
|
878
|
+
commitChanges() {
|
|
879
|
+
if (this._isAutoSaving) throw new Error("When using autoSave, you should not manually call commitChanges, instead have init.map/input update the form state");
|
|
880
|
+
getFields(this).forEach((f) => f.commitChanges());
|
|
881
|
+
},
|
|
882
|
+
get changedValue() {
|
|
883
|
+
const result = {};
|
|
884
|
+
if (this.isUnset()) {
|
|
885
|
+
if (getFields(this).find((f) => f._isIdKey) && config.reference) return { id: null };
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
getFields(this).forEach((f) => {
|
|
889
|
+
if (f._computed) return;
|
|
890
|
+
if (config.reference) return;
|
|
891
|
+
if (f._isLocalOnly) return;
|
|
892
|
+
if (f.dirty || this.isNewEntity && f._kind === "value" && f.value !== void 0 || this.isNewEntity && f._kind === "object" && f.changedValue && Object.entries(f.changedValue).length > 0 || this.isNewEntity && f._kind === "list" && f.value?.length > 0 || deepExhaustive) result[f.key] = f.changedValue;
|
|
893
|
+
});
|
|
894
|
+
const idField = getFields(this).find((f) => f._isIdKey);
|
|
895
|
+
if (idField && idField.value !== void 0) result[idField.key] = idField.value;
|
|
896
|
+
return result;
|
|
897
|
+
},
|
|
898
|
+
dispose() {
|
|
899
|
+
getFields(this).forEach((f) => f.dispose());
|
|
900
|
+
},
|
|
901
|
+
get originalValue() {
|
|
902
|
+
getFields(proxy).map((f) => f.originalValue);
|
|
903
|
+
return originalCopy;
|
|
904
|
+
},
|
|
905
|
+
isSameEntity(other) {
|
|
906
|
+
const idField = getFields(this).find((f) => f._isIdKey);
|
|
907
|
+
if (!idField) return false;
|
|
908
|
+
const ourIdValue = idField.value;
|
|
909
|
+
const otherIdValue = other[idField.key];
|
|
910
|
+
return otherIdValue !== void 0 && areEqual(otherIdValue, ourIdValue);
|
|
911
|
+
},
|
|
912
|
+
isUnset() {
|
|
913
|
+
return !!parentInstance && parentInstance[key] === void 0;
|
|
914
|
+
},
|
|
915
|
+
get idKey() {
|
|
916
|
+
return getFields(this).find((f) => f._isIdKey)?.key;
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
proxy = makeAutoObservable(obj, {
|
|
920
|
+
value: computed({ equals: () => false }),
|
|
921
|
+
originalValue: computed({ equals: () => false })
|
|
922
|
+
});
|
|
923
|
+
return proxy;
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/configBuilders.ts
|
|
927
|
+
/**
|
|
928
|
+
* Provides a Zod-ish API for building form configs.
|
|
929
|
+
*/
|
|
930
|
+
const f = {
|
|
931
|
+
/** Creates the top-level config for a form, like an author or book. */
|
|
932
|
+
config(fields) {
|
|
933
|
+
return new ObjectConfigBuilder(fields).build().config;
|
|
934
|
+
},
|
|
935
|
+
/** Creates the config DSL for an object, like an author or book. */
|
|
936
|
+
object(fields) {
|
|
937
|
+
return new ObjectConfigBuilder(fields);
|
|
938
|
+
},
|
|
939
|
+
/** Creates the config DSL for an list of objects, like an author's list of books. */
|
|
940
|
+
list(fields) {
|
|
941
|
+
return new ListFieldConfigBuilder(fields);
|
|
942
|
+
},
|
|
943
|
+
/** Creates the config DSL for a primitive value, like a first name or phone number. */
|
|
944
|
+
value() {
|
|
945
|
+
return new ValueFieldConfigBuilder();
|
|
946
|
+
},
|
|
947
|
+
/** A shortcut for creating a child object with a single `id` key. */
|
|
948
|
+
reference(fields) {
|
|
949
|
+
return f.object({
|
|
950
|
+
id: f.value(),
|
|
951
|
+
...fields
|
|
952
|
+
}).ref();
|
|
953
|
+
},
|
|
954
|
+
/** A shorthand for creating a computed value. */
|
|
955
|
+
computed() {
|
|
956
|
+
return f.value().computed();
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
/** Provides a fluent DSL for building up an object's config. */
|
|
960
|
+
var ObjectConfigBuilder = class {
|
|
961
|
+
config = {
|
|
962
|
+
type: "object",
|
|
963
|
+
config: {}
|
|
964
|
+
};
|
|
965
|
+
constructor(fields) {
|
|
966
|
+
for (const [key, value] of Object.entries(fields)) this.config.config[key] = value.build();
|
|
967
|
+
}
|
|
968
|
+
ref() {
|
|
969
|
+
this.config.reference = true;
|
|
970
|
+
return this;
|
|
971
|
+
}
|
|
972
|
+
build() {
|
|
973
|
+
return this.config;
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
/** Provides a fluent DSL for building up a value's config. */
|
|
977
|
+
var ValueFieldConfigBuilder = class {
|
|
978
|
+
config = {
|
|
979
|
+
type: "value",
|
|
980
|
+
rules: []
|
|
981
|
+
};
|
|
982
|
+
/** Marks the field as required, keeping any rules that were already added. */
|
|
983
|
+
req() {
|
|
984
|
+
(this.config.rules ??= []).push(required);
|
|
985
|
+
return this;
|
|
986
|
+
}
|
|
987
|
+
/** Marks the field as read only. */
|
|
988
|
+
readOnly() {
|
|
989
|
+
this.config.readOnly = true;
|
|
990
|
+
return this;
|
|
991
|
+
}
|
|
992
|
+
/** Marks the field as local only. */
|
|
993
|
+
localOnly() {
|
|
994
|
+
this.config.isLocalOnly = true;
|
|
995
|
+
return this;
|
|
996
|
+
}
|
|
997
|
+
/** Appends `rules` to the field's validation rules. */
|
|
998
|
+
rules(rules) {
|
|
999
|
+
(this.config.rules ??= []).push(...rules);
|
|
1000
|
+
return this;
|
|
1001
|
+
}
|
|
1002
|
+
/** Marks the field as computed. */
|
|
1003
|
+
computed() {
|
|
1004
|
+
this.config.computed = true;
|
|
1005
|
+
return this;
|
|
1006
|
+
}
|
|
1007
|
+
build() {
|
|
1008
|
+
return this.config;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
/** Provides a fluent DSL for building up a list's config. */
|
|
1012
|
+
var ListFieldConfigBuilder = class {
|
|
1013
|
+
config;
|
|
1014
|
+
constructor(fields) {
|
|
1015
|
+
const config = {};
|
|
1016
|
+
for (const [key, value] of Object.entries(fields)) config[key] = value.build();
|
|
1017
|
+
this.config = {
|
|
1018
|
+
type: "list",
|
|
1019
|
+
rules: [],
|
|
1020
|
+
config
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/** Appends `rule` to the field's validation rules. */
|
|
1024
|
+
rule(rule) {
|
|
1025
|
+
(this.config.rules ??= []).push(rule);
|
|
1026
|
+
return this;
|
|
1027
|
+
}
|
|
1028
|
+
/** Appends `rules` to the field's validation rules. */
|
|
1029
|
+
rules(rules) {
|
|
1030
|
+
(this.config.rules ??= []).push(...rules);
|
|
1031
|
+
return this;
|
|
1032
|
+
}
|
|
1033
|
+
build() {
|
|
1034
|
+
return this.config;
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
//#endregion
|
|
1038
|
+
//#region src/useFormStates.ts
|
|
1039
|
+
/**
|
|
1040
|
+
* A hook to manage many "mini-forms" on a single page, typically one form per row
|
|
1041
|
+
* in a table.
|
|
1042
|
+
*
|
|
1043
|
+
* This hook basically provides the page/table with a cache, so each table row naively ask "what's
|
|
1044
|
+
* the form state for this given row's data?" and get back a new-or-existing `ObjectState` instance
|
|
1045
|
+
* that, if already existing, still has any of the user's WIP changes.
|
|
1046
|
+
*
|
|
1047
|
+
* Each mini-form/row can have its own autoSave calls, independent of the other rows.
|
|
1048
|
+
*
|
|
1049
|
+
* @typeparam T the form type, which is usually as close as possible to your *GraphQL input*
|
|
1050
|
+
* @typeparam I the *form input* type, which is usually the *GraphQL output* type, i.e. the type of the response from your GraphQL query
|
|
1051
|
+
*/
|
|
1052
|
+
function useFormStates(opts) {
|
|
1053
|
+
const { config, autoSave, getId, map, addRules, readOnly = false } = opts;
|
|
1054
|
+
const objectStateCache = useMemo(() => ({}), [config]);
|
|
1055
|
+
const prevCacheRef = useRef(objectStateCache);
|
|
1056
|
+
useEffect(() => {
|
|
1057
|
+
const prevCache = prevCacheRef.current;
|
|
1058
|
+
if (prevCache !== objectStateCache) Object.values(prevCache).forEach((entry) => entry[0].dispose());
|
|
1059
|
+
prevCacheRef.current = objectStateCache;
|
|
1060
|
+
}, [objectStateCache]);
|
|
1061
|
+
const pendingAutoSaves = useRef(/* @__PURE__ */ new Set());
|
|
1062
|
+
const isAutoSaving = useRef(false);
|
|
1063
|
+
const autoSaveRef = useRef(autoSave);
|
|
1064
|
+
autoSaveRef.current = autoSave;
|
|
1065
|
+
const readOnlyRef = useRef(readOnly);
|
|
1066
|
+
readOnlyRef.current = readOnly;
|
|
1067
|
+
return { getFormState: useCallback((input, opts = {}) => {
|
|
1068
|
+
const existing = objectStateCache[getId(input)];
|
|
1069
|
+
let form = existing?.[0];
|
|
1070
|
+
async function maybeAutoSave(form) {
|
|
1071
|
+
if (autoSaveRef.current && form.dirty) {
|
|
1072
|
+
if (!form.valid) {
|
|
1073
|
+
console.debug("Skipping auto-save b/c form is invalid: ", form.errors);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
const { current: pending } = pendingAutoSaves;
|
|
1077
|
+
if (isAutoSaving.current) {
|
|
1078
|
+
pending.add(form);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
try {
|
|
1082
|
+
isAutoSaving.current = true;
|
|
1083
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1084
|
+
pending.delete(form);
|
|
1085
|
+
await autoSaveRef.current(form);
|
|
1086
|
+
} finally {
|
|
1087
|
+
isAutoSaving.current = false;
|
|
1088
|
+
if (pending.size > 0) {
|
|
1089
|
+
const first = pending.values().next().value;
|
|
1090
|
+
pending.delete(first);
|
|
1091
|
+
await maybeAutoSave(first);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (!form) {
|
|
1097
|
+
form = createObjectState(config, initValue(config, {
|
|
1098
|
+
map,
|
|
1099
|
+
input
|
|
1100
|
+
}), { maybeAutoSave: () => maybeAutoSave(form) });
|
|
1101
|
+
if (addRules) addRules(form);
|
|
1102
|
+
objectStateCache[getId(input)] = [form, input];
|
|
1103
|
+
}
|
|
1104
|
+
if (existing && existing[1] !== input) {
|
|
1105
|
+
form.set(initValue(config, {
|
|
1106
|
+
map,
|
|
1107
|
+
input
|
|
1108
|
+
}), { refreshing: true });
|
|
1109
|
+
existing[1] = input;
|
|
1110
|
+
}
|
|
1111
|
+
form.readOnly = readOnlyRef.current || !!opts.readOnly;
|
|
1112
|
+
return form;
|
|
1113
|
+
}, [objectStateCache, config]) };
|
|
1114
|
+
}
|
|
1115
|
+
//#endregion
|
|
1116
|
+
export { createObjectState, f, fragment, required, useFormState, useFormStates };
|
|
1117
|
+
|
|
1118
|
+
//# sourceMappingURL=index.js.map
|