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
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
import { B as getFieldBool, D as fieldPluginDirty, E as encodeFieldValue, N as computed, O as hasPluginDirtyField, P as createSignal, _ as resolveValueInput, d as DEFAULT_ROOT_RECORD_ALIAS, g as readOwn, h as isSafeKey, l as isSemanticEqual, r as getFieldInput, s as isEmptyish, t as internalOf } from "./form-ref-BEri3JKv.js";
|
|
2
|
+
|
|
3
|
+
//#region src/core/field/get-dirty-field-input.ts
|
|
4
|
+
/**
|
|
5
|
+
* Returns only the dirty input of the field store. Arrays are treated as
|
|
6
|
+
* atomic and returned in full if any item is dirty, while object keys
|
|
7
|
+
* without a dirty descendant are omitted. Returns `undefined` if no
|
|
8
|
+
* descendant is dirty.
|
|
9
|
+
*
|
|
10
|
+
* Plugin state serializes THROUGH the field's own entry (the LOS-573
|
|
11
|
+
* envelope): a dirty estimate/amount-or-percent leaf emits
|
|
12
|
+
* kind envelope in place of its bare value — a mode flip
|
|
13
|
+
* with an unchanged value still produces a payload. Inside an emitted
|
|
14
|
+
* array every envelope leaf is wrapped COMPLETE, dirty or not: rows
|
|
15
|
+
* persist wholesale, and a bare value would clobber the persisted meta
|
|
16
|
+
* half of its envelope.
|
|
17
|
+
*
|
|
18
|
+
* @param internalFormStore The form store (plugin state lives here).
|
|
19
|
+
* @param internalFieldStore The field store to get dirty input from.
|
|
20
|
+
*
|
|
21
|
+
* @returns The dirty input, or `undefined` if no descendant is dirty.
|
|
22
|
+
*/
|
|
23
|
+
function getDirtyFieldInput(internalFormStore, internalFieldStore) {
|
|
24
|
+
if (!getFieldBool(internalFieldStore, "isDirty") && !hasPluginDirtyField(internalFormStore, internalFieldStore)) return;
|
|
25
|
+
if (internalFieldStore.kind === "array") return encodeScopeValues(internalFormStore, internalFieldStore, getFieldInput(internalFieldStore));
|
|
26
|
+
if (internalFieldStore.kind === "object") {
|
|
27
|
+
if (internalFieldStore.input.value) {
|
|
28
|
+
const value = {};
|
|
29
|
+
for (const key in internalFieldStore.children) {
|
|
30
|
+
const child = internalFieldStore.children[key];
|
|
31
|
+
if (child.kind === "value") {
|
|
32
|
+
const valueDirty = getFieldBool(child, "isDirty");
|
|
33
|
+
const pluginDirty = fieldPluginDirty(internalFormStore, child);
|
|
34
|
+
if (!valueDirty && !pluginDirty) continue;
|
|
35
|
+
const encoded = encodeFieldValue(internalFormStore, child, valueDirty ? child.input.value : void 0);
|
|
36
|
+
if (valueDirty || encoded !== void 0) value[key] = encoded;
|
|
37
|
+
} else if (getFieldBool(child, "isDirty") || hasPluginDirtyField(internalFormStore, child)) value[key] = getDirtyFieldInput(internalFormStore, child);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return internalFieldStore.input.value;
|
|
42
|
+
}
|
|
43
|
+
return encodeFieldValue(internalFormStore, internalFieldStore, internalFieldStore.input.value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wraps the envelope leaves of an emitted subtree value: rows and nested
|
|
47
|
+
* objects keep their shape, but every value leaf claimed by a plugin's
|
|
48
|
+
* `encodeValue` (estimate/amount-or-percent) is replaced with its COMPLETE
|
|
49
|
+
* envelope — the whole-array emission convention. The supplied value is
|
|
50
|
+
* never mutated.
|
|
51
|
+
*/
|
|
52
|
+
function encodeScopeValues(internalFormStore, internalFieldStore, value) {
|
|
53
|
+
if (!internalFieldStore) return value;
|
|
54
|
+
if (internalFieldStore.kind === "array" && Array.isArray(value)) return value.map((item, index) => encodeScopeValues(internalFormStore, internalFieldStore.children[index], item));
|
|
55
|
+
if (internalFieldStore.kind === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
56
|
+
const row = { ...value };
|
|
57
|
+
for (const key in internalFieldStore.children) {
|
|
58
|
+
const child = internalFieldStore.children[key];
|
|
59
|
+
if (child.kind === "value") {
|
|
60
|
+
if (Object.prototype.hasOwnProperty.call(row, key) || fieldPluginDirty(internalFormStore, child)) {
|
|
61
|
+
const encoded = encodeFieldValue(internalFormStore, child, readOwn(row, key));
|
|
62
|
+
if (encoded !== void 0) row[key] = encoded;
|
|
63
|
+
}
|
|
64
|
+
} else if (Object.prototype.hasOwnProperty.call(row, key)) row[key] = encodeScopeValues(internalFormStore, child, row[key]);
|
|
65
|
+
}
|
|
66
|
+
return row;
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/plugins/envelopes/wire.ts
|
|
73
|
+
/**
|
|
74
|
+
* Returns whether a raw persisted entry is an envelope: an object whose
|
|
75
|
+
* `kind` is `estimate` or `amount-or-percent`. Anything else — including
|
|
76
|
+
* the bare scalars every non-envelope field persists — is a bare value.
|
|
77
|
+
*/
|
|
78
|
+
function isEnvelope(raw) {
|
|
79
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
80
|
+
const kind = raw.kind;
|
|
81
|
+
return kind === "estimate" || kind === "amount-or-percent";
|
|
82
|
+
}
|
|
83
|
+
/** Meta keys only — `kind` and `value` are the envelope's own halves. */
|
|
84
|
+
function metaOf(raw) {
|
|
85
|
+
const meta = {};
|
|
86
|
+
for (const key of Object.keys(raw)) {
|
|
87
|
+
if (key === "kind" || key === "value") continue;
|
|
88
|
+
meta[key] = raw[key];
|
|
89
|
+
}
|
|
90
|
+
return meta;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Wraps an estimate field's halves into its envelope. The value key is
|
|
94
|
+
* OMITTED when `value` is `undefined` (formula mode ships
|
|
95
|
+
* `{ kind: "estimate", mode: "formula" }` and lets the server recompute
|
|
96
|
+
* author the value half).
|
|
97
|
+
*/
|
|
98
|
+
function wrapEstimate(value, meta) {
|
|
99
|
+
const envelope = {
|
|
100
|
+
kind: "estimate",
|
|
101
|
+
...meta
|
|
102
|
+
};
|
|
103
|
+
if (value !== void 0) envelope.value = value;
|
|
104
|
+
return envelope;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Wraps an amount-or-percent field's halves into its envelope. Always
|
|
108
|
+
* complete — an envelope is one bag key, so a partial write would clobber
|
|
109
|
+
* the persisted other half.
|
|
110
|
+
*/
|
|
111
|
+
function wrapAmountOrPercent(value, meta) {
|
|
112
|
+
return {
|
|
113
|
+
kind: "amount-or-percent",
|
|
114
|
+
value,
|
|
115
|
+
...meta
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The envelopes plugin's STATIC wire contract — isomorphic by
|
|
120
|
+
* construction: the server imports this same object for save routing
|
|
121
|
+
* (`encodeDirty`), the recompute pass, and engine-less readers
|
|
122
|
+
* (changelog, list pages), with no form store anywhere (D7).
|
|
123
|
+
*
|
|
124
|
+
* Envelope shape: estimate fields persist
|
|
125
|
+
* `{ kind: "estimate", value?, mode, manualValue?, lastFlippedAt? }`,
|
|
126
|
+
* amount-or-percent fields
|
|
127
|
+
* `{ kind: "amount-or-percent", value, mode, basis? }`.
|
|
128
|
+
* Wire `mode` uses settled names (`estimate`/`formula`, `amount`/`percent`).
|
|
129
|
+
* Only these two controls grow the envelope; scalars stay bare.
|
|
130
|
+
*/
|
|
131
|
+
const envelopesWire = {
|
|
132
|
+
envelopeControls: ["estimate", "amount-or-percent"],
|
|
133
|
+
unwrap(raw) {
|
|
134
|
+
if (!isEnvelope(raw)) return {
|
|
135
|
+
value: raw,
|
|
136
|
+
meta: {}
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
value: readOwn(raw, "value"),
|
|
140
|
+
meta: metaOf(raw)
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
encode(control, raw) {
|
|
144
|
+
if (control !== "estimate") return raw;
|
|
145
|
+
if (!isEnvelope(raw)) return void 0;
|
|
146
|
+
const meta = metaOf(raw);
|
|
147
|
+
if (meta.mode === "estimate") return raw;
|
|
148
|
+
return wrapEstimate(void 0, meta);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/plugins/envelopes/envelope.ts
|
|
154
|
+
/**
|
|
155
|
+
* Schema-declared opening mode for an unpinned estimate field.
|
|
156
|
+
* `"formula"` → formula-first; `"estimate"` / missing → manual-first.
|
|
157
|
+
*/
|
|
158
|
+
function schemaDefaultEstimateMode(schema) {
|
|
159
|
+
return schema?.["x-estimate-default-mode"] === "formula" ? "formula" : "estimate";
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolves an estimate field's live mode:
|
|
163
|
+
* - an explicit `formula` / `estimate` pin always wins
|
|
164
|
+
* - no pin + empty value + schema default `formula` → `formula` (LOS-823)
|
|
165
|
+
* - otherwise → `estimate` (manual-first, LOS-461: a stored unpinned
|
|
166
|
+
* value stays typeable so a schema default cannot clobber it)
|
|
167
|
+
*/
|
|
168
|
+
function resolveEstimateMode(meta, schema) {
|
|
169
|
+
if (meta.mode === "formula" || meta.mode === "estimate") return meta.mode;
|
|
170
|
+
if (schemaDefaultEstimateMode(schema) === "formula" && isEmptyish(meta.value)) return "formula";
|
|
171
|
+
return "estimate";
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Schema-declared opening unit for an unpinned amount-or-percent field.
|
|
175
|
+
* Missing / anything other than `"percent"` → `amount` (amount-first).
|
|
176
|
+
*/
|
|
177
|
+
function schemaDefaultEntryMode(schema) {
|
|
178
|
+
return schema?.["x-hybrid-default-mode"] === "percent" ? "percent" : "amount";
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolves persisted amount-or-percent entry state:
|
|
182
|
+
* - an explicit `percent` / `amount` pin always wins
|
|
183
|
+
* - no pin + empty value + schema default `percent` → `percent` (LOS-824)
|
|
184
|
+
* - otherwise → `amount` (amount-first: a stored unpinned value stays
|
|
185
|
+
* dollars so imported/legacy amounts are not treated as percent-owned)
|
|
186
|
+
* Percent basis falls back to the schema's declared default denominator.
|
|
187
|
+
*/
|
|
188
|
+
function resolveAmountOrPercentEntry(meta, schema, value) {
|
|
189
|
+
const schemaDefault = schema["x-hybrid-default-denominator"];
|
|
190
|
+
return {
|
|
191
|
+
entryMode: meta.mode === "percent" || meta.mode === "amount" ? meta.mode : schemaDefaultEntryMode(schema) === "percent" && isEmptyish(value) ? "percent" : "amount",
|
|
192
|
+
percentBasis: typeof meta.basis === "string" ? meta.basis : typeof schemaDefault === "string" && schemaDefault !== "" ? schemaDefault : void 0
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function resolveEnvelopeValue(form, store, value) {
|
|
196
|
+
return resolveValueInput(form.emptyInput, store.schema, store.isNullish, value);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Decodes one estimate field's raw (kind envelope or bare scalar) into
|
|
200
|
+
* the in-memory envelope. `mode` is omitted when the wire never pinned
|
|
201
|
+
* one — encode must not fabricate `{ mode: "estimate" }` for a virgin
|
|
202
|
+
* field.
|
|
203
|
+
*/
|
|
204
|
+
function decodeEstimateEnvelope(form, store, raw) {
|
|
205
|
+
const { value, meta } = envelopesWire.unwrap(raw);
|
|
206
|
+
const estimateMeta = meta;
|
|
207
|
+
const envelope = {
|
|
208
|
+
kind: "estimate",
|
|
209
|
+
value: resolveEnvelopeValue(form, store, value)
|
|
210
|
+
};
|
|
211
|
+
if (estimateMeta.mode === "formula" || estimateMeta.mode === "estimate") return {
|
|
212
|
+
...envelope,
|
|
213
|
+
mode: estimateMeta.mode,
|
|
214
|
+
manualValue: estimateMeta.manualValue ?? null,
|
|
215
|
+
...estimateMeta.lastFlippedAt !== void 0 ? { lastFlippedAt: estimateMeta.lastFlippedAt } : {}
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
...envelope,
|
|
219
|
+
manualValue: estimateMeta.manualValue ?? null,
|
|
220
|
+
...estimateMeta.lastFlippedAt !== void 0 ? { lastFlippedAt: estimateMeta.lastFlippedAt } : {}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Decodes one amount-or-percent field's raw into the in-memory envelope
|
|
225
|
+
* with entry state already resolved against the schema default.
|
|
226
|
+
*/
|
|
227
|
+
function decodeAmountOrPercentEnvelope(form, store, raw) {
|
|
228
|
+
const { value, meta } = envelopesWire.unwrap(raw);
|
|
229
|
+
const { entryMode, percentBasis } = resolveAmountOrPercentEntry(meta, store.schema, value);
|
|
230
|
+
return {
|
|
231
|
+
kind: "amount-or-percent",
|
|
232
|
+
value: resolveEnvelopeValue(form, store, value),
|
|
233
|
+
mode: entryMode,
|
|
234
|
+
...percentBasis !== void 0 ? { basis: percentBasis } : {}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function writeEnvelope(form, store, slot, next) {
|
|
238
|
+
const resolved = resolveEnvelopeValue(form, store, next.value);
|
|
239
|
+
if (slot.family === "estimate") slot.envelope.value = {
|
|
240
|
+
...next,
|
|
241
|
+
value: resolved
|
|
242
|
+
};
|
|
243
|
+
else slot.envelope.value = {
|
|
244
|
+
...next,
|
|
245
|
+
value: resolved
|
|
246
|
+
};
|
|
247
|
+
store.input.value = resolved;
|
|
248
|
+
store.isDirty.value = !isSemanticEqual(resolved, store.startInput.value);
|
|
249
|
+
}
|
|
250
|
+
function adoptEnvelope(live, start, incoming, schema) {
|
|
251
|
+
if (incoming.kind === "estimate") return adoptEstimate(live, start, incoming, schema);
|
|
252
|
+
return adoptAmountOrPercent(live, start, incoming);
|
|
253
|
+
}
|
|
254
|
+
function adoptEstimate(live, start, incoming, schema) {
|
|
255
|
+
const modeDirty = resolveEstimateMode(live, schema) !== resolveEstimateMode(start, schema);
|
|
256
|
+
const valueDirty = !isSemanticEqual(live.value, start.value);
|
|
257
|
+
return {
|
|
258
|
+
start: incoming,
|
|
259
|
+
live: {
|
|
260
|
+
...incoming,
|
|
261
|
+
...modeDirty ? {
|
|
262
|
+
mode: live.mode,
|
|
263
|
+
lastFlippedAt: live.lastFlippedAt,
|
|
264
|
+
manualValue: live.manualValue
|
|
265
|
+
} : {},
|
|
266
|
+
...valueDirty ? {
|
|
267
|
+
value: live.value,
|
|
268
|
+
...resolveEstimateMode(live, schema) === "estimate" ? { manualValue: live.manualValue } : {}
|
|
269
|
+
} : {}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function adoptAmountOrPercent(live, start, incoming) {
|
|
274
|
+
return {
|
|
275
|
+
start: incoming,
|
|
276
|
+
live: {
|
|
277
|
+
...incoming,
|
|
278
|
+
...live.mode !== start.mode ? { mode: live.mode } : {},
|
|
279
|
+
...live.basis !== start.basis ? { basis: live.basis } : {},
|
|
280
|
+
...!isSemanticEqual(live.value, start.value) ? { value: live.value } : {}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function bindAdopted(form, store, slot, adopted) {
|
|
285
|
+
if (slot.family === "estimate") {
|
|
286
|
+
slot.startEnvelope.value = adopted.start;
|
|
287
|
+
store.startInput.value = resolveEnvelopeValue(form, store, adopted.start.value);
|
|
288
|
+
writeEnvelope(form, store, slot, adopted.live);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
slot.startEnvelope.value = adopted.start;
|
|
292
|
+
store.startInput.value = resolveEnvelopeValue(form, store, adopted.start.value);
|
|
293
|
+
writeEnvelope(form, store, slot, adopted.live);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Estimate keystroke: value (and, in estimate mode, `manualValue`) land
|
|
297
|
+
* on the same envelope as the number.
|
|
298
|
+
*/
|
|
299
|
+
function syncEstimateInput(form, store, slot, input) {
|
|
300
|
+
const live = slot.envelope.value;
|
|
301
|
+
writeEnvelope(form, store, slot, {
|
|
302
|
+
...live,
|
|
303
|
+
value: input,
|
|
304
|
+
...resolveEstimateMode(live, store.schema) === "estimate" ? { manualValue: isEmptyish(input) ? null : input } : {}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Amount-or-percent keystroke: value half only — entry state is untouched.
|
|
309
|
+
*/
|
|
310
|
+
function syncAmountOrPercentInput(form, store, slot, input) {
|
|
311
|
+
writeEnvelope(form, store, slot, {
|
|
312
|
+
...slot.envelope.value,
|
|
313
|
+
value: input
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/core/plugin/key.ts
|
|
319
|
+
/**
|
|
320
|
+
* The identity a plugin's state is stored under in the form store's
|
|
321
|
+
* `pluginState` map (the ProseMirror pattern, minus its string-name
|
|
322
|
+
* registry). The generic is a phantom — it exists only so `getState`
|
|
323
|
+
* returns the right type; nothing is stored on the key itself.
|
|
324
|
+
*
|
|
325
|
+
* Keys are module-level singletons exported next to their plugin factory.
|
|
326
|
+
* Cross-plugin reads go through the exported key (derivation imports
|
|
327
|
+
* `envelopesKey`, never the envelopes implementation), and stay
|
|
328
|
+
* `T | undefined` — a form without the owning plugin is a legal config.
|
|
329
|
+
*/
|
|
330
|
+
var PluginKey = class {
|
|
331
|
+
constructor(name) {
|
|
332
|
+
this.name = name;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Reads the owning plugin's state off a form store, or `undefined` when
|
|
336
|
+
* the plugin is not registered on this form.
|
|
337
|
+
*/
|
|
338
|
+
getState(form) {
|
|
339
|
+
return form.pluginState.get(this);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* A plugin key whose state is a per-field map keyed by field-store
|
|
344
|
+
* IDENTITY. Field stores are position-fixed — array operations move signal
|
|
345
|
+
* VALUES between stores, not the stores themselves — so per-field slots
|
|
346
|
+
* must transfer with item state via the `transferField`/`swapField` hooks,
|
|
347
|
+
* exactly like the input signals they sit next to.
|
|
348
|
+
*/
|
|
349
|
+
var FieldSlotKey = class extends PluginKey {
|
|
350
|
+
/**
|
|
351
|
+
* Reads one field's slot, or `undefined` when the plugin is absent or the
|
|
352
|
+
* field carries no slot (a bare scalar to this plugin).
|
|
353
|
+
*/
|
|
354
|
+
get(form, store) {
|
|
355
|
+
return this.getState(form)?.get(store);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/plugins/envelopes/key.ts
|
|
361
|
+
/**
|
|
362
|
+
* The envelopes plugin's slot key. Other plugins read envelope state
|
|
363
|
+
* through this identity (the derivation plugin pins on `mode`), never
|
|
364
|
+
* through the envelopes implementation.
|
|
365
|
+
*/
|
|
366
|
+
const envelopesKey = new FieldSlotKey("envelopes");
|
|
367
|
+
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/plugins/derivation/key.ts
|
|
370
|
+
/**
|
|
371
|
+
* The derivation plugin's slot key. The scope resolvers and the react
|
|
372
|
+
* surface read derived state through this identity.
|
|
373
|
+
*/
|
|
374
|
+
const derivationKey = new FieldSlotKey("derivation");
|
|
375
|
+
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/core/derivation/merge-collection-rows.ts
|
|
378
|
+
/**
|
|
379
|
+
* Merge a collection's canonical rows (the read model — full server rows
|
|
380
|
+
* with calc fields pre-evaluated, delivered via `offFormValues`) with the
|
|
381
|
+
* live form rows (the write model the user edits) for formula evaluation.
|
|
382
|
+
*
|
|
383
|
+
* Membership comes from the LIVE rows, so rows added or removed during the
|
|
384
|
+
* form session are respected. Each live row is enriched from the canonical
|
|
385
|
+
* row with the same `id`: live keys win (an explicit `null` means the user
|
|
386
|
+
* cleared the field), canonical fills everything the live row doesn't
|
|
387
|
+
* carry — core columns the write model never holds.
|
|
388
|
+
*
|
|
389
|
+
* One home for collection overlay (app re-exports this). The merged result
|
|
390
|
+
* is for evaluation only; it is never written back into form state.
|
|
391
|
+
*/
|
|
392
|
+
function mergeCollectionRows(canonicalRows, liveRows) {
|
|
393
|
+
if (!Array.isArray(liveRows)) return [...canonicalRows];
|
|
394
|
+
const canonicalById = /* @__PURE__ */ new Map();
|
|
395
|
+
for (const row of canonicalRows) if (row && typeof row === "object" && "id" in row) canonicalById.set(row.id, row);
|
|
396
|
+
return liveRows.map((live) => {
|
|
397
|
+
if (!live || typeof live !== "object") return live;
|
|
398
|
+
const liveRow = live;
|
|
399
|
+
const canonical = canonicalById.get(liveRow.id);
|
|
400
|
+
if (!canonical) return liveRow;
|
|
401
|
+
const merged = { ...canonical };
|
|
402
|
+
for (const [key, value] of Object.entries(liveRow)) if (value !== void 0) merged[key] = value;
|
|
403
|
+
for (const key of Object.keys(merged)) if (merged[key] === void 0) delete merged[key];
|
|
404
|
+
return merged;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
//#endregion
|
|
409
|
+
//#region src/core/relation/relation-config.ts
|
|
410
|
+
const SCHEMA_REF_PREFIX = "schema://";
|
|
411
|
+
function refTarget(value) {
|
|
412
|
+
return typeof value === "string" && value.startsWith(SCHEMA_REF_PREFIX) ? value.slice(9) : void 0;
|
|
413
|
+
}
|
|
414
|
+
function str(value) {
|
|
415
|
+
return typeof value === "string" ? value : void 0;
|
|
416
|
+
}
|
|
417
|
+
function bool(value) {
|
|
418
|
+
return typeof value === "boolean" ? value : void 0;
|
|
419
|
+
}
|
|
420
|
+
function strArray(value) {
|
|
421
|
+
return Array.isArray(value) && value.every((s) => typeof s === "string") ? value : void 0;
|
|
422
|
+
}
|
|
423
|
+
function hasType(schema, type) {
|
|
424
|
+
return Array.isArray(schema.type) ? schema.type.includes(type) : schema.type === type;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Reads the widget extras (role, traits filter, …) that ride as sibling
|
|
428
|
+
* keys on both relation forms — `x-relation` namespace first, flat vendor
|
|
429
|
+
* keys as fallback.
|
|
430
|
+
*/
|
|
431
|
+
function readExtras(record) {
|
|
432
|
+
const ns = record["x-relation"];
|
|
433
|
+
if (ns && typeof ns === "object") {
|
|
434
|
+
const obj = ns;
|
|
435
|
+
return {
|
|
436
|
+
role: str(obj.role),
|
|
437
|
+
parent: str(obj.parent),
|
|
438
|
+
multiple: bool(obj.multiple),
|
|
439
|
+
traits: strArray(obj.traits),
|
|
440
|
+
useMockData: bool(obj.useMockData)
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
role: str(record["x-relation-role"]),
|
|
445
|
+
parent: str(record["x-relation-parent"]),
|
|
446
|
+
multiple: bool(record["x-relation-multiple"]),
|
|
447
|
+
traits: strArray(record["x-relation-traits"]),
|
|
448
|
+
useMockData: bool(record["x-use-mock-data"])
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Reads a node's relation config, or `undefined` for a plain enum
|
|
453
|
+
* select/multiselect. Two schema forms:
|
|
454
|
+
* 1. Canonical — `$ref: schema://<target>` (one) or array of that ref
|
|
455
|
+
* (many); widget extras ride as sibling keys.
|
|
456
|
+
* 2. Vendor — `x-relation` namespace or flat `x-relation-target`;
|
|
457
|
+
* cardinality follows the node's `type` unless `multiple` overrides.
|
|
458
|
+
*/
|
|
459
|
+
function readRelationConfig(schema) {
|
|
460
|
+
const record = schema;
|
|
461
|
+
const { multiple, ...extras } = readExtras(record);
|
|
462
|
+
const items = Array.isArray(schema.items) ? void 0 : schema.items;
|
|
463
|
+
const nestedItems = items && typeof items.properties === "object" ? { items } : {};
|
|
464
|
+
const singleTarget = refTarget(schema.$ref);
|
|
465
|
+
if (singleTarget) return {
|
|
466
|
+
target: singleTarget,
|
|
467
|
+
many: false,
|
|
468
|
+
...extras
|
|
469
|
+
};
|
|
470
|
+
const itemTarget = refTarget(items?.$ref);
|
|
471
|
+
if (hasType(schema, "array") && itemTarget) return {
|
|
472
|
+
target: itemTarget,
|
|
473
|
+
many: true,
|
|
474
|
+
...extras
|
|
475
|
+
};
|
|
476
|
+
const ns = record["x-relation"];
|
|
477
|
+
const target = ns && typeof ns === "object" ? str(ns.target) : str(record["x-relation-target"]);
|
|
478
|
+
if (!target) return void 0;
|
|
479
|
+
return {
|
|
480
|
+
target,
|
|
481
|
+
many: multiple ?? hasType(schema, "array"),
|
|
482
|
+
...extras,
|
|
483
|
+
...nestedItems
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Maps a relation target to the party autocomplete's kind hint. The widget
|
|
488
|
+
* only knows `entity` / `contact` / undefined (= search both); join-table
|
|
489
|
+
* targets (`participant`, `party`) stay open.
|
|
490
|
+
*/
|
|
491
|
+
function targetToKind(target) {
|
|
492
|
+
return target === "entity" || target === "contact" ? target : void 0;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The sibling field a contact picker is scoped to (`memberOfEntityId`).
|
|
496
|
+
*
|
|
497
|
+
* Explicit `x-relation-parent` always wins. When it is missing — the live
|
|
498
|
+
* artifact schema never carried the key — a contact relation whose traits
|
|
499
|
+
* include exactly one sibling entity relation's role is scoped to that
|
|
500
|
+
* sibling (LOS-860: titleOfficer traits `["title-company"]` → titleCompany).
|
|
501
|
+
* Two matches is ambiguous, so no parent is invented.
|
|
502
|
+
*/
|
|
503
|
+
function relationParentFieldName(schema, path, relation) {
|
|
504
|
+
if (relation.parent) return relation.parent;
|
|
505
|
+
if (targetToKind(relation.target) !== "contact") return void 0;
|
|
506
|
+
const traits = relation.traits ?? [];
|
|
507
|
+
if (traits.length === 0) return void 0;
|
|
508
|
+
const siblings = propertiesAtParent(schema, path);
|
|
509
|
+
if (!siblings) return void 0;
|
|
510
|
+
const fieldName = path[path.length - 1];
|
|
511
|
+
if (typeof fieldName !== "string") return void 0;
|
|
512
|
+
const matches = [];
|
|
513
|
+
for (const [key, node] of Object.entries(siblings)) {
|
|
514
|
+
if (key === fieldName) continue;
|
|
515
|
+
const sibling = readRelationConfig(node);
|
|
516
|
+
if (!sibling || targetToKind(sibling.target) !== "entity") continue;
|
|
517
|
+
if (sibling.role && traits.includes(sibling.role)) matches.push(key);
|
|
518
|
+
}
|
|
519
|
+
return matches.length === 1 ? matches[0] : void 0;
|
|
520
|
+
}
|
|
521
|
+
function propertiesAtParent(schema, path) {
|
|
522
|
+
let node = schema;
|
|
523
|
+
for (const segment of path.slice(0, -1)) if (typeof segment === "number") {
|
|
524
|
+
const items = Array.isArray(node.items) ? void 0 : node.items;
|
|
525
|
+
if (!items) return void 0;
|
|
526
|
+
node = items;
|
|
527
|
+
} else {
|
|
528
|
+
const next = node.properties?.[segment];
|
|
529
|
+
if (!next) return void 0;
|
|
530
|
+
node = next;
|
|
531
|
+
}
|
|
532
|
+
return node.properties;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* The row-identity keys a relation row carries beyond the stage-configured
|
|
536
|
+
* nested fields — the autocomplete result contracts (`PartySearchResult` /
|
|
537
|
+
* `AssetSearchResult`), which the ref readers/mergers in relation-widgets
|
|
538
|
+
* enumerate. The store walk applies the schema as a recursive allow-list,
|
|
539
|
+
* and a stage-injected item schema declares ONLY the nested tray keys — so
|
|
540
|
+
* these must be declared too or decoded rows lose their identity (empty
|
|
541
|
+
* autocompletes, no trays, and no `id` for the baseline's id-match array
|
|
542
|
+
* rebase). Declared as `hidden` controls: they walk and encode, never
|
|
543
|
+
* render.
|
|
544
|
+
*/
|
|
545
|
+
const HIDDEN_STRING = {
|
|
546
|
+
type: "string",
|
|
547
|
+
"x-ui": { control: "hidden" }
|
|
548
|
+
};
|
|
549
|
+
const PARTY_IDENTITY_PROPS = {
|
|
550
|
+
id: HIDDEN_STRING,
|
|
551
|
+
pubId: HIDDEN_STRING,
|
|
552
|
+
name: HIDDEN_STRING,
|
|
553
|
+
kind: HIDDEN_STRING,
|
|
554
|
+
members: {
|
|
555
|
+
type: "array",
|
|
556
|
+
"x-ui": { control: "hidden" }
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
const ASSET_IDENTITY_PROPS = {
|
|
560
|
+
id: HIDDEN_STRING,
|
|
561
|
+
label: HIDDEN_STRING,
|
|
562
|
+
propertyType: HIDDEN_STRING,
|
|
563
|
+
subtitle: HIDDEN_STRING,
|
|
564
|
+
address1: HIDDEN_STRING,
|
|
565
|
+
address2: HIDDEN_STRING,
|
|
566
|
+
city: HIDDEN_STRING,
|
|
567
|
+
region: HIDDEN_STRING,
|
|
568
|
+
postalCode: HIDDEN_STRING,
|
|
569
|
+
country: HIDDEN_STRING,
|
|
570
|
+
formattedAddress: HIDDEN_STRING
|
|
571
|
+
};
|
|
572
|
+
/**
|
|
573
|
+
* The row-identity contract for a relation target. A relation whose item
|
|
574
|
+
* schema declares NO properties never reaches `withRelationRowIdentity` —
|
|
575
|
+
* its rows are a value leaf holding the whole object, with no allow-list to
|
|
576
|
+
* thin them — so a host feeding such a relation from a canonical record
|
|
577
|
+
* must project the rows against this same set itself.
|
|
578
|
+
*/
|
|
579
|
+
function relationRowIdentityProps(target) {
|
|
580
|
+
return target === "asset" ? ASSET_IDENTITY_PROPS : PARTY_IDENTITY_PROPS;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Returns a schema whose multi-row relation item schemas additionally
|
|
584
|
+
* declare the row-identity keys (stage-configured keys win on collision).
|
|
585
|
+
* Apply to the STORE schema only — rendering iterates the configured keys
|
|
586
|
+
* and skips hidden controls.
|
|
587
|
+
*/
|
|
588
|
+
function withRelationRowIdentity(schema) {
|
|
589
|
+
const properties = schema.properties ?? {};
|
|
590
|
+
let changed = false;
|
|
591
|
+
const nextProps = { ...properties };
|
|
592
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
593
|
+
const relation = readRelationConfig(property);
|
|
594
|
+
if (!relation?.many || !relation.items?.properties) continue;
|
|
595
|
+
const identity = relationRowIdentityProps(relation.target);
|
|
596
|
+
nextProps[key] = {
|
|
597
|
+
...property,
|
|
598
|
+
items: {
|
|
599
|
+
...relation.items,
|
|
600
|
+
properties: {
|
|
601
|
+
...identity,
|
|
602
|
+
...relation.items.properties
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
changed = true;
|
|
607
|
+
}
|
|
608
|
+
return changed ? {
|
|
609
|
+
...schema,
|
|
610
|
+
properties: nextProps
|
|
611
|
+
} : schema;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/plugins/derivation/row-scope.ts
|
|
616
|
+
/**
|
|
617
|
+
* Returns the INNERMOST array-item object store containing the field at the
|
|
618
|
+
* given path, or `undefined` when the path is not inside an array row (a
|
|
619
|
+
* root-level field, or a field under a plain nested object).
|
|
620
|
+
*
|
|
621
|
+
* Tolerant on purpose: an unresolvable segment stops the walk and returns
|
|
622
|
+
* whatever row was found so far, so a stale path (a row removed mid-render)
|
|
623
|
+
* degrades to a scope lookup instead of throwing.
|
|
624
|
+
*
|
|
625
|
+
* @param internalFormStore The form store.
|
|
626
|
+
* @param path The path to the field.
|
|
627
|
+
*
|
|
628
|
+
* @returns The row store, or `undefined`.
|
|
629
|
+
*/
|
|
630
|
+
function findRowStore(internalFormStore, path) {
|
|
631
|
+
let current = internalFormStore;
|
|
632
|
+
let row;
|
|
633
|
+
for (const segment of path) if (current.kind === "object" && typeof segment === "string") {
|
|
634
|
+
const child = readOwn(current.children, segment);
|
|
635
|
+
if (!child) return row;
|
|
636
|
+
current = child;
|
|
637
|
+
} else if (current.kind === "array" && typeof segment === "number") {
|
|
638
|
+
const child = current.children[segment];
|
|
639
|
+
if (!child) return row;
|
|
640
|
+
current = child;
|
|
641
|
+
if (current.kind === "object") row = current;
|
|
642
|
+
} else return row;
|
|
643
|
+
return row;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Resolves a row's CANONICAL record: the server row from `offFormValues`
|
|
647
|
+
* that carries the full column set (core columns the write model never
|
|
648
|
+
* holds, plus the server's pre-evaluated derived values). The row's
|
|
649
|
+
* collection is read by walking the row's own path into `offFormValues`
|
|
650
|
+
* (`["assets", 0]` → `offFormValues.assets`), and the row is matched by
|
|
651
|
+
* `id` — index is not identity, rows reorder.
|
|
652
|
+
*
|
|
653
|
+
* Reads signals (`offFormValues`, the row's `id` input), so callers get
|
|
654
|
+
* re-resolution for free inside a computed.
|
|
655
|
+
*
|
|
656
|
+
* @param internalFormStore The form store.
|
|
657
|
+
* @param rowStore The array-item object store.
|
|
658
|
+
*
|
|
659
|
+
* @returns The canonical row, or `undefined`.
|
|
660
|
+
*/
|
|
661
|
+
function canonicalRowOf(internalFormStore, rowStore) {
|
|
662
|
+
const path = rowStore.path;
|
|
663
|
+
if (path.length < 2) return void 0;
|
|
664
|
+
let collection = internalFormStore.offFormValues.value;
|
|
665
|
+
for (let index = 0; index < path.length - 1; index++) {
|
|
666
|
+
collection = readOwn(collection, path[index]);
|
|
667
|
+
if (collection == null) return void 0;
|
|
668
|
+
}
|
|
669
|
+
if (!Array.isArray(collection)) return void 0;
|
|
670
|
+
const idStore = readOwn(rowStore.children, "id");
|
|
671
|
+
const rowId = idStore ? getFieldInput(idStore) : void 0;
|
|
672
|
+
return collection.find((candidate) => candidate && typeof candidate === "object" && candidate.id === rowId);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Resolves a row-scope dependency once the LIVE row value is already in
|
|
676
|
+
* hand — the precedence a per-row formula evaluates in:
|
|
677
|
+
*
|
|
678
|
+
* 1. the root-record alias (`form.rootRecordAlias`) from `offFormValues` (it
|
|
679
|
+
* wins outright: a row column named like the alias is never the
|
|
680
|
+
* root record),
|
|
681
|
+
* 2. the live sibling value in the SAME row (an explicit `null` counts —
|
|
682
|
+
* only `undefined` means "the row does not hold this"),
|
|
683
|
+
* 3. the canonical row's column.
|
|
684
|
+
*
|
|
685
|
+
* A row scope deliberately does NOT see root-level form fields or other
|
|
686
|
+
* `offFormValues` keys: a row's formula is evaluated against its own record
|
|
687
|
+
* plus the root record, exactly as the server evaluates it.
|
|
688
|
+
*
|
|
689
|
+
* @param internalFormStore The form store.
|
|
690
|
+
* @param rowStore The array-item object store the formula lives in.
|
|
691
|
+
* @param key The dependency identifier.
|
|
692
|
+
* @param formValue The live value the row holds for `key`, or `undefined`.
|
|
693
|
+
*
|
|
694
|
+
* @returns The resolved value, or `undefined`.
|
|
695
|
+
*/
|
|
696
|
+
function resolveRowFallback(internalFormStore, rowStore, key, formValue) {
|
|
697
|
+
if (key === internalFormStore.rootRecordAlias) {
|
|
698
|
+
const handle = readOwn(internalFormStore.offFormValues.value, key);
|
|
699
|
+
if (handle && typeof handle === "object") return handle;
|
|
700
|
+
}
|
|
701
|
+
if (formValue !== void 0) return formValue;
|
|
702
|
+
return readOwn(canonicalRowOf(internalFormStore, rowStore), key);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Resolves a single identifier in the scope of a row: the same precedence
|
|
706
|
+
* `buildDerivation` evaluates a row formula's dependencies in, with the
|
|
707
|
+
* live sibling read derived-aware (a sibling formula resolves through its
|
|
708
|
+
* own derived signal, so the read is always fresh; an erroring sibling
|
|
709
|
+
* resolves `undefined`, never a stale number).
|
|
710
|
+
*
|
|
711
|
+
* @param internalFormStore The form store.
|
|
712
|
+
* @param rowStore The array-item object store.
|
|
713
|
+
* @param key The identifier to resolve.
|
|
714
|
+
*
|
|
715
|
+
* @returns The resolved value, or `undefined`.
|
|
716
|
+
*/
|
|
717
|
+
function resolveRowScopeValue(internalFormStore, rowStore, key) {
|
|
718
|
+
const child = readOwn(rowStore.children, key);
|
|
719
|
+
let formValue;
|
|
720
|
+
if (child) {
|
|
721
|
+
const slot = child.kind === "value" ? derivationKey.get(internalFormStore, child) : void 0;
|
|
722
|
+
if (slot) {
|
|
723
|
+
const state = slot.derived.value;
|
|
724
|
+
formValue = state.error === null ? state.value : void 0;
|
|
725
|
+
} else formValue = getFieldInput(child);
|
|
726
|
+
}
|
|
727
|
+
return resolveRowFallback(internalFormStore, rowStore, key, formValue);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/plugins/derivation/resolve-scope-value.ts
|
|
732
|
+
/**
|
|
733
|
+
* Resolves a single scalar key through the canonical scope precedence —
|
|
734
|
+
* the same order the derivation plugin evaluates formula dependencies in:
|
|
735
|
+
* the form value wins (a formula field resolves through its derived
|
|
736
|
+
* signal, so the read is always fresh; an erroring formula resolves
|
|
737
|
+
* `undefined`, never a stale number), `offFormValues` fills what the form
|
|
738
|
+
* does not hold (an off-stage field still resolves from the canonical record).
|
|
739
|
+
*
|
|
740
|
+
* For widget-side scalar reads (e.g. an amount-or-percent field resolving
|
|
741
|
+
* its percent basis). Collection overlay semantics stay inside the
|
|
742
|
+
* derivation plugin — this helper is for scalars.
|
|
743
|
+
*
|
|
744
|
+
* @param internalFormStore The form store.
|
|
745
|
+
* @param key The root-level field key to resolve.
|
|
746
|
+
*
|
|
747
|
+
* @returns The resolved value, or `undefined`.
|
|
748
|
+
*/
|
|
749
|
+
function resolveScopeValue(internalFormStore, key) {
|
|
750
|
+
const child = readOwn(internalFormStore.children, key);
|
|
751
|
+
let formValue;
|
|
752
|
+
if (child) {
|
|
753
|
+
const slot = child.kind === "value" ? derivationKey.get(internalFormStore, child) : void 0;
|
|
754
|
+
if (slot) {
|
|
755
|
+
const state = slot.derived.value;
|
|
756
|
+
formValue = state.error === null ? state.value : void 0;
|
|
757
|
+
} else formValue = getFieldInput(child);
|
|
758
|
+
}
|
|
759
|
+
return formValue === void 0 ? readOwn(internalFormStore.offFormValues.value, key) : formValue;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Resolves a single scalar key in the scope of the field at `path` — the
|
|
763
|
+
* path-aware sibling of `resolveScopeValue`, and the read a widget owned by
|
|
764
|
+
* a field should use:
|
|
765
|
+
*
|
|
766
|
+
* - inside an array row, the ROW's scope (live siblings in the same row →
|
|
767
|
+
* the canonical row from `offFormValues` matched by `id` → the parent
|
|
768
|
+
* root record under the form's `rootRecordAlias`), so a per-row formula's inputs are ITS
|
|
769
|
+
* row's values;
|
|
770
|
+
* - anywhere else, the document scope (`resolveScopeValue`).
|
|
771
|
+
*
|
|
772
|
+
* The same precedence the derivation graph evaluates the field's own
|
|
773
|
+
* formula in, so a widget can never display an input the value was not
|
|
774
|
+
* computed from.
|
|
775
|
+
*
|
|
776
|
+
* @param internalFormStore The form store.
|
|
777
|
+
* @param path The path of the field whose scope to resolve in.
|
|
778
|
+
* @param key The identifier to resolve.
|
|
779
|
+
*
|
|
780
|
+
* @returns The resolved value, or `undefined`.
|
|
781
|
+
*/
|
|
782
|
+
function resolveScopeValueAt(internalFormStore, path, key) {
|
|
783
|
+
const rowStore = findRowStore(internalFormStore, path);
|
|
784
|
+
return rowStore ? resolveRowScopeValue(internalFormStore, rowStore, key) : resolveScopeValue(internalFormStore, key);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
//#endregion
|
|
788
|
+
//#region src/core/visibility/resolve-conditionals.ts
|
|
789
|
+
/**
|
|
790
|
+
* Resolves the root schema's `allOf` `if/then/else` blocks into one
|
|
791
|
+
* `VisibleWhen` per gated field key (first-match-wins per key).
|
|
792
|
+
*
|
|
793
|
+
* Supported condition shapes (the only ones in seed + prod schemas):
|
|
794
|
+
* - `{ const: x }` → equals x
|
|
795
|
+
* - `{ enum: [x, y] }` → one-of [x, y] (LOS-822 multi-value)
|
|
796
|
+
* - `{ contains: { const: x } }` → contains x (array-valued watchers)
|
|
797
|
+
*
|
|
798
|
+
* A `then` branch keeps the positive op; an `else` branch flips it
|
|
799
|
+
* (`equals` → `not-equals`, `contains` → `not-contains`). Compound
|
|
800
|
+
* conditions (AND across watched fields) are out of scope — the first
|
|
801
|
+
* watched property of an `if` block decides.
|
|
802
|
+
*
|
|
803
|
+
* @param schema The root form schema.
|
|
804
|
+
*
|
|
805
|
+
* @returns The gated field keys mapped to their visibility rule.
|
|
806
|
+
*/
|
|
807
|
+
function resolveConditionals(schema) {
|
|
808
|
+
const out = {};
|
|
809
|
+
const allOf = schema.allOf;
|
|
810
|
+
if (!Array.isArray(allOf)) return out;
|
|
811
|
+
for (const blockRaw of allOf) {
|
|
812
|
+
if (!blockRaw || typeof blockRaw !== "object") continue;
|
|
813
|
+
const block = blockRaw;
|
|
814
|
+
const condition = parseIfCondition(block.if);
|
|
815
|
+
if (!condition) continue;
|
|
816
|
+
collectBranch(out, block.then, condition.positive);
|
|
817
|
+
collectBranch(out, block.else, condition.negative);
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Parses an `if` block's first watched property into the branch rules.
|
|
823
|
+
*/
|
|
824
|
+
function parseIfCondition(ifBlock) {
|
|
825
|
+
if (!ifBlock || typeof ifBlock !== "object") return null;
|
|
826
|
+
const props = ifBlock.properties;
|
|
827
|
+
if (!props || typeof props !== "object") return null;
|
|
828
|
+
const entries = Object.entries(props);
|
|
829
|
+
if (entries.length === 0) return null;
|
|
830
|
+
const [field, condRaw] = entries[0];
|
|
831
|
+
if (!condRaw || typeof condRaw !== "object") return null;
|
|
832
|
+
const cond = condRaw;
|
|
833
|
+
if ("const" in cond) return {
|
|
834
|
+
positive: {
|
|
835
|
+
field,
|
|
836
|
+
op: "equals",
|
|
837
|
+
value: cond.const
|
|
838
|
+
},
|
|
839
|
+
negative: {
|
|
840
|
+
field,
|
|
841
|
+
op: "not-equals",
|
|
842
|
+
value: cond.const
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
if (Array.isArray(cond.enum)) return {
|
|
846
|
+
positive: {
|
|
847
|
+
field,
|
|
848
|
+
op: "one-of",
|
|
849
|
+
value: cond.enum
|
|
850
|
+
},
|
|
851
|
+
negative: {
|
|
852
|
+
field,
|
|
853
|
+
op: "not-one-of",
|
|
854
|
+
value: cond.enum
|
|
855
|
+
}
|
|
856
|
+
};
|
|
857
|
+
if ("contains" in cond && cond.contains && typeof cond.contains === "object") {
|
|
858
|
+
const containsConst = cond.contains.const;
|
|
859
|
+
if (containsConst !== void 0) return {
|
|
860
|
+
positive: {
|
|
861
|
+
field,
|
|
862
|
+
op: "contains",
|
|
863
|
+
value: containsConst
|
|
864
|
+
},
|
|
865
|
+
negative: {
|
|
866
|
+
field,
|
|
867
|
+
op: "not-contains",
|
|
868
|
+
value: containsConst
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Records the branch's gated property keys under the given rule.
|
|
876
|
+
*/
|
|
877
|
+
function collectBranch(out, branch, visibleWhen) {
|
|
878
|
+
if (!branch || typeof branch !== "object") return;
|
|
879
|
+
const props = branch.properties;
|
|
880
|
+
if (!props || typeof props !== "object") return;
|
|
881
|
+
for (const key of Object.keys(props)) {
|
|
882
|
+
if (!isSafeKey(key) || readOwn(out, key) !== void 0) continue;
|
|
883
|
+
out[key] = visibleWhen;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
//#endregion
|
|
888
|
+
//#region src/plugins/visibility/plugin.ts
|
|
889
|
+
/**
|
|
890
|
+
* The visibility plugin's key. It keeps no state (rules and computeds live
|
|
891
|
+
* on the field stores' base `visibleWhen`/`visible` members).
|
|
892
|
+
*/
|
|
893
|
+
const visibilityKey = new PluginKey("visibility");
|
|
894
|
+
/**
|
|
895
|
+
* The visibility plugin: every field gated by an `allOf` `if/then/else`
|
|
896
|
+
* block on its OWN scope's schema gets a `visible` computed signal over the
|
|
897
|
+
* watched field's resolved value. Two scope kinds (LOS-722 extended
|
|
898
|
+
* visibility into rows — the spec's open question 3):
|
|
899
|
+
*
|
|
900
|
+
* - the root scope, whose rules come from the form schema's `allOf`;
|
|
901
|
+
* - an array-row scope, whose rules come from the ITEMS schema's `allOf`
|
|
902
|
+
* (`buildNestedFieldItems` carries the related dataset's conditionals),
|
|
903
|
+
* evaluated against THAT row's values — row A's trigger never gates
|
|
904
|
+
* row B's field.
|
|
905
|
+
*
|
|
906
|
+
* Visibility gates RENDERING only: a hidden field keeps its state, stays
|
|
907
|
+
* in the dirty diff, and rides the payload. Fields without a rule get no
|
|
908
|
+
* signal — the public store reads that as always visible.
|
|
909
|
+
*
|
|
910
|
+
* Register AFTER derivation (array order): a WHEN watching a formula field
|
|
911
|
+
* resolves through its derived slot. No hard `dependsOn` — a form without
|
|
912
|
+
* a calc engine legitimately omits derivation, and the watched-value read
|
|
913
|
+
* falls back to the field's input.
|
|
914
|
+
*/
|
|
915
|
+
function visibility() {
|
|
916
|
+
return {
|
|
917
|
+
name: "visibility",
|
|
918
|
+
key: visibilityKey,
|
|
919
|
+
build: () => null,
|
|
920
|
+
buildScope(ctx, scope) {
|
|
921
|
+
const form = ctx.form;
|
|
922
|
+
const isRoot = scope === form;
|
|
923
|
+
const rules = resolveConditionals(scope.schema);
|
|
924
|
+
for (const [key, visibleWhen] of Object.entries(rules)) {
|
|
925
|
+
const child = readOwn(scope.children, key);
|
|
926
|
+
if (!child || typeof child !== "object") continue;
|
|
927
|
+
const store = scope.children[key];
|
|
928
|
+
store.visibleWhen = visibleWhen;
|
|
929
|
+
store.visible = isRoot ? computed(() => evaluateVisibleWhen(form, visibleWhen)) : computed(() => evaluateRowVisibleWhen(form, scope, visibleWhen));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Evaluates a visibility rule against the canonical scope: the form value
|
|
936
|
+
* wins, `offFormValues` fills what the form does not hold — so a WHEN can
|
|
937
|
+
* watch an off-stage record field, and (via the `record[key]` bracket form) a
|
|
938
|
+
* root-record alias field from an asset schema (LOS-471).
|
|
939
|
+
*/
|
|
940
|
+
function evaluateVisibleWhen(internalFormStore, visibleWhen) {
|
|
941
|
+
return compareVisibleWhen(resolveWhenRef(internalFormStore, visibleWhen.field), visibleWhen);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Evaluates a ROW field's visibility rule in its row's scope: the watched
|
|
945
|
+
* value resolves through `resolveRowScopeValue` (live row sibling,
|
|
946
|
+
* derived-aware → canonical row from `offFormValues`), so toggling row A's
|
|
947
|
+
* trigger flips row A's gated field and no other row's. The bracket form
|
|
948
|
+
* resolves its base through the same row precedence — `record[key]` reads off
|
|
949
|
+
* the root-record alias exactly as it does at root (LOS-819).
|
|
950
|
+
*/
|
|
951
|
+
function evaluateRowVisibleWhen(internalFormStore, rowScope, visibleWhen) {
|
|
952
|
+
return compareVisibleWhen(resolveWhenRefWith((key) => resolveRowScopeValue(internalFormStore, rowScope, key), visibleWhen.field), visibleWhen);
|
|
953
|
+
}
|
|
954
|
+
function compareVisibleWhen(actual, visibleWhen) {
|
|
955
|
+
switch (visibleWhen.op) {
|
|
956
|
+
case "equals": return actual === visibleWhen.value;
|
|
957
|
+
case "not-equals": return actual !== visibleWhen.value;
|
|
958
|
+
case "one-of": return Array.isArray(visibleWhen.value) && visibleWhen.value.includes(actual);
|
|
959
|
+
case "not-one-of": return !Array.isArray(visibleWhen.value) || !visibleWhen.value.includes(actual);
|
|
960
|
+
case "contains": return Array.isArray(actual) && actual.includes(visibleWhen.value);
|
|
961
|
+
case "not-contains": return !Array.isArray(actual) || !actual.includes(visibleWhen.value);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Resolves a watched-field reference at ROOT scope via the shared parser.
|
|
966
|
+
*/
|
|
967
|
+
function resolveWhenRef(internalFormStore, ref) {
|
|
968
|
+
return resolveWhenRefWith((key) => resolveScopeValue(internalFormStore, key), ref);
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Parses a watched-field reference over a scope's own key resolver. Two
|
|
972
|
+
* shapes, mirroring the formula grammar: a plain key resolves through the
|
|
973
|
+
* scope precedence; the bracket form `record[key]` reads `key` off the OBJECT
|
|
974
|
+
* the scope resolves under the form's `rootRecordAlias`. An absent or
|
|
975
|
+
* non-object handle resolves `undefined`, so an equals-gated field simply
|
|
976
|
+
* stays hidden — e.g. in a host with no parent record in scope.
|
|
977
|
+
*/
|
|
978
|
+
function resolveWhenRefWith(resolve, ref) {
|
|
979
|
+
const open = ref.indexOf("[");
|
|
980
|
+
if (open > 0 && ref.endsWith("]")) {
|
|
981
|
+
const base = resolve(ref.slice(0, open));
|
|
982
|
+
if (base && typeof base === "object" && !Array.isArray(base)) return readOwn(base, ref.slice(open + 1, -1));
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
return resolve(ref);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
//#endregion
|
|
989
|
+
//#region src/plugins/bagger/compute-scope.ts
|
|
990
|
+
/**
|
|
991
|
+
* canon is the record the domain's canonical loader returns; bagger
|
|
992
|
+
* distributes it: schema-declared keys become form state through the
|
|
993
|
+
* store's allow-list, the whole record becomes the off-form value bag.
|
|
994
|
+
*
|
|
995
|
+
* This module computes that off-form bag ("the shelf") as a pure function
|
|
996
|
+
* of a schema and a canonical record — no store, no signals, so a later
|
|
997
|
+
* task can call it identically from a plugin's `build` and from any
|
|
998
|
+
* engine-less reader.
|
|
999
|
+
*/
|
|
1000
|
+
const DEFAULT_BAG = "data";
|
|
1001
|
+
function isPlainObject(value) {
|
|
1002
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* The record keys that hold relation ROWS, from both classification
|
|
1006
|
+
* sources. The union is load-bearing in both directions:
|
|
1007
|
+
*
|
|
1008
|
+
* - `collectionsSchema` (the dataset schema) alone decides collections the
|
|
1009
|
+
* walked schema does not configure. A stage that renders no assets tray
|
|
1010
|
+
* still needs `assets` on the shelf as FLATTENED rows — left raw, every
|
|
1011
|
+
* bag-resident key (`assignmentFee`) reads undefined per row and
|
|
1012
|
+
* `SUM(assets[assignmentFee])` or a check silently computes 0.
|
|
1013
|
+
* - The walked schema alone declares relations the dataset schema lacks:
|
|
1014
|
+
* registry-role slugs and fallback defs are synthesized per stage
|
|
1015
|
+
* (`buildStageSchemaFromConfig`), so replacing rather than union-ing
|
|
1016
|
+
* would drop those collections off the shelf.
|
|
1017
|
+
*/
|
|
1018
|
+
function collectionKeys(schema, collectionsSchema) {
|
|
1019
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1020
|
+
for (const properties of [schema.properties, collectionsSchema?.properties]) for (const [key, node] of Object.entries(properties ?? {})) if (readRelationConfig(node)?.many) keys.add(key);
|
|
1021
|
+
return keys;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Merges a source row's `x-column` fields with its bag column — bag wins,
|
|
1025
|
+
* per the `x-column` save-routing authority (AI.md: read = row spread over
|
|
1026
|
+
* its `data`). INTACT merge only, no envelope unwrapping: a later task
|
|
1027
|
+
* seeds form rows from this output, and unwrapping here would hand the
|
|
1028
|
+
* store already-flattened envelope values, reproducing the LOS-461
|
|
1029
|
+
* regression.
|
|
1030
|
+
*/
|
|
1031
|
+
function flattenSourceRow(row, bag) {
|
|
1032
|
+
const bagValue = row[bag];
|
|
1033
|
+
return isPlainObject(bagValue) ? {
|
|
1034
|
+
...row,
|
|
1035
|
+
...bagValue
|
|
1036
|
+
} : { ...row };
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Unwraps every kind-envelope entry (estimate / amount-or-percent) in a
|
|
1040
|
+
* flattened row to its value half, in place on a shallow copy. Shelf-only:
|
|
1041
|
+
* the off-form bag is read-only display data, so it carries resolved
|
|
1042
|
+
* values, never the envelope's mode/meta half.
|
|
1043
|
+
*/
|
|
1044
|
+
function unwrapEnvelopes(flat) {
|
|
1045
|
+
const unwrapped = { ...flat };
|
|
1046
|
+
for (const key of Object.keys(unwrapped)) {
|
|
1047
|
+
const raw = unwrapped[key];
|
|
1048
|
+
if (isEnvelope(raw)) unwrapped[key] = envelopesWire.unwrap(raw).value;
|
|
1049
|
+
}
|
|
1050
|
+
return unwrapped;
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Computes the off-form value bag for a canonical record: every declared
|
|
1054
|
+
* scalar and relation collection, bag-merged and envelope-unwrapped, plus
|
|
1055
|
+
* an alias entry holding the whole computed scope (so a formula or
|
|
1056
|
+
* widget can address `record.termMonths` as readily as bare `termMonths`).
|
|
1057
|
+
*/
|
|
1058
|
+
function computeBag(schema, record, opts) {
|
|
1059
|
+
const bag = opts?.bag ?? DEFAULT_BAG;
|
|
1060
|
+
const alias = opts?.rootRecordAlias ?? DEFAULT_ROOT_RECORD_ALIAS;
|
|
1061
|
+
const scope = unwrapEnvelopes(flattenSourceRow(record, bag));
|
|
1062
|
+
for (const key of collectionKeys(schema, opts?.collectionsSchema)) {
|
|
1063
|
+
const source = record[key];
|
|
1064
|
+
if (isPlainObject(source)) continue;
|
|
1065
|
+
scope[key] = Array.isArray(source) ? source.map((row) => unwrapEnvelopes(flattenSourceRow(row, bag))) : [];
|
|
1066
|
+
}
|
|
1067
|
+
scope[alias] = { ...scope };
|
|
1068
|
+
return scope;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
//#endregion
|
|
1072
|
+
//#region src/plugins/bagger/plugin.ts
|
|
1073
|
+
/**
|
|
1074
|
+
* canon is the record the domain's canonical loader returns; bagger
|
|
1075
|
+
* distributes it: schema-declared keys become form state through the
|
|
1076
|
+
* store's allow-list, the whole record becomes the off-form value bag.
|
|
1077
|
+
*
|
|
1078
|
+
* Two constraints:
|
|
1079
|
+
* - The shelf (`offFormValues`) unwraps kind envelopes (`computeBag`); the
|
|
1080
|
+
* form side never does — row seeding reads a SECOND, intact row index
|
|
1081
|
+
* built here from `flattenSourceRow`, so a seeded estimate keeps its
|
|
1082
|
+
* `{ kind, value, mode }` envelope instead of collapsing to a bare
|
|
1083
|
+
* number (LOS-461).
|
|
1084
|
+
* - `build` runs BEFORE the schema walk, so it may read only
|
|
1085
|
+
* `config.schema` — `form.schema` (set by `initializeFieldStore`) is
|
|
1086
|
+
* still undefined there. The shelf is written from the ROOT `buildScope`
|
|
1087
|
+
* call instead, the one write point after the walk (dispatched in plugin
|
|
1088
|
+
* array order — `create-form-store.ts`).
|
|
1089
|
+
*
|
|
1090
|
+
* A seeded envelope leaf (estimate / amount-or-percent) must never fork
|
|
1091
|
+
* its two halves: the envelopes plugin already built this leaf's slot
|
|
1092
|
+
* (empty) from the row's OWN raw before bagger runs in the same
|
|
1093
|
+
* `buildScope` dispatch, so seeding writes through the SAME decode
|
|
1094
|
+
* (`decodeEstimateEnvelope`/`decodeAmountOrPercentEnvelope`) and the SAME sole
|
|
1095
|
+
* writer (`writeEnvelope`) the walk itself uses — never a hand-built
|
|
1096
|
+
* envelope object landing straight in `input`.
|
|
1097
|
+
*/
|
|
1098
|
+
const baggerKey = new PluginKey("bagger");
|
|
1099
|
+
/**
|
|
1100
|
+
* Builds the id-keyed row index for one many:true relation field, from the
|
|
1101
|
+
* INTACT (non-unwrapped) flatten — the seeding source, never `computeBag`'s
|
|
1102
|
+
* unwrapped rows.
|
|
1103
|
+
*/
|
|
1104
|
+
function indexRows(rows, bag) {
|
|
1105
|
+
const index = /* @__PURE__ */ new Map();
|
|
1106
|
+
if (!Array.isArray(rows)) return index;
|
|
1107
|
+
for (const row of rows) {
|
|
1108
|
+
if (typeof row !== "object" || row === null) continue;
|
|
1109
|
+
const flat = flattenSourceRow(row, bag);
|
|
1110
|
+
const id = flat.id;
|
|
1111
|
+
if (typeof id === "string") index.set(id, flat);
|
|
1112
|
+
}
|
|
1113
|
+
return index;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Seeds one declared leaf with its canonical value as BASELINE: `input`,
|
|
1117
|
+
* `startInput`, AND `initialInput` — `reset()` restores a field from
|
|
1118
|
+
* `initialInput` (`methods/reset.ts`), so a seed that skipped it would
|
|
1119
|
+
* survive until the first reset and then blank out, the legacy-hydration
|
|
1120
|
+
* parity `reset` otherwise breaks (a legacy record merged canonical
|
|
1121
|
+
* values into the row BEFORE decode, so every channel already carried
|
|
1122
|
+
* them; seeding after the walk must land on the same three channels by
|
|
1123
|
+
* hand).
|
|
1124
|
+
*
|
|
1125
|
+
* An envelope-control leaf (estimate / amount-or-percent) does NOT get
|
|
1126
|
+
* its raw envelope object written into `input` — that would fork the
|
|
1127
|
+
* value half (a wrapped object landing in a number field) from the
|
|
1128
|
+
* envelopes plugin's own slot, which the walk already built empty from
|
|
1129
|
+
* this row's OWN raw earlier in the same `buildScope` dispatch. Instead
|
|
1130
|
+
* this decodes through the plugin's own functions
|
|
1131
|
+
* (`decodeEstimateEnvelope`/`decodeAmountOrPercentEnvelope`) and writes through its
|
|
1132
|
+
* sole writer (`writeEnvelope`), plus reassigns `slot.startEnvelope` —
|
|
1133
|
+
* the same field `rebase`'s `bindAdopted` reassigns when adopting a new
|
|
1134
|
+
* baseline (`envelope.ts`) — so the meta half (mode, manualValue) seeds
|
|
1135
|
+
* alongside the value half instead of staying at its empty default.
|
|
1136
|
+
*/
|
|
1137
|
+
function seedLeaf(ctx, child, rawValue) {
|
|
1138
|
+
const slot = envelopesKey.get(ctx.form, child);
|
|
1139
|
+
if (!slot) {
|
|
1140
|
+
child.input.value = rawValue;
|
|
1141
|
+
child.startInput.value = rawValue;
|
|
1142
|
+
child.initialInput.value = rawValue;
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
if (slot.family === "estimate") {
|
|
1146
|
+
const decoded = decodeEstimateEnvelope(ctx.form, child, rawValue);
|
|
1147
|
+
slot.startEnvelope.value = decoded;
|
|
1148
|
+
child.startInput.value = decoded.value;
|
|
1149
|
+
child.initialInput.value = decoded.value;
|
|
1150
|
+
writeEnvelope(ctx.form, child, slot, decoded);
|
|
1151
|
+
} else {
|
|
1152
|
+
const decoded = decodeAmountOrPercentEnvelope(ctx.form, child, rawValue);
|
|
1153
|
+
slot.startEnvelope.value = decoded;
|
|
1154
|
+
child.startInput.value = decoded.value;
|
|
1155
|
+
child.initialInput.value = decoded.value;
|
|
1156
|
+
writeEnvelope(ctx.form, child, slot, decoded);
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Seeds one row scope's declared value leaves from its canonical source
|
|
1161
|
+
* row: a leaf with no live input yet (`input.value === undefined`) takes
|
|
1162
|
+
* the source row's value as baseline (`seedLeaf`). A row already holding
|
|
1163
|
+
* a value, or with no canonical match, is left untouched.
|
|
1164
|
+
*/
|
|
1165
|
+
function seedRow(ctx, scope, raw) {
|
|
1166
|
+
const fieldKey = String(scope.path[0]);
|
|
1167
|
+
const rowId = raw?.id ?? "";
|
|
1168
|
+
const sourceRow = ctx.state.rowsByField.get(fieldKey)?.get(rowId);
|
|
1169
|
+
if (!sourceRow) return;
|
|
1170
|
+
for (const [key, child] of Object.entries(scope.children)) {
|
|
1171
|
+
if (child.kind !== "value") continue;
|
|
1172
|
+
if (child.input.value !== void 0) continue;
|
|
1173
|
+
const rawValue = sourceRow[key];
|
|
1174
|
+
if (rawValue === void 0) continue;
|
|
1175
|
+
seedLeaf(ctx, child, rawValue);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* The bagger plugin: computes the off-form shelf once (`computeBag`) and
|
|
1180
|
+
* owns `offFormValues`, then seeds every relation row's declared keys from
|
|
1181
|
+
* its canonical row as an unedited baseline. Registration order:
|
|
1182
|
+
* `[envelopes(), bagger(record), derivation?, visibility()]` — envelopes
|
|
1183
|
+
* first so its slot already exists on every estimate/amount-or-percent leaf by the
|
|
1184
|
+
* time seeding runs (`dependsOn: [envelopesKey]` enforces this at
|
|
1185
|
+
* `createFormStore`).
|
|
1186
|
+
*/
|
|
1187
|
+
function bagger(record, opts) {
|
|
1188
|
+
const bag = opts?.bag ?? "data";
|
|
1189
|
+
return {
|
|
1190
|
+
name: "bagger",
|
|
1191
|
+
key: baggerKey,
|
|
1192
|
+
dependsOn: [envelopesKey],
|
|
1193
|
+
build(form, config) {
|
|
1194
|
+
const schema = config.schema;
|
|
1195
|
+
const scope = computeBag(schema, record, {
|
|
1196
|
+
...opts,
|
|
1197
|
+
rootRecordAlias: form.rootRecordAlias
|
|
1198
|
+
});
|
|
1199
|
+
const rowsByField = /* @__PURE__ */ new Map();
|
|
1200
|
+
for (const key of collectionKeys(schema, opts?.collectionsSchema)) rowsByField.set(key, indexRows(record[key], bag));
|
|
1201
|
+
return {
|
|
1202
|
+
scope,
|
|
1203
|
+
rowsByField
|
|
1204
|
+
};
|
|
1205
|
+
},
|
|
1206
|
+
buildScope(ctx, scope, raw) {
|
|
1207
|
+
if (scope === ctx.form) {
|
|
1208
|
+
ctx.form.offFormValues.value = ctx.state.scope;
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
seedRow(ctx, scope, raw);
|
|
1212
|
+
},
|
|
1213
|
+
reseedScope(ctx, scope, raw) {
|
|
1214
|
+
seedRow(ctx, scope, raw);
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
//#endregion
|
|
1220
|
+
//#region src/plugins/checks/interpolate.ts
|
|
1221
|
+
/**
|
|
1222
|
+
* Eslint-style `{{ placeholder }}` interpolation (ANALYSIS-eslint.md §1.5).
|
|
1223
|
+
* An unsupplied placeholder is left literally in place rather than blanked.
|
|
1224
|
+
*/
|
|
1225
|
+
function interpolate(text, data) {
|
|
1226
|
+
return text.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, (full, key) => {
|
|
1227
|
+
if (!data || !Object.prototype.hasOwnProperty.call(data, key)) return full;
|
|
1228
|
+
return String(data[key]);
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
//#endregion
|
|
1233
|
+
//#region src/plugins/checks/key.ts
|
|
1234
|
+
const checksKey = new PluginKey("checks");
|
|
1235
|
+
|
|
1236
|
+
//#endregion
|
|
1237
|
+
//#region src/plugins/checks/formula.ts
|
|
1238
|
+
/** Shared messageId for parse/eval failures (plugin catch + host mapping). */
|
|
1239
|
+
const UNEVALUABLE_MESSAGE_ID = "unevaluable";
|
|
1240
|
+
/**
|
|
1241
|
+
* The one built-in check definition: a `bespoke.check` row is an *instance*
|
|
1242
|
+
* of this, not a definition of its own (ANALYSIS-eslint.md §2.6).
|
|
1243
|
+
*/
|
|
1244
|
+
function formulaCheck(engine) {
|
|
1245
|
+
return {
|
|
1246
|
+
meta: {
|
|
1247
|
+
name: "formula",
|
|
1248
|
+
messages: {
|
|
1249
|
+
failed: "{{message}}",
|
|
1250
|
+
[UNEVALUABLE_MESSAGE_ID]: "{{error}}"
|
|
1251
|
+
},
|
|
1252
|
+
defaultOptions: {
|
|
1253
|
+
formula: "",
|
|
1254
|
+
message: "Failed.",
|
|
1255
|
+
targetFieldKeys: []
|
|
1256
|
+
},
|
|
1257
|
+
optionsSchema: {
|
|
1258
|
+
type: "object",
|
|
1259
|
+
required: ["formula"],
|
|
1260
|
+
properties: {
|
|
1261
|
+
formula: { type: "string" },
|
|
1262
|
+
message: { type: "string" },
|
|
1263
|
+
name: { type: "string" },
|
|
1264
|
+
targetFieldKeys: {
|
|
1265
|
+
type: "array",
|
|
1266
|
+
items: { type: "string" }
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
},
|
|
1271
|
+
create(context) {
|
|
1272
|
+
const formula = context.options.formula;
|
|
1273
|
+
const targetPaths = (context.options.targetFieldKeys ?? []).map((key) => [key]);
|
|
1274
|
+
const parsed = engine.parse(formula);
|
|
1275
|
+
const deps = parsed.ok ? engine.extractDependencies(parsed.node) : [];
|
|
1276
|
+
const pathRefs = parsed.ok ? engine.extractPathRefs?.(parsed.node) ?? [] : [];
|
|
1277
|
+
const collections = new Set(pathRefs.map((ref) => ref.collection));
|
|
1278
|
+
return { evaluate() {
|
|
1279
|
+
if (!parsed.ok) {
|
|
1280
|
+
context.report({
|
|
1281
|
+
messageId: UNEVALUABLE_MESSAGE_ID,
|
|
1282
|
+
data: { error: parsed.error },
|
|
1283
|
+
paths: targetPaths
|
|
1284
|
+
});
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const scope = buildEvalScope(context.scope, deps, collections);
|
|
1288
|
+
let out;
|
|
1289
|
+
try {
|
|
1290
|
+
out = engine.evaluate(parsed.node, scope);
|
|
1291
|
+
} catch (err) {
|
|
1292
|
+
context.report({
|
|
1293
|
+
messageId: UNEVALUABLE_MESSAGE_ID,
|
|
1294
|
+
data: { error: err instanceof Error ? err.message : String(err) },
|
|
1295
|
+
paths: targetPaths
|
|
1296
|
+
});
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (out === null || out === void 0) return;
|
|
1300
|
+
if (out) return;
|
|
1301
|
+
context.report({
|
|
1302
|
+
messageId: "failed",
|
|
1303
|
+
data: { message: context.options.message ?? "Failed." },
|
|
1304
|
+
paths: targetPaths
|
|
1305
|
+
});
|
|
1306
|
+
} };
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Eval bag for one formula run. Every dep goes through `scope.get` — pathRef
|
|
1312
|
+
* names are NOT forced through `rows()` (a `record[…]` root-record ref is a plain
|
|
1313
|
+
* object; rows would collapse it to `[]`). Collections absent from deps
|
|
1314
|
+
* still land via rows() for aggregate-only refs.
|
|
1315
|
+
*/
|
|
1316
|
+
function buildEvalScope(scope, deps, collections) {
|
|
1317
|
+
const bag = {};
|
|
1318
|
+
for (const dep of deps) bag[dep] = scope.get(dep);
|
|
1319
|
+
for (const collection of collections) if (!(collection in bag)) bag[collection] = scope.rows(collection);
|
|
1320
|
+
return bag;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
//#endregion
|
|
1324
|
+
//#region src/plugins/checks/plugin.ts
|
|
1325
|
+
const SEVERITY_ORDER = {
|
|
1326
|
+
error: 0,
|
|
1327
|
+
warning: 1,
|
|
1328
|
+
info: 2
|
|
1329
|
+
};
|
|
1330
|
+
/**
|
|
1331
|
+
* Checks plugin (LOS-605 / spec D8): eslint's contract on a form store.
|
|
1332
|
+
* Findings are a parallel channel — they never merge into `errors`.
|
|
1333
|
+
*
|
|
1334
|
+
* Register after derivation so a check that reads a formula field resolves
|
|
1335
|
+
* through its derived slot. Array order, not a hard `dependsOn` — a form
|
|
1336
|
+
* without a calc engine legitimately omits derivation.
|
|
1337
|
+
*
|
|
1338
|
+
* Always register (even with zero instances). Live instance edits go through
|
|
1339
|
+
* `replaceCheckInstances` so the host does not remount the form.
|
|
1340
|
+
*/
|
|
1341
|
+
function checks(config) {
|
|
1342
|
+
return {
|
|
1343
|
+
name: "checks",
|
|
1344
|
+
key: checksKey,
|
|
1345
|
+
build(form) {
|
|
1346
|
+
const collections = createSignal(config.collections ?? Object.freeze({}));
|
|
1347
|
+
const instances = createSignal(instanceComputeds(config, freezeScope(form, collections)));
|
|
1348
|
+
const findings = computed(() => {
|
|
1349
|
+
const all = [];
|
|
1350
|
+
for (const slot of instances.value) all.push(...slot.value);
|
|
1351
|
+
return [...all].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.id.localeCompare(b.id));
|
|
1352
|
+
});
|
|
1353
|
+
return {
|
|
1354
|
+
findings,
|
|
1355
|
+
hasBlockingFinding: computed(() => findings.value.some((finding) => finding.severity === "error")),
|
|
1356
|
+
instances,
|
|
1357
|
+
collections
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Re-registers check instances on a live form store (admin edits, late
|
|
1364
|
+
* prop arrival) without remounting. Swaps the per-instance computeds the
|
|
1365
|
+
* `findings` signal already reads.
|
|
1366
|
+
*/
|
|
1367
|
+
function replaceCheckInstances(form, config) {
|
|
1368
|
+
const internal = internalOf(form);
|
|
1369
|
+
const state = checksKey.getState(internal);
|
|
1370
|
+
if (!state) throw new Error("replaceCheckInstances: checks plugin is not registered");
|
|
1371
|
+
state.collections.value = config.collections ?? Object.freeze({});
|
|
1372
|
+
const scope = freezeScope(internal, state.collections);
|
|
1373
|
+
state.instances.value = instanceComputeds(config, scope);
|
|
1374
|
+
}
|
|
1375
|
+
function instanceComputeds(config, scope) {
|
|
1376
|
+
return registerInstances(config, scope).map((runtime) => computed(() => evaluateInstance(runtime)));
|
|
1377
|
+
}
|
|
1378
|
+
function registerInstances(config, scope) {
|
|
1379
|
+
const runtimes = [];
|
|
1380
|
+
for (const instance of config.instances) {
|
|
1381
|
+
if (instance.severity === "off") continue;
|
|
1382
|
+
const definition = config.definitions[instance.check];
|
|
1383
|
+
if (!definition) throw new Error(`Check instance "${instance.id}" (${instance.check}): unknown definition`);
|
|
1384
|
+
const options = mergeAndValidateOptions(instance, definition);
|
|
1385
|
+
const reports = [];
|
|
1386
|
+
const context = {
|
|
1387
|
+
id: instance.id,
|
|
1388
|
+
checkId: instance.check,
|
|
1389
|
+
options,
|
|
1390
|
+
scope,
|
|
1391
|
+
report(finding) {
|
|
1392
|
+
reports.push(finding);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
const handlers = definition.create(context);
|
|
1396
|
+
runtimes.push({
|
|
1397
|
+
id: instance.id,
|
|
1398
|
+
checkId: instance.check,
|
|
1399
|
+
severity: instance.severity,
|
|
1400
|
+
definition,
|
|
1401
|
+
options,
|
|
1402
|
+
evaluate: () => handlers.evaluate(),
|
|
1403
|
+
takeReports: () => {
|
|
1404
|
+
const batch = reports.slice();
|
|
1405
|
+
reports.length = 0;
|
|
1406
|
+
return batch;
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
return runtimes;
|
|
1411
|
+
}
|
|
1412
|
+
function mergeAndValidateOptions(instance, definition) {
|
|
1413
|
+
const defaults = definition.meta.defaultOptions && typeof definition.meta.defaultOptions === "object" ? definition.meta.defaultOptions : {};
|
|
1414
|
+
const provided = instance.options && typeof instance.options === "object" ? instance.options : {};
|
|
1415
|
+
const merged = {
|
|
1416
|
+
...defaults,
|
|
1417
|
+
...provided
|
|
1418
|
+
};
|
|
1419
|
+
const schema = definition.meta.optionsSchema;
|
|
1420
|
+
if (schema === false) return Object.freeze(merged);
|
|
1421
|
+
if (schema === void 0) {
|
|
1422
|
+
if (Object.keys(provided).length > 0) throw new Error(`Check instance "${instance.id}" (${instance.check}): this check takes no options`);
|
|
1423
|
+
return Object.freeze(merged);
|
|
1424
|
+
}
|
|
1425
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
1426
|
+
for (const key of required) {
|
|
1427
|
+
const value = merged[key];
|
|
1428
|
+
if (value === void 0 || value === null) throw new Error(`Check instance "${instance.id}" (${instance.check}): missing option "${key}"`);
|
|
1429
|
+
}
|
|
1430
|
+
const properties = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
|
|
1431
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
1432
|
+
if (!Object.prototype.hasOwnProperty.call(merged, key)) continue;
|
|
1433
|
+
const value = merged[key];
|
|
1434
|
+
if (value === void 0) continue;
|
|
1435
|
+
validateOptionType(instance, key, value, propSchema);
|
|
1436
|
+
}
|
|
1437
|
+
return Object.freeze(merged);
|
|
1438
|
+
}
|
|
1439
|
+
function validateOptionType(instance, key, value, propSchema) {
|
|
1440
|
+
const named = `Check instance "${instance.id}" (${instance.check})`;
|
|
1441
|
+
if (propSchema.type === "string") {
|
|
1442
|
+
if (typeof value !== "string") throw new Error(`${named}: option "${key}" must be a string`);
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
if (propSchema.type === "array") {
|
|
1446
|
+
if (!Array.isArray(value)) throw new Error(`${named}: option "${key}" must be an array`);
|
|
1447
|
+
if (propSchema.items?.type === "string") {
|
|
1448
|
+
for (const entry of value) if (typeof entry !== "string") throw new Error(`${named}: option "${key}" must be an array of strings`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Live form / derived value only — offForm fill belongs to the derivation
|
|
1454
|
+
* closure in `get`, not here.
|
|
1455
|
+
*/
|
|
1456
|
+
function resolveLiveFormValue(form, key) {
|
|
1457
|
+
const child = readOwn(form.children, key);
|
|
1458
|
+
if (!child) return void 0;
|
|
1459
|
+
const slot = child.kind === "value" ? derivationKey.get(form, child) : void 0;
|
|
1460
|
+
if (slot) {
|
|
1461
|
+
const state = slot.derived.value;
|
|
1462
|
+
return state.error === null ? state.value : void 0;
|
|
1463
|
+
}
|
|
1464
|
+
return getFieldInput(child);
|
|
1465
|
+
}
|
|
1466
|
+
function freezeScope(form, collections) {
|
|
1467
|
+
const get = (key) => {
|
|
1468
|
+
const formValue = resolveLiveFormValue(form, key);
|
|
1469
|
+
const offValue = readOwn(form.offFormValues.value, key);
|
|
1470
|
+
const fromCollections = readOwn(collections.value, key);
|
|
1471
|
+
const live = formValue === void 0 ? offValue : formValue;
|
|
1472
|
+
if (Array.isArray(fromCollections) && Array.isArray(live)) return mergeCollectionRows(fromCollections, live);
|
|
1473
|
+
if (Array.isArray(offValue) && Array.isArray(formValue)) return mergeCollectionRows(offValue, formValue);
|
|
1474
|
+
return live;
|
|
1475
|
+
};
|
|
1476
|
+
const scope = {
|
|
1477
|
+
get,
|
|
1478
|
+
rows(collection) {
|
|
1479
|
+
const value = get(collection);
|
|
1480
|
+
return Array.isArray(value) ? value : [];
|
|
1481
|
+
},
|
|
1482
|
+
values() {
|
|
1483
|
+
const bag = { ...form.offFormValues.value };
|
|
1484
|
+
for (const key of Object.keys(form.children)) bag[key] = get(key);
|
|
1485
|
+
for (const key of Object.keys(collections.value)) if (!(key in bag)) bag[key] = get(key);
|
|
1486
|
+
return bag;
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
return Object.freeze(scope);
|
|
1490
|
+
}
|
|
1491
|
+
function evaluateInstance(runtime) {
|
|
1492
|
+
try {
|
|
1493
|
+
runtime.evaluate();
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
runtime.takeReports();
|
|
1496
|
+
const targets = runtime.options.targetFieldKeys ?? [];
|
|
1497
|
+
return findingsFromDescriptor(runtime, {
|
|
1498
|
+
messageId: UNEVALUABLE_MESSAGE_ID,
|
|
1499
|
+
data: { error: err instanceof Error ? err.message : String(err) },
|
|
1500
|
+
paths: targets.map((key) => [key])
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
const reported = runtime.takeReports();
|
|
1504
|
+
const findings = [];
|
|
1505
|
+
for (const descriptor of reported) findings.push(...findingsFromDescriptor(runtime, descriptor));
|
|
1506
|
+
return findings;
|
|
1507
|
+
}
|
|
1508
|
+
function findingsFromDescriptor(runtime, descriptor) {
|
|
1509
|
+
const message = resolveMessage(runtime, descriptor);
|
|
1510
|
+
const paths = descriptor.paths;
|
|
1511
|
+
if (!paths || paths.length === 0) return [findingOf(runtime, {
|
|
1512
|
+
...descriptor,
|
|
1513
|
+
message,
|
|
1514
|
+
path: []
|
|
1515
|
+
})];
|
|
1516
|
+
return paths.map((path) => findingOf(runtime, {
|
|
1517
|
+
...descriptor,
|
|
1518
|
+
message,
|
|
1519
|
+
path
|
|
1520
|
+
}));
|
|
1521
|
+
}
|
|
1522
|
+
function resolveMessage(runtime, descriptor) {
|
|
1523
|
+
if (descriptor.message) return descriptor.message;
|
|
1524
|
+
if (descriptor.messageId) {
|
|
1525
|
+
const template = runtime.definition.meta.messages[descriptor.messageId];
|
|
1526
|
+
if (template) return interpolate(template, descriptor.data);
|
|
1527
|
+
}
|
|
1528
|
+
return "Failed.";
|
|
1529
|
+
}
|
|
1530
|
+
function findingOf(runtime, parts) {
|
|
1531
|
+
const options = runtime.options;
|
|
1532
|
+
const name = typeof options.name === "string" && options.name.length > 0 ? options.name : runtime.checkId;
|
|
1533
|
+
return {
|
|
1534
|
+
id: runtime.id,
|
|
1535
|
+
checkId: runtime.checkId,
|
|
1536
|
+
name,
|
|
1537
|
+
messageId: parts.messageId,
|
|
1538
|
+
message: parts.message,
|
|
1539
|
+
severity: runtime.severity,
|
|
1540
|
+
path: parts.path ?? []
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
//#endregion
|
|
1545
|
+
export { bindAdopted as A, wrapEstimate as B, withRelationRowIdentity as C, FieldSlotKey as D, envelopesKey as E, syncEstimateInput as F, getDirtyFieldInput as H, writeEnvelope as I, envelopesWire as L, decodeEstimateEnvelope as M, resolveEstimateMode as N, PluginKey as O, syncAmountOrPercentInput as P, isEnvelope as R, targetToKind as S, derivationKey as T, encodeScopeValues as V, resolveRowFallback as _, checksKey as a, relationParentFieldName as b, collectionKeys as c, visibility as d, visibilityKey as f, findRowStore as g, canonicalRowOf as h, formulaCheck as i, decodeAmountOrPercentEnvelope as j, adoptEnvelope as k, computeBag as l, resolveScopeValueAt as m, replaceCheckInstances as n, bagger as o, resolveScopeValue as p, UNEVALUABLE_MESSAGE_ID as r, baggerKey as s, checks as t, flattenSourceRow as u, resolveRowScopeValue as v, mergeCollectionRows as w, relationRowIdentityProps as x, readRelationConfig as y, wrapAmountOrPercent as z };
|