jsonisch 0.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/form-ref-BEri3JKv.js +1123 -0
- package/dist/form-ref-D_xHSaqL.d.ts +994 -0
- package/dist/handle-submit-CD3WY9gW.js +208 -0
- package/dist/handle-submit-Dk7QW3_q.d.ts +34 -0
- package/dist/index.d.ts +724 -0
- package/dist/index.js +1521 -0
- package/dist/plugin/index.d.ts +188 -0
- package/dist/plugin/index.js +4 -0
- package/dist/plugin-BCsAE8RK.d.ts +612 -0
- package/dist/plugin-Qka-vaO1.js +1545 -0
- package/dist/react/index.d.ts +476 -0
- package/dist/react/index.js +456 -0
- package/docs/decode-fork.md +24 -0
- package/docs/plugin-lifecycle.md +44 -0
- package/docs/rerender-history.md +61 -0
- package/docs/wire-shapes.md +70 -0
- package/package.json +87 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1521 @@
|
|
|
1
|
+
import { A as unwrapLeafInput, B as getFieldBool, C as dispatchSyncInitial, D as fieldPluginDirty, E as encodeFieldValue, L as untrack, M as batch, N as computed, O as hasPluginDirtyField, P as createSignal, S as dispatchSwapField, T as dispatchTransferField, V as walkFieldStore, _ as resolveValueInput, a as getFieldStore, c as isPresenceEqual, d as DEFAULT_ROOT_RECORD_ALIAS, f as createFormStore, g as readOwn, h as isSafeKey, i as focusFieldElement, j as createId, l as isSemanticEqual, m as containerPresence, n as validateFormInput, o as getFieldStoreChain, p as initializeFieldStore, r as getFieldInput, s as isEmptyish, t as internalOf, u as DEFAULT_EMPTY_INPUT, x as dispatchResetField, y as dispatchRebase, z as inferControl } from "./form-ref-BEri3JKv.js";
|
|
2
|
+
import { a as setFieldInput, i as computeContainerDirty, o as resetItemState, r as validateIfRequired, t as handleSubmit } from "./handle-submit-CD3WY9gW.js";
|
|
3
|
+
import { A as bindAdopted, B as wrapEstimate, C as withRelationRowIdentity, E as envelopesKey, F as syncEstimateInput, H as getDirtyFieldInput, I as writeEnvelope, L as envelopesWire, M as decodeEstimateEnvelope, N as resolveEstimateMode, P as syncAmountOrPercentInput, R as isEnvelope, S as targetToKind, T as derivationKey, V as encodeScopeValues, _ as resolveRowFallback, b as relationParentFieldName, c as collectionKeys, d as visibility, i as formulaCheck, j as decodeAmountOrPercentEnvelope, k as adoptEnvelope, l as computeBag, m as resolveScopeValueAt, n as replaceCheckInstances, o as bagger, p as resolveScopeValue, r as UNEVALUABLE_MESSAGE_ID, t as checks, u as flattenSourceRow, w as mergeCollectionRows, x as relationRowIdentityProps, y as readRelationConfig, z as wrapAmountOrPercent } from "./plugin-Qka-vaO1.js";
|
|
4
|
+
|
|
5
|
+
//#region src/core/codec/decode-record.ts
|
|
6
|
+
/**
|
|
7
|
+
* How a server record becomes form state — one decode, then a fork:
|
|
8
|
+
*
|
|
9
|
+
* Postgres row { …columns, data: { myField: { kind, value?, mode, … } } }
|
|
10
|
+
* │
|
|
11
|
+
* │ ① decodeRecord (this file): schema-declared keys only,
|
|
12
|
+
* │ envelopes pass through WHOLE
|
|
13
|
+
* ▼
|
|
14
|
+
* initialInput { myField: { kind, value?, mode, … } }
|
|
15
|
+
* │
|
|
16
|
+
* │ ② createFormStore visits each declared field; an
|
|
17
|
+
* │ estimate/amount-or-percent field's envelope is read TWICE,
|
|
18
|
+
* │ once per half — both readers take the SAME raw object:
|
|
19
|
+
* ├──────────────────────────┬─────────────────────────────┐
|
|
20
|
+
* ▼ ▼ │
|
|
21
|
+
* value half → field input meta half → envelope slot │
|
|
22
|
+
* (`unwrapLeafInput`, (`envelopes()` buildScope, │
|
|
23
|
+
* core/plugin/driver.ts) plugins/envelopes/plugin.ts) │
|
|
24
|
+
*
|
|
25
|
+
* It is a FORK, not a chain: the meta reader consumes the original raw,
|
|
26
|
+
* never the value reader's output, so the value a user sees and the mode
|
|
27
|
+
* state next to it can never derive from different data. The same fork
|
|
28
|
+
* re-runs on reset, `applyBaseline`, and array-row reuse — every path
|
|
29
|
+
* funnels through the same two readers.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Decodes a nested server record into the form's `initialInput` shape,
|
|
33
|
+
* routing each declared root property by its `x-column` geometry:
|
|
34
|
+
* `x-column: true` fields read from a real record column (a top-level
|
|
35
|
+
* record key), everything else reads from the record's `data` JSONB bag.
|
|
36
|
+
*
|
|
37
|
+
* The schema is the allow-list — only declared property keys are read, so
|
|
38
|
+
* undeclared record keys (including prototype-pollution keys) never enter
|
|
39
|
+
* the result. Nested values pass through as-is; the store walk applies the
|
|
40
|
+
* allow-list recursively when the result becomes `initialInput`, and
|
|
41
|
+
* unwraps envelope leaves (`{ kind, value?, mode, … }`) through the
|
|
42
|
+
* registered wire contracts.
|
|
43
|
+
*
|
|
44
|
+
* For flat-JSONB surfaces without column routing (e.g. workflow form
|
|
45
|
+
* tasks), pass the bag as `{ data: bag }`.
|
|
46
|
+
*
|
|
47
|
+
* @param schema The form's JSON-Schema (object schema with properties).
|
|
48
|
+
* @param record The server record (`{ …columns, data? }`).
|
|
49
|
+
* @param options Decode options (envelope wire contracts).
|
|
50
|
+
*
|
|
51
|
+
* @returns The decoded initial input, or `undefined` for a nullish record.
|
|
52
|
+
*/
|
|
53
|
+
function decodeRecord(schema, record, options = {}) {
|
|
54
|
+
if (record == null) return void 0;
|
|
55
|
+
const properties = schema.properties ?? {};
|
|
56
|
+
const data = record.data && typeof record.data === "object" ? record.data : void 0;
|
|
57
|
+
const input = {};
|
|
58
|
+
for (const key of Object.keys(properties)) {
|
|
59
|
+
if (!isSafeKey(key)) continue;
|
|
60
|
+
const isColumn = properties[key]["x-column"] === true;
|
|
61
|
+
let value;
|
|
62
|
+
if (isColumn && options.envelopes?.has(inferControl(properties[key]))) {
|
|
63
|
+
const bagValue = readOwn(data, key);
|
|
64
|
+
value = bagValue !== void 0 ? bagValue : readOwn(record, key);
|
|
65
|
+
} else value = isColumn ? readOwn(record, key) : readOwn(data, key);
|
|
66
|
+
if (value !== void 0) input[key] = value;
|
|
67
|
+
}
|
|
68
|
+
return input;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/core/field/align-rows.ts
|
|
73
|
+
/**
|
|
74
|
+
* Non-empty identity: `null`, `undefined`, and `""` do not join.
|
|
75
|
+
*/
|
|
76
|
+
function isUsableId(id) {
|
|
77
|
+
return id != null && id !== "";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* For each server index, the local row that supplies live state. Server
|
|
81
|
+
* order is the result order. A local index is used at most once — two
|
|
82
|
+
* server rows with the same id must not share one live store. Match only
|
|
83
|
+
* when both sides have a usable id.
|
|
84
|
+
*
|
|
85
|
+
* The caller must not invoke this when the item schema has no `id`; those
|
|
86
|
+
* arrays stay positional.
|
|
87
|
+
*/
|
|
88
|
+
function alignRows(localIds, serverRows) {
|
|
89
|
+
const used = /* @__PURE__ */ new Set();
|
|
90
|
+
return serverRows.map((row) => {
|
|
91
|
+
const serverId = readOwn(row, "id");
|
|
92
|
+
if (!isUsableId(serverId)) return { fromLocalIndex: null };
|
|
93
|
+
for (let index = 0; index < localIds.length; index++) {
|
|
94
|
+
if (used.has(index) || localIds[index] !== serverId) continue;
|
|
95
|
+
used.add(index);
|
|
96
|
+
return { fromLocalIndex: index };
|
|
97
|
+
}
|
|
98
|
+
return { fromLocalIndex: null };
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//#endregion
|
|
103
|
+
//#region src/core/field/copy-item-state.ts
|
|
104
|
+
/**
|
|
105
|
+
* Copies the deeply nested state (signal values) from one field store to
|
|
106
|
+
* another: `elements`, `errors`, `startInput`, `input`, `isTouched`,
|
|
107
|
+
* `isEdited`, `isDirty`, and for arrays `startItems` and `items`. This is
|
|
108
|
+
* the state-transfer engine under `insert`, `remove` and `move` — errors
|
|
109
|
+
* and dirty/touched flags travel with their row. `initialInput`,
|
|
110
|
+
* `initialItems` and `initialElements` stay put: the reset target belongs
|
|
111
|
+
* to the position, not the moving item.
|
|
112
|
+
*
|
|
113
|
+
* @param internalFormStore The form store providing the empty input config.
|
|
114
|
+
* @param fromInternalFieldStore The source field store to copy from.
|
|
115
|
+
* @param toInternalFieldStore The destination field store to copy to.
|
|
116
|
+
*/
|
|
117
|
+
function copyItemState(internalFormStore, fromInternalFieldStore, toInternalFieldStore) {
|
|
118
|
+
batch(() => {
|
|
119
|
+
untrack(() => {
|
|
120
|
+
toInternalFieldStore.elements = fromInternalFieldStore.elements;
|
|
121
|
+
toInternalFieldStore.validationErrors.value = fromInternalFieldStore.validationErrors.value;
|
|
122
|
+
toInternalFieldStore.startInput.value = fromInternalFieldStore.startInput.value;
|
|
123
|
+
toInternalFieldStore.input.value = fromInternalFieldStore.input.value;
|
|
124
|
+
toInternalFieldStore.isTouched.value = fromInternalFieldStore.isTouched.value;
|
|
125
|
+
toInternalFieldStore.isEdited.value = fromInternalFieldStore.isEdited.value;
|
|
126
|
+
toInternalFieldStore.isDirty.value = fromInternalFieldStore.isDirty.value;
|
|
127
|
+
if (fromInternalFieldStore.kind === "value" && toInternalFieldStore.kind === "value") dispatchTransferField(internalFormStore, fromInternalFieldStore, toInternalFieldStore);
|
|
128
|
+
if (fromInternalFieldStore.kind === "array" && toInternalFieldStore.kind === "array") {
|
|
129
|
+
const fromItems = fromInternalFieldStore.items.value;
|
|
130
|
+
toInternalFieldStore.startItems.value = fromInternalFieldStore.startItems.value;
|
|
131
|
+
toInternalFieldStore.items.value = fromItems;
|
|
132
|
+
for (let index = 0; index < fromItems.length; index++) {
|
|
133
|
+
if (!toInternalFieldStore.children[index]) {
|
|
134
|
+
toInternalFieldStore.children[index] = {};
|
|
135
|
+
initializeFieldStore(internalFormStore, toInternalFieldStore.children[index], toInternalFieldStore.itemSchema, void 0, [...toInternalFieldStore.path, index]);
|
|
136
|
+
}
|
|
137
|
+
copyItemState(internalFormStore, fromInternalFieldStore.children[index], toInternalFieldStore.children[index]);
|
|
138
|
+
}
|
|
139
|
+
} else if (fromInternalFieldStore.kind === "object" && toInternalFieldStore.kind === "object") for (const key in fromInternalFieldStore.children) copyItemState(internalFormStore, fromInternalFieldStore.children[key], toInternalFieldStore.children[key]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/core/field/park-item-state.ts
|
|
146
|
+
/**
|
|
147
|
+
* Path must be the item path or envelope slots are not built (LOS-596).
|
|
148
|
+
*/
|
|
149
|
+
function parkItemState(form, array, source, sourceIndex) {
|
|
150
|
+
const parked = {};
|
|
151
|
+
initializeFieldStore(form, parked, array.itemSchema, void 0, [...array.path, sourceIndex]);
|
|
152
|
+
copyItemState(form, source, parked);
|
|
153
|
+
return parked;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
//#endregion
|
|
157
|
+
//#region src/core/field/rebase-field-baseline.ts
|
|
158
|
+
/**
|
|
159
|
+
* Rebases the live state of a field store and all its children onto a new
|
|
160
|
+
* baseline value — the live half of `applyBaseline` (`setInitialFieldInput`
|
|
161
|
+
* is the reset-target half and must run first): the dirty baseline
|
|
162
|
+
* (`startInput`/`startItems`) always moves to the new value; the current
|
|
163
|
+
* input moves with it only when the field was clean, so an in-flight edit
|
|
164
|
+
* survives and is re-diffed against the new baseline (an edit equal to the
|
|
165
|
+
* fresh server value becomes clean — semantic dirty rules apply).
|
|
166
|
+
*
|
|
167
|
+
* Callers must wrap in `batch` + `untrack` (like the reset internals).
|
|
168
|
+
*
|
|
169
|
+
* @param internalFormStore The form store providing the empty input config.
|
|
170
|
+
* @param internalFieldStore The field store to rebase.
|
|
171
|
+
* @param input The new baseline value.
|
|
172
|
+
*/
|
|
173
|
+
function rebaseFieldBaseline(internalFormStore, internalFieldStore, input) {
|
|
174
|
+
if (internalFieldStore.kind === "array") rebaseArrayBaseline(internalFormStore, internalFieldStore, input);
|
|
175
|
+
else if (internalFieldStore.kind === "object") {
|
|
176
|
+
const presence = containerPresence(internalFieldStore.isNullish, input);
|
|
177
|
+
const presenceClean = isPresenceEqual(internalFieldStore.startInput.value, internalFieldStore.input.value);
|
|
178
|
+
internalFieldStore.startInput.value = presence;
|
|
179
|
+
if (presenceClean) internalFieldStore.input.value = presence;
|
|
180
|
+
for (const key in internalFieldStore.children) rebaseFieldBaseline(internalFormStore, internalFieldStore.children[key], readOwn(input, key));
|
|
181
|
+
if (typeof internalFieldStore.path[internalFieldStore.path.length - 1] === "number") dispatchRebase(internalFormStore, internalFieldStore, input);
|
|
182
|
+
internalFieldStore.isDirty.value = computeContainerDirty(internalFieldStore);
|
|
183
|
+
} else {
|
|
184
|
+
const newValue = resolveValueInput(internalFormStore.emptyInput, internalFieldStore.schema, internalFieldStore.isNullish, unwrapLeafInput(internalFormStore, internalFieldStore.control, input));
|
|
185
|
+
const wasClean = isSemanticEqual(internalFieldStore.input.value, internalFieldStore.startInput.value);
|
|
186
|
+
internalFieldStore.startInput.value = newValue;
|
|
187
|
+
if (wasClean) internalFieldStore.input.value = newValue;
|
|
188
|
+
internalFieldStore.isDirty.value = !isSemanticEqual(internalFieldStore.input.value, newValue);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The array half of the rebase. Membership (item identity) follows the same
|
|
193
|
+
* clean-vs-dirty rule as values: unchanged membership adopts the server
|
|
194
|
+
* rows — by `id` when the item schema has usable ids, otherwise
|
|
195
|
+
* positionally — KEEPING surviving item IDs so mounted rows preserve their
|
|
196
|
+
* identity (react keys). Unmatched clean locals drop; unmatched dirty
|
|
197
|
+
* locals append after the server prefix. Locally changed membership
|
|
198
|
+
* (insert, remove, reorder, presence flip) wins wholesale — the array
|
|
199
|
+
* stays dirty and only rows matchable by a server `id` still rebase their
|
|
200
|
+
* content.
|
|
201
|
+
*/
|
|
202
|
+
function rebaseArrayBaseline(internalFormStore, internalArrayStore, input) {
|
|
203
|
+
const presence = containerPresence(internalArrayStore.isNullish, input);
|
|
204
|
+
const presenceClean = isPresenceEqual(internalArrayStore.startInput.value, internalArrayStore.input.value);
|
|
205
|
+
internalArrayStore.startInput.value = presence;
|
|
206
|
+
if (presenceClean) internalArrayStore.input.value = presence;
|
|
207
|
+
const serverRows = Array.isArray(input) ? input : [];
|
|
208
|
+
const items = internalArrayStore.items.value;
|
|
209
|
+
const membershipClean = presenceClean && internalArrayStore.startItems.value.join() === items.join();
|
|
210
|
+
const serverRowsById = indexServerRowsById(internalArrayStore.itemSchema, serverRows);
|
|
211
|
+
if (membershipClean && serverRowsById) rebaseCleanMembershipById(internalFormStore, internalArrayStore, items, serverRows);
|
|
212
|
+
else if (membershipClean) rebaseCleanMembershipPositional(internalFormStore, internalArrayStore, items, serverRows);
|
|
213
|
+
else if (serverRowsById) for (let index = 0; index < items.length; index++) {
|
|
214
|
+
const serverRow = serverRowsById.get(readItemId(internalArrayStore.children[index]));
|
|
215
|
+
if (serverRow === void 0) continue;
|
|
216
|
+
rebaseFieldBaseline(internalFormStore, internalArrayStore.children[index], serverRow);
|
|
217
|
+
}
|
|
218
|
+
internalArrayStore.isDirty.value = !isPresenceEqual(internalArrayStore.startInput.value, internalArrayStore.input.value) || internalArrayStore.startItems.value.join() !== internalArrayStore.items.value.join();
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Clean membership, no usable ids: adopt server rows by index. Grown
|
|
222
|
+
* slots are fresh baseline rows; surviving item IDs stay on their index.
|
|
223
|
+
*/
|
|
224
|
+
function rebaseCleanMembershipPositional(internalFormStore, internalArrayStore, items, serverRows) {
|
|
225
|
+
for (let index = items.length; index < serverRows.length; index++) if (internalArrayStore.children[index]) resetItemState(internalFormStore, internalArrayStore.children[index], serverRows[index]);
|
|
226
|
+
else {
|
|
227
|
+
internalArrayStore.children[index] = {};
|
|
228
|
+
initializeFieldStore(internalFormStore, internalArrayStore.children[index], internalArrayStore.itemSchema, serverRows[index], [...internalArrayStore.path, index]);
|
|
229
|
+
}
|
|
230
|
+
const shared = Math.min(items.length, serverRows.length);
|
|
231
|
+
for (let index = 0; index < shared; index++) rebaseFieldBaseline(internalFormStore, internalArrayStore.children[index], serverRows[index]);
|
|
232
|
+
const newItems = [...items.slice(0, serverRows.length), ...Array.from({ length: Math.max(0, serverRows.length - items.length) }, () => createId())];
|
|
233
|
+
internalArrayStore.items.value = newItems;
|
|
234
|
+
internalArrayStore.startItems.value = newItems;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Clean membership with usable ids: grow/shrink to the server list, move
|
|
238
|
+
* live row state onto the aligned indices, then rebase each child onto
|
|
239
|
+
* that server row. Unmatched dirty locals append after that prefix;
|
|
240
|
+
* unmatched clean locals drop. Stores are position-fixed (`path` stays on
|
|
241
|
+
* the store) — never `children[i] = oldChildren[j]`.
|
|
242
|
+
*/
|
|
243
|
+
function rebaseCleanMembershipById(internalFormStore, internalArrayStore, items, serverRows) {
|
|
244
|
+
const alignment = alignRows(items.map((_, index) => readItemId(internalArrayStore.children[index])), serverRows);
|
|
245
|
+
const usedLocal = /* @__PURE__ */ new Set();
|
|
246
|
+
for (const { fromLocalIndex } of alignment) if (fromLocalIndex != null) usedLocal.add(fromLocalIndex);
|
|
247
|
+
const leftoverDirty = [];
|
|
248
|
+
for (let index = 0; index < items.length; index++) {
|
|
249
|
+
if (usedLocal.has(index)) continue;
|
|
250
|
+
const child = internalArrayStore.children[index];
|
|
251
|
+
if (child && (getFieldBool(child, "isDirty") || hasPluginDirtyField(internalFormStore, child))) leftoverDirty.push(index);
|
|
252
|
+
}
|
|
253
|
+
const parked = /* @__PURE__ */ new Map();
|
|
254
|
+
for (const fromLocalIndex of [...usedLocal, ...leftoverDirty]) {
|
|
255
|
+
if (parked.has(fromLocalIndex)) continue;
|
|
256
|
+
parked.set(fromLocalIndex, parkItemState(internalFormStore, internalArrayStore, internalArrayStore.children[fromLocalIndex], fromLocalIndex));
|
|
257
|
+
}
|
|
258
|
+
for (let index = 0; index < serverRows.length; index++) {
|
|
259
|
+
ensureItemStore(internalFormStore, internalArrayStore, index, serverRows[index]);
|
|
260
|
+
const fromLocalIndex = alignment[index].fromLocalIndex;
|
|
261
|
+
if (fromLocalIndex == null) resetItemState(internalFormStore, internalArrayStore.children[index], serverRows[index]);
|
|
262
|
+
else copyItemState(internalFormStore, parked.get(fromLocalIndex), internalArrayStore.children[index]);
|
|
263
|
+
rebaseFieldBaseline(internalFormStore, internalArrayStore.children[index], serverRows[index]);
|
|
264
|
+
}
|
|
265
|
+
const leftoverIds = [];
|
|
266
|
+
for (let n = 0; n < leftoverDirty.length; n++) {
|
|
267
|
+
const fromLocalIndex = leftoverDirty[n];
|
|
268
|
+
const dest = serverRows.length + n;
|
|
269
|
+
ensureItemStore(internalFormStore, internalArrayStore, dest, void 0);
|
|
270
|
+
copyItemState(internalFormStore, parked.get(fromLocalIndex), internalArrayStore.children[dest]);
|
|
271
|
+
leftoverIds.push(items[fromLocalIndex]);
|
|
272
|
+
}
|
|
273
|
+
const newItems = [...alignment.map(({ fromLocalIndex }) => fromLocalIndex != null ? items[fromLocalIndex] : createId()), ...leftoverIds];
|
|
274
|
+
internalArrayStore.items.value = newItems;
|
|
275
|
+
internalArrayStore.startItems.value = newItems;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Reads the live `id` of an object row, or `undefined` when the store has
|
|
279
|
+
* no value-leaf `id` child.
|
|
280
|
+
*/
|
|
281
|
+
function readItemId(store) {
|
|
282
|
+
if (store?.kind !== "object") return void 0;
|
|
283
|
+
const idStore = store.children.id;
|
|
284
|
+
return idStore?.kind === "value" ? idStore.input.value : void 0;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Ensures a child store exists at `index` so a dest write has a home.
|
|
288
|
+
* Existing stores are left as-is (live state is transferred or reset next).
|
|
289
|
+
*/
|
|
290
|
+
function ensureItemStore(internalFormStore, internalArrayStore, index, input) {
|
|
291
|
+
if (internalArrayStore.children[index]) return;
|
|
292
|
+
internalArrayStore.children[index] = {};
|
|
293
|
+
initializeFieldStore(internalFormStore, internalArrayStore.children[index], internalArrayStore.itemSchema, input, [...internalArrayStore.path, index]);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Indexes server rows by their `id` value when the item schema declares an
|
|
297
|
+
* `id` property. Returns `undefined` when rows have no usable identity
|
|
298
|
+
* (no declared `id`, or no row carries one) — clean membership stays
|
|
299
|
+
* positional; dirty membership skips the id join.
|
|
300
|
+
*/
|
|
301
|
+
function indexServerRowsById(itemSchema, serverRows) {
|
|
302
|
+
if (!itemSchema.properties || itemSchema.properties.id === void 0) return;
|
|
303
|
+
const index = /* @__PURE__ */ new Map();
|
|
304
|
+
for (const row of serverRows) {
|
|
305
|
+
const id = readOwn(row, "id");
|
|
306
|
+
if (id != null && id !== "" && !index.has(id)) index.set(id, row);
|
|
307
|
+
}
|
|
308
|
+
return index.size > 0 ? index : void 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/core/field/set-initial-field-input.ts
|
|
313
|
+
/**
|
|
314
|
+
* Sets the initial input (the reset target) for a field store and all its
|
|
315
|
+
* children recursively, initializing missing array children as needed.
|
|
316
|
+
* Updates only `initialInput` and `initialItems` — `reset` moves them into
|
|
317
|
+
* the live state. This is half of `applyBaseline`.
|
|
318
|
+
*
|
|
319
|
+
* @param internalFormStore The form store providing the empty input config.
|
|
320
|
+
* @param internalFieldStore The field store to update.
|
|
321
|
+
* @param initialInput The new initial input value.
|
|
322
|
+
*/
|
|
323
|
+
function setInitialFieldInput(internalFormStore, internalFieldStore, initialInput, options) {
|
|
324
|
+
batch(() => {
|
|
325
|
+
if (internalFieldStore.kind === "array") {
|
|
326
|
+
internalFieldStore.initialInput.value = containerPresence(internalFieldStore.isNullish, initialInput);
|
|
327
|
+
const initialArrayInput = Array.isArray(initialInput) ? initialInput : [];
|
|
328
|
+
for (let index = internalFieldStore.children.length; index < initialArrayInput.length; index++) {
|
|
329
|
+
internalFieldStore.children[index] = {};
|
|
330
|
+
initializeFieldStore(internalFormStore, internalFieldStore.children[index], internalFieldStore.itemSchema, initialArrayInput[index], [...internalFieldStore.path, index]);
|
|
331
|
+
}
|
|
332
|
+
internalFieldStore.initialItems.value = Array.from({ length: initialArrayInput.length }, () => createId());
|
|
333
|
+
for (let index = 0; index < internalFieldStore.children.length; index++) setInitialFieldInput(internalFormStore, internalFieldStore.children[index], initialArrayInput[index], options);
|
|
334
|
+
} else if (internalFieldStore.kind === "object") {
|
|
335
|
+
internalFieldStore.initialInput.value = containerPresence(internalFieldStore.isNullish, initialInput);
|
|
336
|
+
for (const key in internalFieldStore.children) setInitialFieldInput(internalFormStore, internalFieldStore.children[key], readOwn(initialInput, key), options);
|
|
337
|
+
} else {
|
|
338
|
+
internalFieldStore.initialInput.value = resolveValueInput(internalFormStore.emptyInput, internalFieldStore.schema, internalFieldStore.isNullish, unwrapLeafInput(internalFormStore, internalFieldStore.control, initialInput));
|
|
339
|
+
if (options?.syncInitial) dispatchSyncInitial(internalFormStore, internalFieldStore, initialInput);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/methods/apply-baseline.ts
|
|
346
|
+
/**
|
|
347
|
+
* Rebases a live form on a fresh server-loaded record — after a save or a
|
|
348
|
+
* revalidate, the store adopts the record as its new baseline instead of
|
|
349
|
+
* being torn down and rebuilt: the record decodes through the `x-column`
|
|
350
|
+
* codec (envelope fields keep their kind-discriminated shape for
|
|
351
|
+
* the plugin rebase), clean fields
|
|
352
|
+
* take the new server value, dirty fields keep the user's in-flight edit
|
|
353
|
+
* re-diffed against the new baseline (an edit equal to the fresh server
|
|
354
|
+
* value becomes clean), and a later `reset()` returns to the NEW baseline.
|
|
355
|
+
* Array membership follows the same rule: unchanged membership adopts the
|
|
356
|
+
* server rows — by `id` when the item schema has usable ids, otherwise
|
|
357
|
+
* positionally — and surviving rows keep their identity. Unmatched clean
|
|
358
|
+
* locals drop; unmatched dirty locals append after the server prefix
|
|
359
|
+
* (field-level dirty still enables Save). Locally changed membership wins,
|
|
360
|
+
* with rows still rebasing content by server `id` where ids exist.
|
|
361
|
+
*
|
|
362
|
+
* It is NOT conflict resolution — two people editing the same field stays
|
|
363
|
+
* last-write-wins. A nullish record is a no-op (nothing to rebase on).
|
|
364
|
+
*
|
|
365
|
+
* For flat-JSONB surfaces without column routing, pass the bag as
|
|
366
|
+
* `{ data: bag }` (the `decodeRecord` convention).
|
|
367
|
+
*
|
|
368
|
+
* @param form The form store to rebase.
|
|
369
|
+
* @param record The fresh server record (`{ …columns, data? }`).
|
|
370
|
+
* @param config Rebase options (e.g. fresh off-form values).
|
|
371
|
+
*/
|
|
372
|
+
function applyBaseline(form, record, config) {
|
|
373
|
+
const internalFormStore = internalOf(form);
|
|
374
|
+
const decoded = decodeRecord(internalFormStore.schema, record, { envelopes: internalFormStore.pluginDriver.envelopes });
|
|
375
|
+
if (decoded === void 0) return;
|
|
376
|
+
batch(() => {
|
|
377
|
+
untrack(() => {
|
|
378
|
+
setInitialFieldInput(internalFormStore, internalFormStore, decoded);
|
|
379
|
+
rebaseFieldBaseline(internalFormStore, internalFormStore, decoded);
|
|
380
|
+
dispatchRebase(internalFormStore, internalFormStore, decoded);
|
|
381
|
+
if (config?.offFormValues) internalFormStore.offFormValues.value = config.offFormValues;
|
|
382
|
+
if (internalFormStore.validate === "initial") validateFormInput(internalFormStore);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/core/field/swap-item-state.ts
|
|
389
|
+
/**
|
|
390
|
+
* Swaps the deeply nested state (signal values) between two field stores:
|
|
391
|
+
* `elements`, `errors`, `startInput`, `input`, `isTouched`, `isEdited`,
|
|
392
|
+
* `isDirty`, and for arrays `startItems` and `items`. The state-transfer
|
|
393
|
+
* engine under `swap` — errors and dirty/touched flags travel with their
|
|
394
|
+
* row. `initialInput`, `initialItems` and `initialElements` stay put: the
|
|
395
|
+
* reset target belongs to the position, not the moving item.
|
|
396
|
+
*
|
|
397
|
+
* @param internalFormStore The form store providing the empty input config.
|
|
398
|
+
* @param firstInternalFieldStore The first field store to swap.
|
|
399
|
+
* @param secondInternalFieldStore The second field store to swap.
|
|
400
|
+
*/
|
|
401
|
+
function swapItemState(internalFormStore, firstInternalFieldStore, secondInternalFieldStore) {
|
|
402
|
+
batch(() => {
|
|
403
|
+
untrack(() => {
|
|
404
|
+
const tempElements = firstInternalFieldStore.elements;
|
|
405
|
+
firstInternalFieldStore.elements = secondInternalFieldStore.elements;
|
|
406
|
+
secondInternalFieldStore.elements = tempElements;
|
|
407
|
+
const tempErrors = firstInternalFieldStore.validationErrors.value;
|
|
408
|
+
firstInternalFieldStore.validationErrors.value = secondInternalFieldStore.validationErrors.value;
|
|
409
|
+
secondInternalFieldStore.validationErrors.value = tempErrors;
|
|
410
|
+
const tempStartInput = firstInternalFieldStore.startInput.value;
|
|
411
|
+
firstInternalFieldStore.startInput.value = secondInternalFieldStore.startInput.value;
|
|
412
|
+
secondInternalFieldStore.startInput.value = tempStartInput;
|
|
413
|
+
const tempInput = firstInternalFieldStore.input.value;
|
|
414
|
+
firstInternalFieldStore.input.value = secondInternalFieldStore.input.value;
|
|
415
|
+
secondInternalFieldStore.input.value = tempInput;
|
|
416
|
+
const tempIsTouched = firstInternalFieldStore.isTouched.value;
|
|
417
|
+
firstInternalFieldStore.isTouched.value = secondInternalFieldStore.isTouched.value;
|
|
418
|
+
secondInternalFieldStore.isTouched.value = tempIsTouched;
|
|
419
|
+
const tempIsEdited = firstInternalFieldStore.isEdited.value;
|
|
420
|
+
firstInternalFieldStore.isEdited.value = secondInternalFieldStore.isEdited.value;
|
|
421
|
+
secondInternalFieldStore.isEdited.value = tempIsEdited;
|
|
422
|
+
const tempIsDirty = firstInternalFieldStore.isDirty.value;
|
|
423
|
+
firstInternalFieldStore.isDirty.value = secondInternalFieldStore.isDirty.value;
|
|
424
|
+
secondInternalFieldStore.isDirty.value = tempIsDirty;
|
|
425
|
+
if (firstInternalFieldStore.kind === "value" && secondInternalFieldStore.kind === "value") dispatchSwapField(internalFormStore, firstInternalFieldStore, secondInternalFieldStore);
|
|
426
|
+
if (firstInternalFieldStore.kind === "array" && secondInternalFieldStore.kind === "array") {
|
|
427
|
+
const firstItems = firstInternalFieldStore.items.value;
|
|
428
|
+
const secondItems = secondInternalFieldStore.items.value;
|
|
429
|
+
const tempStartItems = firstInternalFieldStore.startItems.value;
|
|
430
|
+
firstInternalFieldStore.startItems.value = secondInternalFieldStore.startItems.value;
|
|
431
|
+
secondInternalFieldStore.startItems.value = tempStartItems;
|
|
432
|
+
firstInternalFieldStore.items.value = secondItems;
|
|
433
|
+
secondInternalFieldStore.items.value = firstItems;
|
|
434
|
+
const maxLength = Math.max(firstItems.length, secondItems.length);
|
|
435
|
+
for (let index = 0; index < maxLength; index++) {
|
|
436
|
+
if (!firstInternalFieldStore.children[index]) {
|
|
437
|
+
firstInternalFieldStore.children[index] = {};
|
|
438
|
+
initializeFieldStore(internalFormStore, firstInternalFieldStore.children[index], firstInternalFieldStore.itemSchema, void 0, [...firstInternalFieldStore.path, index]);
|
|
439
|
+
}
|
|
440
|
+
if (!secondInternalFieldStore.children[index]) {
|
|
441
|
+
secondInternalFieldStore.children[index] = {};
|
|
442
|
+
initializeFieldStore(internalFormStore, secondInternalFieldStore.children[index], secondInternalFieldStore.itemSchema, void 0, [...secondInternalFieldStore.path, index]);
|
|
443
|
+
}
|
|
444
|
+
swapItemState(internalFormStore, firstInternalFieldStore.children[index], secondInternalFieldStore.children[index]);
|
|
445
|
+
}
|
|
446
|
+
} else if (firstInternalFieldStore.kind === "object" && secondInternalFieldStore.kind === "object") for (const key in firstInternalFieldStore.children) swapItemState(internalFormStore, firstInternalFieldStore.children[key], secondInternalFieldStore.children[key]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/methods/array-ops.ts
|
|
453
|
+
/**
|
|
454
|
+
* Resolves the array store at a path, marking every container between the
|
|
455
|
+
* root and the array as present (an insert into a nullish ancestor is a
|
|
456
|
+
* real change), and throws when the path does not resolve to an array
|
|
457
|
+
* field.
|
|
458
|
+
*/
|
|
459
|
+
function resolveArrayStore(internalFormStore, path) {
|
|
460
|
+
const chain = getFieldStoreChain(internalFormStore, path);
|
|
461
|
+
const target = chain[chain.length - 1];
|
|
462
|
+
if (target.kind !== "array") throw new Error(`Expected an array field at path ${JSON.stringify(path)}, got "${target.kind}"`);
|
|
463
|
+
for (let index = 1; index < chain.length - 1; index++) {
|
|
464
|
+
const ancestor = chain[index];
|
|
465
|
+
if (ancestor.kind === "value") continue;
|
|
466
|
+
ancestor.input.value = true;
|
|
467
|
+
ancestor.isDirty.value = computeContainerDirty(ancestor);
|
|
468
|
+
}
|
|
469
|
+
return target;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Inserts a new item into the array field at the given path. All items at
|
|
473
|
+
* or after the insertion point shift up by one index, and their full state
|
|
474
|
+
* (values, errors, touched/dirty flags, elements) shifts with them.
|
|
475
|
+
*
|
|
476
|
+
* @param form The form store containing the array field.
|
|
477
|
+
* @param path The path to the array field.
|
|
478
|
+
* @param config The insert configuration.
|
|
479
|
+
*/
|
|
480
|
+
function insert(form, path, config) {
|
|
481
|
+
batch(() => {
|
|
482
|
+
untrack(() => {
|
|
483
|
+
const internalFormStore = internalOf(form);
|
|
484
|
+
const internalArrayStore = resolveArrayStore(internalFormStore, path);
|
|
485
|
+
const items = internalArrayStore.items.value;
|
|
486
|
+
const insertIndex = config?.at ?? items.length;
|
|
487
|
+
if (insertIndex < 0 || insertIndex > items.length) return;
|
|
488
|
+
const newItems = [...items];
|
|
489
|
+
newItems.splice(insertIndex, 0, createId());
|
|
490
|
+
internalArrayStore.items.value = newItems;
|
|
491
|
+
for (let index = items.length; index > insertIndex; index--) {
|
|
492
|
+
if (!internalArrayStore.children[index]) {
|
|
493
|
+
internalArrayStore.children[index] = {};
|
|
494
|
+
initializeFieldStore(internalFormStore, internalArrayStore.children[index], internalArrayStore.itemSchema, void 0, [...internalArrayStore.path, index]);
|
|
495
|
+
}
|
|
496
|
+
copyItemState(internalFormStore, internalArrayStore.children[index - 1], internalArrayStore.children[index]);
|
|
497
|
+
}
|
|
498
|
+
if (!internalArrayStore.children[insertIndex]) {
|
|
499
|
+
internalArrayStore.children[insertIndex] = {};
|
|
500
|
+
initializeFieldStore(internalFormStore, internalArrayStore.children[insertIndex], internalArrayStore.itemSchema, config?.initialInput, [...internalArrayStore.path, insertIndex]);
|
|
501
|
+
} else resetItemState(internalFormStore, internalArrayStore.children[insertIndex], config?.initialInput);
|
|
502
|
+
internalArrayStore.input.value = true;
|
|
503
|
+
internalArrayStore.isTouched.value = true;
|
|
504
|
+
internalArrayStore.isEdited.value = true;
|
|
505
|
+
internalArrayStore.isDirty.value = true;
|
|
506
|
+
validateIfRequired(internalFormStore, internalArrayStore, "input");
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Removes the item at the given index from the array field at the given
|
|
512
|
+
* path. All items after it shift down by one index with their full state.
|
|
513
|
+
*
|
|
514
|
+
* @param form The form store containing the array field.
|
|
515
|
+
* @param path The path to the array field.
|
|
516
|
+
* @param at The index of the item to remove.
|
|
517
|
+
*/
|
|
518
|
+
function remove(form, path, at) {
|
|
519
|
+
batch(() => {
|
|
520
|
+
untrack(() => {
|
|
521
|
+
const internalFormStore = internalOf(form);
|
|
522
|
+
const internalArrayStore = resolveArrayStore(internalFormStore, path);
|
|
523
|
+
const items = internalArrayStore.items.value;
|
|
524
|
+
if (at < 0 || at > items.length - 1) return;
|
|
525
|
+
const newItems = [...items];
|
|
526
|
+
newItems.splice(at, 1);
|
|
527
|
+
internalArrayStore.items.value = newItems;
|
|
528
|
+
for (let index = at; index < items.length - 1; index++) copyItemState(internalFormStore, internalArrayStore.children[index + 1], internalArrayStore.children[index]);
|
|
529
|
+
internalArrayStore.isTouched.value = true;
|
|
530
|
+
internalArrayStore.isEdited.value = true;
|
|
531
|
+
internalArrayStore.isDirty.value = internalArrayStore.startItems.value.join() !== newItems.join();
|
|
532
|
+
validateIfRequired(internalFormStore, internalArrayStore, "input");
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Moves the item at one index to another within the array field at the
|
|
538
|
+
* given path. All items between the two indices shift accordingly with
|
|
539
|
+
* their full state.
|
|
540
|
+
*
|
|
541
|
+
* @param form The form store containing the array field.
|
|
542
|
+
* @param path The path to the array field.
|
|
543
|
+
* @param from The index of the item to move.
|
|
544
|
+
* @param to The index to move the item to.
|
|
545
|
+
*/
|
|
546
|
+
function move(form, path, from, to) {
|
|
547
|
+
batch(() => {
|
|
548
|
+
untrack(() => {
|
|
549
|
+
const internalFormStore = internalOf(form);
|
|
550
|
+
const internalArrayStore = resolveArrayStore(internalFormStore, path);
|
|
551
|
+
const items = internalArrayStore.items.value;
|
|
552
|
+
if (from < 0 || from > items.length - 1 || to < 0 || to > items.length - 1 || from === to) return;
|
|
553
|
+
const newItems = [...items];
|
|
554
|
+
newItems.splice(to, 0, newItems.splice(from, 1)[0]);
|
|
555
|
+
internalArrayStore.items.value = newItems;
|
|
556
|
+
const tempStore = parkItemState(internalFormStore, internalArrayStore, internalArrayStore.children[from], from);
|
|
557
|
+
if (from < to) for (let index = from; index < to; index++) copyItemState(internalFormStore, internalArrayStore.children[index + 1], internalArrayStore.children[index]);
|
|
558
|
+
else for (let index = from; index > to; index--) copyItemState(internalFormStore, internalArrayStore.children[index - 1], internalArrayStore.children[index]);
|
|
559
|
+
copyItemState(internalFormStore, tempStore, internalArrayStore.children[to]);
|
|
560
|
+
internalArrayStore.isTouched.value = true;
|
|
561
|
+
internalArrayStore.isEdited.value = true;
|
|
562
|
+
internalArrayStore.isDirty.value = internalArrayStore.startItems.value.join() !== newItems.join();
|
|
563
|
+
validateIfRequired(internalFormStore, internalArrayStore, "input");
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Swaps two items in the array field at the given path by exchanging their
|
|
569
|
+
* positions and their full state.
|
|
570
|
+
*
|
|
571
|
+
* @param form The form store containing the array field.
|
|
572
|
+
* @param path The path to the array field.
|
|
573
|
+
* @param at The index of the first item.
|
|
574
|
+
* @param and The index of the second item.
|
|
575
|
+
*/
|
|
576
|
+
function swap(form, path, at, and) {
|
|
577
|
+
batch(() => {
|
|
578
|
+
untrack(() => {
|
|
579
|
+
const internalFormStore = internalOf(form);
|
|
580
|
+
const internalArrayStore = resolveArrayStore(internalFormStore, path);
|
|
581
|
+
const items = internalArrayStore.items.value;
|
|
582
|
+
if (at < 0 || at > items.length - 1 || and < 0 || and > items.length - 1 || at === and) return;
|
|
583
|
+
const newItems = [...items];
|
|
584
|
+
const tempItemId = newItems[at];
|
|
585
|
+
newItems[at] = newItems[and];
|
|
586
|
+
newItems[and] = tempItemId;
|
|
587
|
+
internalArrayStore.items.value = newItems;
|
|
588
|
+
swapItemState(internalFormStore, internalArrayStore.children[at], internalArrayStore.children[and]);
|
|
589
|
+
internalArrayStore.isTouched.value = true;
|
|
590
|
+
internalArrayStore.isEdited.value = true;
|
|
591
|
+
internalArrayStore.isDirty.value = internalArrayStore.startItems.value.join() !== newItems.join();
|
|
592
|
+
validateIfRequired(internalFormStore, internalArrayStore, "input");
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/methods/errors.ts
|
|
599
|
+
/**
|
|
600
|
+
* Sets or clears the error messages of the field at the given path, or the
|
|
601
|
+
* form-level (root) errors when no path is given. Useful for custom errors
|
|
602
|
+
* that do not come from schema validation (e.g. a failed server action).
|
|
603
|
+
*
|
|
604
|
+
* @param form The form store to set errors on.
|
|
605
|
+
* @param errors The error messages, or `null` to clear.
|
|
606
|
+
* @param path The path to the field (omit for form-level errors).
|
|
607
|
+
*/
|
|
608
|
+
function setErrors(form, errors, path) {
|
|
609
|
+
const internal = internalOf(form);
|
|
610
|
+
(path ? getFieldStore(internal, path) : internal).validationErrors.value = errors;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Retrieves the error messages of the field at the given path, or the
|
|
614
|
+
* form-level (root) errors when no path is given. Does NOT include
|
|
615
|
+
* descendants — use `getDeepErrors` for a subtree.
|
|
616
|
+
*
|
|
617
|
+
* @param form The form store to read errors from.
|
|
618
|
+
* @param path The path to the field (omit for form-level errors).
|
|
619
|
+
*
|
|
620
|
+
* @returns The error messages, or `null`.
|
|
621
|
+
*/
|
|
622
|
+
function getErrors(form, path) {
|
|
623
|
+
const internal = internalOf(form);
|
|
624
|
+
return (path ? getFieldStore(internal, path) : internal).errors.value;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Retrieves every error message of the field at the given path and all its
|
|
628
|
+
* descendants (the entire form when no path is given), in depth-first
|
|
629
|
+
* order. Form-level errors are included. This is what a checks panel sits
|
|
630
|
+
* on — it surfaces errors of fields that are not currently rendered.
|
|
631
|
+
*
|
|
632
|
+
* @param form The form store to read errors from.
|
|
633
|
+
* @param path The path to scope to (omit for the whole form).
|
|
634
|
+
*
|
|
635
|
+
* @returns The error messages, or `null` if none exist.
|
|
636
|
+
*/
|
|
637
|
+
function getDeepErrors(form, path) {
|
|
638
|
+
const internal = internalOf(form);
|
|
639
|
+
let deepErrors = null;
|
|
640
|
+
walkFieldStore(path ? getFieldStore(internal, path) : internal, (internalFieldStore) => {
|
|
641
|
+
const errors = internalFieldStore.errors.value;
|
|
642
|
+
if (errors) if (deepErrors) deepErrors.push(...errors);
|
|
643
|
+
else deepErrors = [...errors];
|
|
644
|
+
});
|
|
645
|
+
return deepErrors;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Retrieves every erroring field of the subtree at the given path (the
|
|
649
|
+
* entire form when no path is given) as `{ path, errors }` entries in
|
|
650
|
+
* depth-first order.
|
|
651
|
+
*
|
|
652
|
+
* @param form The form store to read errors from.
|
|
653
|
+
* @param path The path to scope to (omit for the whole form).
|
|
654
|
+
*
|
|
655
|
+
* @returns The deep error entries (empty when none exist).
|
|
656
|
+
*/
|
|
657
|
+
function getDeepErrorEntries(form, path) {
|
|
658
|
+
const internal = internalOf(form);
|
|
659
|
+
const entries = [];
|
|
660
|
+
walkFieldStore(path ? getFieldStore(internal, path) : internal, (internalFieldStore) => {
|
|
661
|
+
const errors = internalFieldStore.errors.value;
|
|
662
|
+
if (errors) entries.push({
|
|
663
|
+
path: [...internalFieldStore.path],
|
|
664
|
+
errors
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
return entries;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
//#endregion
|
|
671
|
+
//#region src/methods/focus.ts
|
|
672
|
+
/**
|
|
673
|
+
* Focuses the first focusable element of the field at the given path.
|
|
674
|
+
* Detached, disabled or hidden elements are skipped.
|
|
675
|
+
*
|
|
676
|
+
* @param form The form store containing the field.
|
|
677
|
+
* @param path The path to the field to focus.
|
|
678
|
+
*/
|
|
679
|
+
function focus(form, path) {
|
|
680
|
+
focusFieldElement(getFieldStore(internalOf(form), path));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/methods/get-dirty-input.ts
|
|
685
|
+
/**
|
|
686
|
+
* Retrieves only the dirty input values of the field at the given path, or
|
|
687
|
+
* the entire form when no path is given. Arrays are treated as atomic and
|
|
688
|
+
* returned in full if any item is dirty, while object keys without a dirty
|
|
689
|
+
* descendant are omitted. Returns `undefined` if nothing in the inspected
|
|
690
|
+
* subtree is dirty.
|
|
691
|
+
*
|
|
692
|
+
* @param form The form store to retrieve dirty input from.
|
|
693
|
+
* @param path The path to the field (omit for the whole form).
|
|
694
|
+
*
|
|
695
|
+
* @returns The dirty input, or `undefined`.
|
|
696
|
+
*/
|
|
697
|
+
function getDirtyInput(form, path) {
|
|
698
|
+
const internal = internalOf(form);
|
|
699
|
+
return getDirtyFieldInput(internal, path ? getFieldStore(internal, path) : internal);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/methods/get-dirty-paths.ts
|
|
704
|
+
/**
|
|
705
|
+
* Returns the paths to the dirty fields of the form (or of the subtree at
|
|
706
|
+
* the given path). Arrays are treated as atomic and contribute only their
|
|
707
|
+
* own path if any item is dirty; object branches are recursed into, and a
|
|
708
|
+
* container that flipped dirty itself (e.g. nullish → present) emits its
|
|
709
|
+
* own path only when no descendant already covers it.
|
|
710
|
+
*
|
|
711
|
+
* @param form The form store to inspect.
|
|
712
|
+
* @param path The path to scope to (omit for the whole form).
|
|
713
|
+
*
|
|
714
|
+
* @returns The list of paths to the dirty fields.
|
|
715
|
+
*/
|
|
716
|
+
function getDirtyPaths(form, path) {
|
|
717
|
+
const internal = internalOf(form);
|
|
718
|
+
const paths = [];
|
|
719
|
+
collectDirtyPaths(path ? getFieldStore(internal, path) : internal, paths);
|
|
720
|
+
return paths;
|
|
721
|
+
}
|
|
722
|
+
function collectDirtyPaths(internalFieldStore, paths) {
|
|
723
|
+
if (internalFieldStore.kind === "object" && internalFieldStore.input.value) {
|
|
724
|
+
const lengthBefore = paths.length;
|
|
725
|
+
for (const key in internalFieldStore.children) collectDirtyPaths(internalFieldStore.children[key], paths);
|
|
726
|
+
if (paths.length === lengthBefore && internalFieldStore.isDirty.value && internalFieldStore.path.length > 0) paths.push([...internalFieldStore.path]);
|
|
727
|
+
} else if (internalFieldStore.kind === "value") {
|
|
728
|
+
if (internalFieldStore.isDirty.value && internalFieldStore.path.length > 0) paths.push([...internalFieldStore.path]);
|
|
729
|
+
} else if (getFieldBool(internalFieldStore, "isDirty") && internalFieldStore.path.length > 0) paths.push([...internalFieldStore.path]);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/methods/get-input.ts
|
|
734
|
+
/**
|
|
735
|
+
* Retrieves the current input value of the field at the given path, or the
|
|
736
|
+
* entire form when no path is given. The tree is the allow-list, so the
|
|
737
|
+
* result only ever contains declared fields.
|
|
738
|
+
*
|
|
739
|
+
* @param form The form store to retrieve input from.
|
|
740
|
+
* @param path The path to the field (omit for the whole form).
|
|
741
|
+
*
|
|
742
|
+
* @returns The input value.
|
|
743
|
+
*/
|
|
744
|
+
function getInput(form, path) {
|
|
745
|
+
const internal = internalOf(form);
|
|
746
|
+
return getFieldInput(path ? getFieldStore(internal, path) : internal);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/methods/pick-dirty.ts
|
|
751
|
+
/**
|
|
752
|
+
* Picks only the dirty parts of the given value, using the form's dirty
|
|
753
|
+
* fields as a structural mask while reading from the SUPPLIED value (e.g. a
|
|
754
|
+
* validated output), not the form's own input. Arrays are treated as atomic
|
|
755
|
+
* and object keys without a dirty descendant are omitted. Returns
|
|
756
|
+
* `undefined` if no field is dirty or no dirty key is present in the value.
|
|
757
|
+
*
|
|
758
|
+
* Envelope leaves (estimate/amount-or-percent) emit their COMPLETE
|
|
759
|
+
* kind envelope — the plugin wraps the supplied
|
|
760
|
+
* value with its meta half, and a leaf whose only change is plugin state
|
|
761
|
+
* (a mode flip) still emits.
|
|
762
|
+
*
|
|
763
|
+
* @param form The form store providing the dirty mask.
|
|
764
|
+
* @param from The value to filter down to its dirty parts.
|
|
765
|
+
*
|
|
766
|
+
* @returns The dirty parts of the value, or `undefined`.
|
|
767
|
+
*/
|
|
768
|
+
function pickDirty(form, from) {
|
|
769
|
+
const internal = internalOf(form);
|
|
770
|
+
if (!getFieldBool(internal, "isDirty") && !hasPluginDirtyField(internal, internal)) return;
|
|
771
|
+
const result = pickFieldValue(internal, internal, from);
|
|
772
|
+
return result && typeof result === "object" && Object.keys(result).length ? result : void 0;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Recursively picks the dirty parts of a value using the field store as a
|
|
776
|
+
* structural mask. Objects with present input recurse into their dirty
|
|
777
|
+
* children that exist in the value; arrays, leaves, nullish-cleared
|
|
778
|
+
* containers and shape-diverging values are returned as-is (arrays with
|
|
779
|
+
* their envelope leaves wrapped).
|
|
780
|
+
*/
|
|
781
|
+
function pickFieldValue(internalFormStore, internalFieldStore, value) {
|
|
782
|
+
if (internalFieldStore.kind === "object" && internalFieldStore.input.value && value && typeof value === "object" && !Array.isArray(value)) {
|
|
783
|
+
const result = {};
|
|
784
|
+
for (const key in internalFieldStore.children) {
|
|
785
|
+
const child = internalFieldStore.children[key];
|
|
786
|
+
const present = Object.prototype.hasOwnProperty.call(value, key);
|
|
787
|
+
if (child.kind === "value") {
|
|
788
|
+
const valueDirty = getFieldBool(child, "isDirty");
|
|
789
|
+
const pluginDirty = fieldPluginDirty(internalFormStore, child);
|
|
790
|
+
if (!(valueDirty && present) && !pluginDirty) continue;
|
|
791
|
+
const encoded = encodeFieldValue(internalFormStore, child, valueDirty && present ? value[key] : void 0);
|
|
792
|
+
if (valueDirty && present || encoded !== void 0) result[key] = encoded;
|
|
793
|
+
} else if ((getFieldBool(child, "isDirty") || hasPluginDirtyField(internalFormStore, child)) && present) result[key] = pickFieldValue(internalFormStore, child, value[key]);
|
|
794
|
+
}
|
|
795
|
+
return result;
|
|
796
|
+
}
|
|
797
|
+
if (internalFieldStore.kind === "array") return encodeScopeValues(internalFormStore, internalFieldStore, value);
|
|
798
|
+
return value;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/methods/reset.ts
|
|
803
|
+
/**
|
|
804
|
+
* Resets a specific field or the entire form to its initial state, with
|
|
805
|
+
* fine-grained control over which state to preserve via the `keep*` flags.
|
|
806
|
+
* When `initialInput` is provided it replaces the reset baseline first.
|
|
807
|
+
*
|
|
808
|
+
* @param form The form store to reset.
|
|
809
|
+
* @param config The reset configuration.
|
|
810
|
+
*/
|
|
811
|
+
function reset(form, config) {
|
|
812
|
+
batch(() => {
|
|
813
|
+
untrack(() => {
|
|
814
|
+
const internalFormStore = internalOf(form);
|
|
815
|
+
const internalFieldStore = config?.path ? getFieldStore(internalFormStore, config.path) : internalFormStore;
|
|
816
|
+
if (config && "initialInput" in config) setInitialFieldInput(internalFormStore, internalFieldStore, config.initialInput, { syncInitial: true });
|
|
817
|
+
walkFieldStore(internalFieldStore, (fieldStore) => {
|
|
818
|
+
fieldStore.elements = fieldStore.initialElements;
|
|
819
|
+
if (!config?.keepErrors) fieldStore.validationErrors.value = null;
|
|
820
|
+
if (!config?.keepTouched) fieldStore.isTouched.value = false;
|
|
821
|
+
if (!config?.keepEdited) fieldStore.isEdited.value = false;
|
|
822
|
+
fieldStore.startInput.value = fieldStore.initialInput.value;
|
|
823
|
+
if (!config?.keepInput) fieldStore.input.value = fieldStore.initialInput.value;
|
|
824
|
+
if (fieldStore.kind === "array") {
|
|
825
|
+
fieldStore.startItems.value = fieldStore.initialItems.value;
|
|
826
|
+
if (!config?.keepInput || fieldStore.startItems.value.length === fieldStore.items.value.length) fieldStore.items.value = fieldStore.initialItems.value;
|
|
827
|
+
fieldStore.isDirty.value = computeContainerDirty(fieldStore);
|
|
828
|
+
} else if (fieldStore.kind === "object") fieldStore.isDirty.value = computeContainerDirty(fieldStore);
|
|
829
|
+
else {
|
|
830
|
+
fieldStore.isDirty.value = !isSemanticEqual(fieldStore.input.value, fieldStore.startInput.value);
|
|
831
|
+
dispatchResetField(internalFormStore, fieldStore);
|
|
832
|
+
for (const element of fieldStore.elements) if (element instanceof HTMLInputElement && element.type === "file") element.value = "";
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
if (!config?.path) {
|
|
836
|
+
if (!config?.keepSubmitted) internalFormStore.isSubmitted.value = false;
|
|
837
|
+
if (internalFormStore.validate === "initial") validateFormInput(internalFormStore);
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
//#endregion
|
|
844
|
+
//#region src/methods/set-entry.ts
|
|
845
|
+
function amountOrPercentOf(form, path) {
|
|
846
|
+
const internalFormStore = internalOf(form);
|
|
847
|
+
const store = getFieldStore(internalFormStore, path);
|
|
848
|
+
const slot = store.kind === "value" ? envelopesKey.get(internalFormStore, store) : void 0;
|
|
849
|
+
if (store.kind !== "value" || slot?.family !== "amount-or-percent") throw new Error(`Not an amount-or-percent field (at ${JSON.stringify(path)}) — needs a field with an amount-or-percent envelope slot`);
|
|
850
|
+
return {
|
|
851
|
+
form: internalFormStore,
|
|
852
|
+
store,
|
|
853
|
+
slot
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Sets the entry mode of an amount-or-percent field (enter a dollar
|
|
858
|
+
* amount, or a percent of the percent basis). Dirties the meta half; the
|
|
859
|
+
* field's own value — always the resolved dollar amount — is the widget's
|
|
860
|
+
* to convert and write.
|
|
861
|
+
*
|
|
862
|
+
* @param form The form store containing the field.
|
|
863
|
+
* @param path The path to the amount-or-percent field.
|
|
864
|
+
* @param mode The entry mode.
|
|
865
|
+
*/
|
|
866
|
+
function setEntryMode(form, path, mode) {
|
|
867
|
+
const target = amountOrPercentOf(form, path);
|
|
868
|
+
batch(() => {
|
|
869
|
+
untrack(() => {
|
|
870
|
+
writeEnvelope(target.form, target.store, target.slot, {
|
|
871
|
+
...target.slot.envelope.value,
|
|
872
|
+
mode
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Sets the percent basis of an amount-or-percent field (the root-level field key
|
|
879
|
+
* the percent is taken of). Dirties the meta half; keeping the resolved
|
|
880
|
+
* dollar amount constant against the new basis is the widget's job.
|
|
881
|
+
*
|
|
882
|
+
* @param form The form store containing the field.
|
|
883
|
+
* @param path The path to the amount-or-percent field.
|
|
884
|
+
* @param percentBasis The basis field key.
|
|
885
|
+
*/
|
|
886
|
+
function setPercentBasis(form, path, percentBasis) {
|
|
887
|
+
const target = amountOrPercentOf(form, path);
|
|
888
|
+
batch(() => {
|
|
889
|
+
untrack(() => {
|
|
890
|
+
writeEnvelope(target.form, target.store, target.slot, {
|
|
891
|
+
...target.slot.envelope.value,
|
|
892
|
+
basis: percentBasis
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/methods/set-input.ts
|
|
900
|
+
/**
|
|
901
|
+
* Sets the input value of the field at the given path (or the entire form
|
|
902
|
+
* for an empty path), updating touched, edited and dirty state, and
|
|
903
|
+
* triggers validation when the form's validation mode requires it. Throws
|
|
904
|
+
* on a path not declared in the schema.
|
|
905
|
+
*
|
|
906
|
+
* @param form The form store to set input on.
|
|
907
|
+
* @param path The path to the field (`[]` for the whole form).
|
|
908
|
+
* @param input The new input value.
|
|
909
|
+
*/
|
|
910
|
+
function setInput(form, path, input) {
|
|
911
|
+
batch(() => {
|
|
912
|
+
const internalFormStore = internalOf(form);
|
|
913
|
+
setFieldInput(internalFormStore, path, input);
|
|
914
|
+
validateIfRequired(internalFormStore, path.length ? getFieldStore(internalFormStore, path) : internalFormStore, "input");
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
//#endregion
|
|
919
|
+
//#region src/methods/set-mode.ts
|
|
920
|
+
/**
|
|
921
|
+
* Flips an estimate field between its two modes — the ONLY supported mode
|
|
922
|
+
* writer (a raw `mode` signal write skips value seeding and the flip
|
|
923
|
+
* timestamp):
|
|
924
|
+
*
|
|
925
|
+
* - `estimate` → `formula`: the current input is preserved as the meta
|
|
926
|
+
* half's `manualValue` and the field computes again. The derived value
|
|
927
|
+
* is NOT written into the input — derived values are outputs; the
|
|
928
|
+
* server recompute pass is their author.
|
|
929
|
+
* - `formula` → `estimate`: the manual value is seeded from the last
|
|
930
|
+
* formula result (estimate-first chronology: when you stop trusting the
|
|
931
|
+
* formula you start from its current value and adjust), which marks the
|
|
932
|
+
* value dirty like any user edit.
|
|
933
|
+
*
|
|
934
|
+
* Either flip dirties the meta half (`lastFlippedAt` stamped), so a flip
|
|
935
|
+
* with no other edit still produces a payload.
|
|
936
|
+
*
|
|
937
|
+
* Works at any depth: an estimate field inside an array row has its own
|
|
938
|
+
* envelope slot, built from the row's envelope. The throw is reserved
|
|
939
|
+
* for a field that genuinely has none — a non-estimate control, or a form
|
|
940
|
+
* without the envelopes plugin.
|
|
941
|
+
*
|
|
942
|
+
* @param form The form store containing the field.
|
|
943
|
+
* @param path The path to the estimate field.
|
|
944
|
+
* @param mode The target mode (a no-op when already current).
|
|
945
|
+
* @param options Flip options (e.g. an injected timestamp).
|
|
946
|
+
*/
|
|
947
|
+
function setMode(form, path, mode, options = {}) {
|
|
948
|
+
const internalFormStore = internalOf(form);
|
|
949
|
+
const store = getFieldStore(internalFormStore, path);
|
|
950
|
+
const slot = store.kind === "value" ? envelopesKey.get(internalFormStore, store) : void 0;
|
|
951
|
+
if (store.kind !== "value" || slot?.family !== "estimate") throw new Error(`Not an estimate field (at ${JSON.stringify(path)}) — setMode needs a field with an estimate envelope slot`);
|
|
952
|
+
batch(() => {
|
|
953
|
+
untrack(() => {
|
|
954
|
+
const live = slot.envelope.value;
|
|
955
|
+
if (resolveEstimateMode(live, store.schema) === mode) return;
|
|
956
|
+
const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
957
|
+
if (mode === "formula") {
|
|
958
|
+
const manualValue = store.isDirty.value ? isEmptyish(store.input.value) ? null : store.input.value : live.manualValue ?? null;
|
|
959
|
+
writeEnvelope(internalFormStore, store, slot, {
|
|
960
|
+
...live,
|
|
961
|
+
mode: "formula",
|
|
962
|
+
manualValue,
|
|
963
|
+
lastFlippedAt: now
|
|
964
|
+
});
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const candidate = derivationKey.get(internalFormStore, store)?.formulaValue.value;
|
|
968
|
+
const seeded = candidate && candidate.error === null && candidate.value !== void 0 ? candidate.value : live.value;
|
|
969
|
+
writeEnvelope(internalFormStore, store, slot, {
|
|
970
|
+
...live,
|
|
971
|
+
mode: "estimate",
|
|
972
|
+
value: seeded,
|
|
973
|
+
lastFlippedAt: now
|
|
974
|
+
});
|
|
975
|
+
if (candidate && candidate.error === null && candidate.value !== void 0) {
|
|
976
|
+
store.isTouched.value = true;
|
|
977
|
+
store.isEdited.value = true;
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/methods/set-off-form-values.ts
|
|
985
|
+
/**
|
|
986
|
+
* Replaces the form's off-form values (the read-only eval scope formula
|
|
987
|
+
* resolution falls back to). One signal write: every formula field reading
|
|
988
|
+
* off-form values re-resolves once — the LOS-470 `BpsBaseSync` behavior as
|
|
989
|
+
* a store property. `applyBaseline` performs the same
|
|
990
|
+
* write on reconcile, batched with the value rebase.
|
|
991
|
+
*
|
|
992
|
+
* @param form The form store.
|
|
993
|
+
* @param values The new off-form values.
|
|
994
|
+
*/
|
|
995
|
+
function setOffFormValues(form, values) {
|
|
996
|
+
internalOf(form).offFormValues.value = values;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
//#endregion
|
|
1000
|
+
//#region src/methods/validate.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* Validates the entire form input with the injected validator, routing each
|
|
1003
|
+
* issue to its field's `errors` signal. Optionally focuses the first field
|
|
1004
|
+
* with an error.
|
|
1005
|
+
*
|
|
1006
|
+
* @param form The form store to validate.
|
|
1007
|
+
* @param config The validation configuration.
|
|
1008
|
+
*
|
|
1009
|
+
* @returns The validation result.
|
|
1010
|
+
*/
|
|
1011
|
+
function validate(form, config) {
|
|
1012
|
+
return validateFormInput(internalOf(form), config);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/core/codec/encode-dirty.ts
|
|
1017
|
+
/**
|
|
1018
|
+
* Builds the envelope-control lookup from a wire list.
|
|
1019
|
+
*/
|
|
1020
|
+
function envelopeContracts(wire) {
|
|
1021
|
+
const map = /* @__PURE__ */ new Map();
|
|
1022
|
+
for (const contract of wire ?? []) for (const control of contract.envelopeControls ?? []) map.set(control, contract);
|
|
1023
|
+
return map;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Encodes a dirty-values object (the `pickDirty`/`getDirtyInput` result)
|
|
1027
|
+
* into the save payload, partitioning each root key by its `x-column`
|
|
1028
|
+
* geometry: `x-column: true` fields become column updates, everything else
|
|
1029
|
+
* lands in the `data` bag. Undeclared keys — including prototype-pollution
|
|
1030
|
+
* keys — are dropped: the schema is the allow-list at the write boundary
|
|
1031
|
+
* too.
|
|
1032
|
+
*
|
|
1033
|
+
* Wire contracts carry each plugin's persistence policy (behavior
|
|
1034
|
+
* relocated verbatim from LOS-461, not redesigned): `skipValue` drops a
|
|
1035
|
+
* formula value (the server recompute is its only author), and an
|
|
1036
|
+
* envelope contract's `encode` normalizes the outgoing envelope (an
|
|
1037
|
+
* estimate value persists exactly when its meta pins `mode: "estimate"`).
|
|
1038
|
+
*
|
|
1039
|
+
* @param schema The form's JSON-Schema (object schema with properties).
|
|
1040
|
+
* @param dirty The dirty values, or `undefined` when nothing is dirty.
|
|
1041
|
+
* @param options Encoding options (column set, wire contracts).
|
|
1042
|
+
*
|
|
1043
|
+
* @returns The partitioned payload, or `undefined` when nothing survives.
|
|
1044
|
+
*/
|
|
1045
|
+
function encodeDirty(schema, dirty, options = {}) {
|
|
1046
|
+
if (dirty == null) return void 0;
|
|
1047
|
+
const properties = schema.properties ?? {};
|
|
1048
|
+
const envelopes$1 = envelopeContracts(options.wire);
|
|
1049
|
+
const columns = {};
|
|
1050
|
+
const data = {};
|
|
1051
|
+
let hasEntries = false;
|
|
1052
|
+
for (const key of Object.keys(dirty)) {
|
|
1053
|
+
if (!isSafeKey(key)) continue;
|
|
1054
|
+
if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
|
|
1055
|
+
const property = properties[key];
|
|
1056
|
+
const control = inferControl(property);
|
|
1057
|
+
if (options.wire?.some((w) => w.skipValue?.(control, dirty, key))) continue;
|
|
1058
|
+
const isColumn = property["x-column"] === true;
|
|
1059
|
+
const contract = envelopes$1.get(control);
|
|
1060
|
+
if (contract) {
|
|
1061
|
+
const encoded = contract.encode ? contract.encode(control, dirty[key]) : dirty[key];
|
|
1062
|
+
if (encoded === void 0) continue;
|
|
1063
|
+
data[key] = encoded;
|
|
1064
|
+
if (isColumn && (!options.knownColumns || options.knownColumns.has(key))) {
|
|
1065
|
+
const value = contract.unwrap ? contract.unwrap(encoded).value : void 0;
|
|
1066
|
+
if (value !== void 0) columns[key] = value;
|
|
1067
|
+
}
|
|
1068
|
+
hasEntries = true;
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
if (isColumn) {
|
|
1072
|
+
if (options.knownColumns && !options.knownColumns.has(key)) continue;
|
|
1073
|
+
columns[key] = dirty[key];
|
|
1074
|
+
} else data[key] = dirty[key];
|
|
1075
|
+
hasEntries = true;
|
|
1076
|
+
}
|
|
1077
|
+
return hasEntries ? {
|
|
1078
|
+
columns,
|
|
1079
|
+
data
|
|
1080
|
+
} : void 0;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region src/plugins/envelopes/plugin.ts
|
|
1085
|
+
/**
|
|
1086
|
+
* Builds (or re-seeds) the slots of ONE object scope — the document root
|
|
1087
|
+
* or a single array row. Idempotent: called again on a REUSED store (an
|
|
1088
|
+
* array shrink-then-regrow, a rebuilt row) it re-seeds the existing
|
|
1089
|
+
* signals in place instead of replacing them, so computeds already wired
|
|
1090
|
+
* to the mode signal keep tracking it.
|
|
1091
|
+
*/
|
|
1092
|
+
function buildScopeSlots(form, state, scope, raw) {
|
|
1093
|
+
for (const key of Object.keys(scope.children)) {
|
|
1094
|
+
const child = scope.children[key];
|
|
1095
|
+
if (child.kind !== "value") continue;
|
|
1096
|
+
if (child.control === "estimate") buildEstimateSlot(form, state, child, readOwn(raw, key));
|
|
1097
|
+
else if (child.control === "amount-or-percent") buildAmountOrPercentSlot(form, state, child, readOwn(raw, key));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function estimateDirty(live, start, schema) {
|
|
1101
|
+
return resolveEstimateMode(live, schema) !== resolveEstimateMode(start, schema) || resolveEstimateMode(live, schema) === "estimate" && !isSemanticEqual(live.value, start.value);
|
|
1102
|
+
}
|
|
1103
|
+
function buildEstimateSlot(form, state, store, raw) {
|
|
1104
|
+
const decoded = decodeEstimateEnvelope(form, store, raw);
|
|
1105
|
+
const existing = state.get(store);
|
|
1106
|
+
if (existing?.family === "estimate") {
|
|
1107
|
+
existing.startEnvelope.value = decoded;
|
|
1108
|
+
writeEnvelope(form, store, existing, decoded);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const envelope = createSignal(decoded);
|
|
1112
|
+
const startEnvelope = createSignal(decoded);
|
|
1113
|
+
const slot = {
|
|
1114
|
+
family: "estimate",
|
|
1115
|
+
envelope,
|
|
1116
|
+
startEnvelope,
|
|
1117
|
+
mode: computed(() => resolveEstimateMode(envelope.value, store.schema)),
|
|
1118
|
+
manualValue: computed(() => envelope.value.manualValue ?? null),
|
|
1119
|
+
lastFlippedAt: computed(() => envelope.value.lastFlippedAt),
|
|
1120
|
+
isDirty: computed(() => estimateDirty(envelope.value, startEnvelope.value, store.schema))
|
|
1121
|
+
};
|
|
1122
|
+
state.set(store, slot);
|
|
1123
|
+
}
|
|
1124
|
+
function buildAmountOrPercentSlot(form, state, store, raw) {
|
|
1125
|
+
const decoded = decodeAmountOrPercentEnvelope(form, store, raw);
|
|
1126
|
+
const existing = state.get(store);
|
|
1127
|
+
if (existing?.family === "amount-or-percent") {
|
|
1128
|
+
existing.startEnvelope.value = decoded;
|
|
1129
|
+
writeEnvelope(form, store, existing, decoded);
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const envelope = createSignal(decoded);
|
|
1133
|
+
const startEnvelope = createSignal(decoded);
|
|
1134
|
+
const slot = {
|
|
1135
|
+
family: "amount-or-percent",
|
|
1136
|
+
envelope,
|
|
1137
|
+
startEnvelope,
|
|
1138
|
+
entryMode: computed(() => envelope.value.mode),
|
|
1139
|
+
percentBasis: computed(() => envelope.value.basis),
|
|
1140
|
+
isDirty: computed(() => envelope.value.mode !== startEnvelope.value.mode || envelope.value.basis !== startEnvelope.value.basis)
|
|
1141
|
+
};
|
|
1142
|
+
state.set(store, slot);
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Rebases one scope's slots onto fresh raw data — the meta half of
|
|
1146
|
+
* `applyBaseline`. `adoptEnvelope` keeps per-channel clean-vs-dirty, then
|
|
1147
|
+
* `writeEnvelope` runs once.
|
|
1148
|
+
*/
|
|
1149
|
+
function rebaseScopeSlots(form, state, scope, raw) {
|
|
1150
|
+
for (const key of Object.keys(scope.children)) {
|
|
1151
|
+
const child = scope.children[key];
|
|
1152
|
+
if (child.kind !== "value") continue;
|
|
1153
|
+
const slot = state.get(child);
|
|
1154
|
+
if (!slot) continue;
|
|
1155
|
+
if (slot.family === "estimate") {
|
|
1156
|
+
const incoming = decodeEstimateEnvelope(form, child, readOwn(raw, key));
|
|
1157
|
+
bindAdopted(form, child, slot, adoptEnvelope(slot.envelope.value, slot.startEnvelope.value, incoming, child.schema));
|
|
1158
|
+
} else {
|
|
1159
|
+
const incoming = decodeAmountOrPercentEnvelope(form, child, readOwn(raw, key));
|
|
1160
|
+
bindAdopted(form, child, slot, adoptEnvelope(slot.envelope.value, slot.startEnvelope.value, incoming, child.schema));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Serializes an estimate slot's meta half. In estimate mode an EDITED input
|
|
1166
|
+
* is the manual value (keystrokes mirror into the meta — never the loaded
|
|
1167
|
+
* column value); an unedited one carries the decoded `manualValue`
|
|
1168
|
+
* forward. In formula mode the value preserved at flip time carries
|
|
1169
|
+
* forward.
|
|
1170
|
+
*/
|
|
1171
|
+
function encodeEstimateMeta(store, slot) {
|
|
1172
|
+
const live = slot.envelope.value;
|
|
1173
|
+
const start = slot.startEnvelope.value;
|
|
1174
|
+
const mode = resolveEstimateMode(live, store.schema);
|
|
1175
|
+
const meta = {
|
|
1176
|
+
mode,
|
|
1177
|
+
manualValue: mode === "estimate" ? store.isDirty.value ? isEmptyish(store.input.value) ? null : store.input.value : start.manualValue ?? null : live.manualValue ?? null
|
|
1178
|
+
};
|
|
1179
|
+
const flippedAt = live.lastFlippedAt ?? start.lastFlippedAt;
|
|
1180
|
+
if (flippedAt !== void 0) meta.lastFlippedAt = flippedAt;
|
|
1181
|
+
return meta;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Whether any LIVE value leaf in the subtree carries a dirty slot. Walks
|
|
1185
|
+
* the tree (not the slot map): stale child stores past an array shrink
|
|
1186
|
+
* keep their slots, and those must never phantom-dirty the form. Reads
|
|
1187
|
+
* array `items`, so a reactive caller subscribes to structural changes.
|
|
1188
|
+
* Never short-circuits — this runs inside the `isDirty` aggregate
|
|
1189
|
+
* computed, and an unread branch would deafen the projection.
|
|
1190
|
+
*/
|
|
1191
|
+
function hasDirtySlot(state, store) {
|
|
1192
|
+
if (store.kind === "value") return state.get(store)?.isDirty.value ?? false;
|
|
1193
|
+
let dirty = false;
|
|
1194
|
+
if (store.kind === "array") {
|
|
1195
|
+
const length = store.items.value.length;
|
|
1196
|
+
for (let index = 0; index < length; index++) {
|
|
1197
|
+
const child = store.children[index];
|
|
1198
|
+
if (child && hasDirtySlot(state, child)) dirty = true;
|
|
1199
|
+
}
|
|
1200
|
+
return dirty;
|
|
1201
|
+
}
|
|
1202
|
+
for (const key in store.children) if (hasDirtySlot(state, store.children[key])) dirty = true;
|
|
1203
|
+
return dirty;
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* The envelopes plugin: owns the meta half of estimate and
|
|
1207
|
+
* amount-or-percent fields — mode/entry state decoded from the kind
|
|
1208
|
+
* envelope, dirty-tracked, and serialized by wrapping the field's own
|
|
1209
|
+
* payload entry. No factory arguments: the envelope rides the field key,
|
|
1210
|
+
* so every scope's raw value already carries the meta half (no envelope
|
|
1211
|
+
* side-channel to decode).
|
|
1212
|
+
*/
|
|
1213
|
+
function envelopes() {
|
|
1214
|
+
return {
|
|
1215
|
+
name: "envelopes",
|
|
1216
|
+
key: envelopesKey,
|
|
1217
|
+
wire: envelopesWire,
|
|
1218
|
+
build: () => /* @__PURE__ */ new Map(),
|
|
1219
|
+
buildScope(ctx, scope, raw) {
|
|
1220
|
+
buildScopeSlots(ctx.form, ctx.state, scope, raw);
|
|
1221
|
+
},
|
|
1222
|
+
reseedScope(ctx, scope, raw) {
|
|
1223
|
+
buildScopeSlots(ctx.form, ctx.state, scope, raw);
|
|
1224
|
+
},
|
|
1225
|
+
resetField(ctx, store) {
|
|
1226
|
+
if (store.kind !== "value") return;
|
|
1227
|
+
const slot = ctx.state.get(store);
|
|
1228
|
+
if (!slot) return;
|
|
1229
|
+
if (slot.family === "estimate") writeEnvelope(ctx.form, store, slot, {
|
|
1230
|
+
...slot.startEnvelope.value,
|
|
1231
|
+
value: store.input.value
|
|
1232
|
+
});
|
|
1233
|
+
else writeEnvelope(ctx.form, store, slot, {
|
|
1234
|
+
...slot.startEnvelope.value,
|
|
1235
|
+
value: store.input.value
|
|
1236
|
+
});
|
|
1237
|
+
},
|
|
1238
|
+
rebase(ctx, scope, raw) {
|
|
1239
|
+
rebaseScopeSlots(ctx.form, ctx.state, scope, raw);
|
|
1240
|
+
},
|
|
1241
|
+
syncInput(ctx, store, input) {
|
|
1242
|
+
if (store.kind !== "value") return false;
|
|
1243
|
+
const slot = ctx.state.get(store);
|
|
1244
|
+
if (!slot) return false;
|
|
1245
|
+
if (slot.family === "estimate") syncEstimateInput(ctx.form, store, slot, input);
|
|
1246
|
+
else syncAmountOrPercentInput(ctx.form, store, slot, input);
|
|
1247
|
+
return true;
|
|
1248
|
+
},
|
|
1249
|
+
syncInitial(ctx, store, raw) {
|
|
1250
|
+
if (store.kind !== "value") return;
|
|
1251
|
+
const slot = ctx.state.get(store);
|
|
1252
|
+
if (!slot) return;
|
|
1253
|
+
if (slot.family === "estimate") slot.startEnvelope.value = decodeEstimateEnvelope(ctx.form, store, raw);
|
|
1254
|
+
else slot.startEnvelope.value = decodeAmountOrPercentEnvelope(ctx.form, store, raw);
|
|
1255
|
+
},
|
|
1256
|
+
transferField(ctx, from, to) {
|
|
1257
|
+
const source = ctx.state.get(from);
|
|
1258
|
+
const target = ctx.state.get(to);
|
|
1259
|
+
if (!source || !target || source.family !== target.family) return;
|
|
1260
|
+
if (source.family === "estimate" && target.family === "estimate") {
|
|
1261
|
+
target.startEnvelope.value = source.startEnvelope.value;
|
|
1262
|
+
target.envelope.value = source.envelope.value;
|
|
1263
|
+
} else if (source.family === "amount-or-percent" && target.family === "amount-or-percent") {
|
|
1264
|
+
target.startEnvelope.value = source.startEnvelope.value;
|
|
1265
|
+
target.envelope.value = source.envelope.value;
|
|
1266
|
+
}
|
|
1267
|
+
},
|
|
1268
|
+
swapField(ctx, first, second) {
|
|
1269
|
+
const a = ctx.state.get(first);
|
|
1270
|
+
const b = ctx.state.get(second);
|
|
1271
|
+
if (!a || !b || a.family !== b.family) return;
|
|
1272
|
+
if (a.family === "estimate" && b.family === "estimate") {
|
|
1273
|
+
swapSignals(a.startEnvelope, b.startEnvelope);
|
|
1274
|
+
swapSignals(a.envelope, b.envelope);
|
|
1275
|
+
} else if (a.family === "amount-or-percent" && b.family === "amount-or-percent") {
|
|
1276
|
+
swapSignals(a.startEnvelope, b.startEnvelope);
|
|
1277
|
+
swapSignals(a.envelope, b.envelope);
|
|
1278
|
+
}
|
|
1279
|
+
},
|
|
1280
|
+
fieldIsDirty(ctx, store) {
|
|
1281
|
+
return ctx.state.get(store)?.isDirty.value ?? false;
|
|
1282
|
+
},
|
|
1283
|
+
encodeValue(ctx, store, valueOut) {
|
|
1284
|
+
const slot = ctx.state.get(store);
|
|
1285
|
+
if (!slot) return void 0;
|
|
1286
|
+
if (!slot.isDirty.value && valueOut === void 0) return void 0;
|
|
1287
|
+
if (slot.family === "estimate") {
|
|
1288
|
+
if (!slot.isDirty.value) {
|
|
1289
|
+
const persisted = slot.startEnvelope.value.mode;
|
|
1290
|
+
if (persisted === void 0) return void 0;
|
|
1291
|
+
const start = slot.startEnvelope.value;
|
|
1292
|
+
const meta$1 = {
|
|
1293
|
+
mode: persisted,
|
|
1294
|
+
manualValue: start.manualValue ?? null
|
|
1295
|
+
};
|
|
1296
|
+
if (start.lastFlippedAt !== void 0) meta$1.lastFlippedAt = start.lastFlippedAt;
|
|
1297
|
+
return persisted === "estimate" ? wrapEstimate(valueOut, meta$1) : wrapEstimate(void 0, meta$1);
|
|
1298
|
+
}
|
|
1299
|
+
const meta = encodeEstimateMeta(store, slot);
|
|
1300
|
+
return resolveEstimateMode(slot.envelope.value, store.schema) === "estimate" ? wrapEstimate(valueOut !== void 0 ? valueOut : store.input.value, meta) : wrapEstimate(void 0, meta);
|
|
1301
|
+
}
|
|
1302
|
+
return wrapAmountOrPercent(valueOut !== void 0 ? valueOut : store.input.value, {
|
|
1303
|
+
mode: slot.entryMode.value,
|
|
1304
|
+
basis: slot.percentBasis.value ?? ""
|
|
1305
|
+
});
|
|
1306
|
+
},
|
|
1307
|
+
isDirty(ctx) {
|
|
1308
|
+
return hasDirtySlot(ctx.state, ctx.form);
|
|
1309
|
+
},
|
|
1310
|
+
fieldSnapshot(ctx, store, path) {
|
|
1311
|
+
const slot = ctx.state.get(store);
|
|
1312
|
+
if (!slot) return {};
|
|
1313
|
+
if (slot.family === "estimate") {
|
|
1314
|
+
slot.callbacks ??= { setMode: (mode) => setMode(ctx.form, path, mode) };
|
|
1315
|
+
return {
|
|
1316
|
+
mode: slot.mode.value,
|
|
1317
|
+
setMode: slot.callbacks.setMode
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
slot.callbacks ??= {
|
|
1321
|
+
setEntryMode: (mode) => setEntryMode(ctx.form, path, mode),
|
|
1322
|
+
setPercentBasis: (percentBasis) => setPercentBasis(ctx.form, path, percentBasis)
|
|
1323
|
+
};
|
|
1324
|
+
return {
|
|
1325
|
+
entryMode: slot.entryMode.value,
|
|
1326
|
+
percentBasis: slot.percentBasis.value,
|
|
1327
|
+
setEntryMode: slot.callbacks.setEntryMode,
|
|
1328
|
+
setPercentBasis: slot.callbacks.setPercentBasis
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
function swapSignals(first, second) {
|
|
1334
|
+
const value = first.value;
|
|
1335
|
+
first.value = second.value;
|
|
1336
|
+
second.value = value;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
//#endregion
|
|
1340
|
+
//#region src/plugins/derivation/plugin.ts
|
|
1341
|
+
/**
|
|
1342
|
+
* Key for a directed dep-graph edge (`from` reads `to`).
|
|
1343
|
+
*/
|
|
1344
|
+
function edgeKey(from, to) {
|
|
1345
|
+
return `${from}\u0000${to}`;
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Extracts a human-readable message from a thrown evaluation error.
|
|
1349
|
+
*/
|
|
1350
|
+
function messageOf(error) {
|
|
1351
|
+
return error instanceof Error && error.message ? error.message : String(error);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* The derivation plugin: parses every `x-formula` once per scope (the
|
|
1355
|
+
* document root and each array row — it is scope, not machinery, that
|
|
1356
|
+
* differs; LOS-596), breaks dependency cycles deterministically, and gives
|
|
1357
|
+
* each formula field its `formulaValue`/`derived` slot plus the composed
|
|
1358
|
+
* `errors` channel.
|
|
1359
|
+
*
|
|
1360
|
+
* Scope resolution lives HERE and only here (the LOS-514 closure — no
|
|
1361
|
+
* other read path exists): per dependency, the form value wins (a formula
|
|
1362
|
+
* dep resolves through its own derived signal, chaining fresh values),
|
|
1363
|
+
* `offFormValues` fills only `undefined`, and when both sides are arrays
|
|
1364
|
+
* the canonical rows merge with the live form rows (the collection
|
|
1365
|
+
* overlay for aggregate refs).
|
|
1366
|
+
*
|
|
1367
|
+
* Declares `dependsOn: [envelopesKey]` — the estimate pin reads the mode
|
|
1368
|
+
* signal the envelopes plugin creates; without it the pin would silently
|
|
1369
|
+
* never engage and a manually pinned value would be overwritten by the
|
|
1370
|
+
* next recompute (the D2 data-loss scenario), so a missing envelopes
|
|
1371
|
+
* plugin is a startup error instead.
|
|
1372
|
+
*/
|
|
1373
|
+
function derivation(engine) {
|
|
1374
|
+
return {
|
|
1375
|
+
name: "derivation",
|
|
1376
|
+
key: derivationKey,
|
|
1377
|
+
dependsOn: [envelopesKey],
|
|
1378
|
+
build: () => /* @__PURE__ */ new Map(),
|
|
1379
|
+
buildScope(ctx, scope) {
|
|
1380
|
+
const form = ctx.form;
|
|
1381
|
+
if (scope === form) {
|
|
1382
|
+
wireFormulaGraph(form, ctx.state, engine, scope.children, (dep, formValue) => {
|
|
1383
|
+
const offValue = readOwn(form.offFormValues.value, dep);
|
|
1384
|
+
if (Array.isArray(offValue) && Array.isArray(formValue)) return mergeCollectionRows(offValue, formValue);
|
|
1385
|
+
return formValue === void 0 ? offValue : formValue;
|
|
1386
|
+
});
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
wireFormulaGraph(form, ctx.state, engine, scope.children, (dep, formValue) => resolveRowFallback(form, scope, dep, formValue));
|
|
1390
|
+
},
|
|
1391
|
+
fieldSnapshot(ctx, store) {
|
|
1392
|
+
const slot = ctx.state.get(store);
|
|
1393
|
+
if (!slot) return {};
|
|
1394
|
+
return {
|
|
1395
|
+
derived: slot.derived.value,
|
|
1396
|
+
formulaValue: slot.formulaValue.value
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Wires the formula fields of ONE scope (the document root or a single
|
|
1403
|
+
* array row): collects the scope's parseable `x-formula` fields, breaks
|
|
1404
|
+
* cycles among them, and gives each one its slot. Everything
|
|
1405
|
+
* scope-specific enters through `resolveOutside`.
|
|
1406
|
+
*/
|
|
1407
|
+
function wireFormulaGraph(form, state, engine, children, resolveOutside) {
|
|
1408
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1409
|
+
for (const key of Object.keys(children)) {
|
|
1410
|
+
const child = children[key];
|
|
1411
|
+
if (child.kind !== "value") continue;
|
|
1412
|
+
if (child.control !== "formula" && child.control !== "estimate") continue;
|
|
1413
|
+
if (child.schema["x-server-maintained"] === true) {
|
|
1414
|
+
const passthrough = computed(() => ({
|
|
1415
|
+
value: getFieldInput(child),
|
|
1416
|
+
error: null
|
|
1417
|
+
}));
|
|
1418
|
+
state.set(child, {
|
|
1419
|
+
derived: passthrough,
|
|
1420
|
+
formulaValue: passthrough,
|
|
1421
|
+
isRollup: false
|
|
1422
|
+
});
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const formula = child.schema["x-formula"];
|
|
1426
|
+
if (typeof formula !== "string" || formula.trim() === "") continue;
|
|
1427
|
+
const parsed = engine.parse(formula);
|
|
1428
|
+
entries.set(key, {
|
|
1429
|
+
store: child,
|
|
1430
|
+
node: parsed.ok ? parsed.node : void 0,
|
|
1431
|
+
deps: parsed.ok ? engine.extractDependencies(parsed.node) : [],
|
|
1432
|
+
parseError: parsed.ok ? null : parsed.error
|
|
1433
|
+
});
|
|
1434
|
+
}
|
|
1435
|
+
if (entries.size === 0) return;
|
|
1436
|
+
const brokenEdges = /* @__PURE__ */ new Set();
|
|
1437
|
+
const cycleErrors = /* @__PURE__ */ new Map();
|
|
1438
|
+
const dfsState = /* @__PURE__ */ new Map();
|
|
1439
|
+
const visit = (key) => {
|
|
1440
|
+
dfsState.set(key, "visiting");
|
|
1441
|
+
for (const dep of entries.get(key).deps) {
|
|
1442
|
+
if (!entries.has(dep)) continue;
|
|
1443
|
+
if (dep === key || dfsState.get(dep) === "visiting") {
|
|
1444
|
+
brokenEdges.add(edgeKey(key, dep));
|
|
1445
|
+
cycleErrors.set(key, `Circular reference: "${key}" reads "${dep}"`);
|
|
1446
|
+
continue;
|
|
1447
|
+
}
|
|
1448
|
+
if (dfsState.get(dep) !== "done") visit(dep);
|
|
1449
|
+
}
|
|
1450
|
+
dfsState.set(key, "done");
|
|
1451
|
+
};
|
|
1452
|
+
for (const key of entries.keys()) if (!dfsState.has(key)) visit(key);
|
|
1453
|
+
const resolveDep = (fromKey, dep) => {
|
|
1454
|
+
const child = children[dep];
|
|
1455
|
+
let formValue;
|
|
1456
|
+
if (child) {
|
|
1457
|
+
const depEntry = child.kind === "value" ? entries.get(dep) : void 0;
|
|
1458
|
+
if (depEntry && depEntry.store === child && !brokenEdges.has(edgeKey(fromKey, dep))) {
|
|
1459
|
+
const depState = state.get(child).derived.value;
|
|
1460
|
+
if (depState.error !== null) throw new Error(`Upstream formula error: "${dep}"`);
|
|
1461
|
+
formValue = depState.value;
|
|
1462
|
+
} else formValue = getFieldInput(child);
|
|
1463
|
+
}
|
|
1464
|
+
return resolveOutside(dep, formValue);
|
|
1465
|
+
};
|
|
1466
|
+
for (const [key, entry] of entries) {
|
|
1467
|
+
const { store } = entry;
|
|
1468
|
+
const isRollup = entry.parseError === null && (engine.extractPathRefs?.(entry.node)?.length ?? 0) > 0;
|
|
1469
|
+
const cycleError = cycleErrors.get(key) ?? null;
|
|
1470
|
+
const formulaValue = computed(() => {
|
|
1471
|
+
if (entry.parseError !== null) return {
|
|
1472
|
+
value: void 0,
|
|
1473
|
+
error: entry.parseError
|
|
1474
|
+
};
|
|
1475
|
+
try {
|
|
1476
|
+
const scope = Object.create(null);
|
|
1477
|
+
for (const dep of entry.deps) scope[dep] = resolveDep(key, dep);
|
|
1478
|
+
return {
|
|
1479
|
+
value: engine.evaluate(entry.node, scope),
|
|
1480
|
+
error: cycleError
|
|
1481
|
+
};
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
return {
|
|
1484
|
+
value: void 0,
|
|
1485
|
+
error: cycleError ?? messageOf(error)
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
const derived = store.control === "estimate" ? computed(() => {
|
|
1490
|
+
const envelope = envelopesKey.get(form, store);
|
|
1491
|
+
return envelope?.family === "estimate" && envelope.mode.value === "estimate" && !isEmptyish(store.input.value) ? {
|
|
1492
|
+
value: store.input.value,
|
|
1493
|
+
error: null
|
|
1494
|
+
} : formulaValue.value;
|
|
1495
|
+
}) : formulaValue;
|
|
1496
|
+
state.set(store, {
|
|
1497
|
+
derived,
|
|
1498
|
+
formulaValue,
|
|
1499
|
+
isRollup
|
|
1500
|
+
});
|
|
1501
|
+
store.errors = computed(() => {
|
|
1502
|
+
const validation = store.validationErrors.value;
|
|
1503
|
+
const calc = derived.value.error;
|
|
1504
|
+
if (calc === null) return validation;
|
|
1505
|
+
return validation ? [...validation, calc] : [calc];
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/plugins/derivation/wire.ts
|
|
1512
|
+
/**
|
|
1513
|
+
* The derivation plugin's STATIC wire contract: a `formula` value is
|
|
1514
|
+
* ALWAYS server-recomputed — a client payload only carries a stale echo of
|
|
1515
|
+
* the last-rendered result, so `encodeDirty` drops it. (The estimate-pin
|
|
1516
|
+
* policy lives on `envelopesWire.encode` — the envelope owns it.)
|
|
1517
|
+
*/
|
|
1518
|
+
const derivationWire = { skipValue: (control) => control === "formula" };
|
|
1519
|
+
|
|
1520
|
+
//#endregion
|
|
1521
|
+
export { DEFAULT_EMPTY_INPUT, DEFAULT_ROOT_RECORD_ALIAS, UNEVALUABLE_MESSAGE_ID, applyBaseline, bagger, checks, collectionKeys, computeBag, createFormStore, decodeRecord, derivation, derivationWire, encodeDirty, envelopeContracts, envelopes, envelopesWire, flattenSourceRow, focus, formulaCheck, getDeepErrorEntries, getDeepErrors, getDirtyInput, getDirtyPaths, getErrors, getInput, handleSubmit, inferControl, insert, isEmptyish, isEnvelope, isPresenceEqual, isSemanticEqual, mergeCollectionRows, move, pickDirty, readRelationConfig, relationParentFieldName, relationRowIdentityProps, remove, replaceCheckInstances, reset, resolveScopeValue, resolveScopeValueAt, setEntryMode, setErrors, setInput, setMode, setOffFormValues, setPercentBasis, swap, targetToKind, validate, visibility, withRelationRowIdentity, wrapAmountOrPercent, wrapEstimate };
|