@bluprynt/forms-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/dist/form-definition.schema.json +352 -0
- package/dist/index.cjs +2351 -0
- package/dist/index.d.cts +1200 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1200 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2321 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +47 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2321 @@
|
|
|
1
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
2
|
+
//#region src/date-utils.ts
|
|
3
|
+
const RELATIVE_DATE_RE = /^([+-])(\d+)([dwmy])$/;
|
|
4
|
+
/**
|
|
5
|
+
* Type guard that checks whether a value is a relative date expression.
|
|
6
|
+
*
|
|
7
|
+
* Relative date expressions follow the pattern `[+-]<amount><unit>` where
|
|
8
|
+
* `unit` is one of `d` (days), `w` (weeks), `m` (months), or `y` (years).
|
|
9
|
+
*
|
|
10
|
+
* @param value - The value to test.
|
|
11
|
+
* @returns `true` if `value` is a string matching the relative date pattern.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* isRelativeDate("+7d") // true (7 days from now)
|
|
16
|
+
* isRelativeDate("-1m") // true (1 month ago)
|
|
17
|
+
* isRelativeDate("2024-01-01") // false (absolute date)
|
|
18
|
+
* isRelativeDate(42) // false (not a string)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
const isRelativeDate = (value) => typeof value === "string" && RELATIVE_DATE_RE.test(value);
|
|
22
|
+
/**
|
|
23
|
+
* Resolves a relative date expression into an absolute ISO-8601 date string.
|
|
24
|
+
*
|
|
25
|
+
* Supported units:
|
|
26
|
+
* - `d` -- days
|
|
27
|
+
* - `w` -- weeks (7 days)
|
|
28
|
+
* - `m` -- months
|
|
29
|
+
* - `y` -- years
|
|
30
|
+
*
|
|
31
|
+
* Arithmetic is performed in UTC. If the input does not match the relative
|
|
32
|
+
* date pattern, it is returned unchanged.
|
|
33
|
+
*
|
|
34
|
+
* @param relative - A relative date expression (e.g. `"+7d"`, `"-3m"`).
|
|
35
|
+
* @param now - Reference date for the calculation. Defaults to `new Date()`.
|
|
36
|
+
* @returns An ISO-8601 date-time string, or the original string if it is not
|
|
37
|
+
* a valid relative expression.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* const base = new Date("2024-06-15T00:00:00Z");
|
|
42
|
+
* resolveRelativeDate("+7d", base) // "2024-06-22T00:00:00.000Z"
|
|
43
|
+
* resolveRelativeDate("-1m", base) // "2024-05-15T00:00:00.000Z"
|
|
44
|
+
* resolveRelativeDate("+1y", base) // "2025-06-15T00:00:00.000Z"
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
const resolveRelativeDate = (relative, now) => {
|
|
48
|
+
const match = RELATIVE_DATE_RE.exec(relative);
|
|
49
|
+
if (!match) return relative;
|
|
50
|
+
const sign = match[1] === "+" ? 1 : -1;
|
|
51
|
+
const amount = Number(match[2]) * sign;
|
|
52
|
+
const unit = match[3];
|
|
53
|
+
const result = new Date(now.getTime());
|
|
54
|
+
switch (unit) {
|
|
55
|
+
case "d":
|
|
56
|
+
result.setUTCDate(result.getUTCDate() + amount);
|
|
57
|
+
break;
|
|
58
|
+
case "w":
|
|
59
|
+
result.setUTCDate(result.getUTCDate() + amount * 7);
|
|
60
|
+
break;
|
|
61
|
+
case "m":
|
|
62
|
+
result.setUTCMonth(result.getUTCMonth() + amount);
|
|
63
|
+
break;
|
|
64
|
+
case "y":
|
|
65
|
+
result.setUTCFullYear(result.getUTCFullYear() + amount);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return result.toISOString();
|
|
69
|
+
};
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/condition-evaluator.ts
|
|
72
|
+
/**
|
|
73
|
+
* Evaluates condition trees against form state.
|
|
74
|
+
*
|
|
75
|
+
* Supports three kinds of conditions:
|
|
76
|
+
* - **Simple** ({@link SimpleCondition}): compares a single field's value
|
|
77
|
+
* using one of the supported operators (`set`, `notset`, `eq`, `ne`, `lt`,
|
|
78
|
+
* `gt`, `lte`, `gte`, `in`, `notin`).
|
|
79
|
+
* - **Compound AND**: `{ and: [...] }` -- all child conditions must be true.
|
|
80
|
+
* - **Compound OR**: `{ or: [...] }` -- at least one child condition must be true.
|
|
81
|
+
*
|
|
82
|
+
* **Hidden-field rule**: when a `visibilityMap` is provided and the
|
|
83
|
+
* referenced field is hidden (`false`), the condition evaluates as if the
|
|
84
|
+
* field has no value. This means `notset` returns `true` and all other
|
|
85
|
+
* operators return `false`.
|
|
86
|
+
*
|
|
87
|
+
* **Date handling**: condition values that are relative date expressions
|
|
88
|
+
* (e.g. `"+7d"`) are resolved against `ctx.now` before comparison.
|
|
89
|
+
*/
|
|
90
|
+
var ConditionEvaluator = class {
|
|
91
|
+
/**
|
|
92
|
+
* Evaluates a condition tree against the current form state.
|
|
93
|
+
*
|
|
94
|
+
* @param condition - The condition to evaluate (simple or compound).
|
|
95
|
+
* @param ctx - Evaluation context containing form values and optional
|
|
96
|
+
* visibility/date overrides.
|
|
97
|
+
* @returns `true` if the condition is satisfied, `false` otherwise.
|
|
98
|
+
*/
|
|
99
|
+
evalCondition(condition, ctx) {
|
|
100
|
+
if ("and" in condition) return condition.and.every((c) => this.evalCondition(c, ctx));
|
|
101
|
+
if ("or" in condition) return condition.or.some((c) => this.evalCondition(c, ctx));
|
|
102
|
+
return this.evalSimple(condition, ctx);
|
|
103
|
+
}
|
|
104
|
+
evalSimple(cond, ctx) {
|
|
105
|
+
if (ctx.visibilityMap && ctx.visibilityMap.get(cond.field) === false) return cond.op === "notset";
|
|
106
|
+
const fieldValue = ctx.values[String(cond.field)];
|
|
107
|
+
switch (cond.op) {
|
|
108
|
+
case "set": return fieldValue !== null && fieldValue !== void 0 && fieldValue !== "";
|
|
109
|
+
case "notset": return fieldValue === null || fieldValue === void 0 || fieldValue === "";
|
|
110
|
+
case "eq": return fieldValue === this.resolveIfDate(cond.value, ctx.now);
|
|
111
|
+
case "ne": return fieldValue !== this.resolveIfDate(cond.value, ctx.now);
|
|
112
|
+
case "lt": return this.compareTo(fieldValue, cond.value, ctx.now) < 0;
|
|
113
|
+
case "gt": return this.compareTo(fieldValue, cond.value, ctx.now) > 0;
|
|
114
|
+
case "lte": return this.compareTo(fieldValue, cond.value, ctx.now) <= 0;
|
|
115
|
+
case "gte": return this.compareTo(fieldValue, cond.value, ctx.now) >= 0;
|
|
116
|
+
case "in": return Array.isArray(cond.value) && cond.value.includes(fieldValue);
|
|
117
|
+
case "notin": return Array.isArray(cond.value) && !cond.value.includes(fieldValue);
|
|
118
|
+
default: return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
resolveIfDate(value, now) {
|
|
122
|
+
if (isRelativeDate(value)) return resolveRelativeDate(value, now);
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
compareTo(a, b, now) {
|
|
126
|
+
const resolvedB = this.resolveIfDate(b, now);
|
|
127
|
+
if (typeof a === "number" && typeof resolvedB === "number") return a - resolvedB;
|
|
128
|
+
if (typeof a === "string" && typeof resolvedB === "string") {
|
|
129
|
+
const ta = Date.parse(a);
|
|
130
|
+
const tb = Date.parse(resolvedB);
|
|
131
|
+
if (!Number.isNaN(ta) && !Number.isNaN(tb)) return ta - tb;
|
|
132
|
+
if (a < resolvedB) return -1;
|
|
133
|
+
if (a > resolvedB) return 1;
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
return NaN;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/dependency-graph.ts
|
|
141
|
+
const DfsVisitState = {
|
|
142
|
+
Unvisited: 0,
|
|
143
|
+
InProgress: 1,
|
|
144
|
+
Completed: 2
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Manages the condition dependency graph for form fields.
|
|
148
|
+
*
|
|
149
|
+
* Built from the field registry during engine preparation. Provides:
|
|
150
|
+
* - Forward dependency graph (`graph`): answers "if field X changes, which
|
|
151
|
+
* items need to re-evaluate their visibility?"
|
|
152
|
+
* - Topological ordering (`topologicalOrder`): guarantees that when computing
|
|
153
|
+
* visibility, every item is evaluated after the fields it depends on.
|
|
154
|
+
* - Affected-ids lookup (`getAffectedIds`): returns all transitively
|
|
155
|
+
* affected item ids when a field value changes (lazily cached).
|
|
156
|
+
*
|
|
157
|
+
* Static methods (`extractFieldRefs`, `detectCycle`) can be used before
|
|
158
|
+
* constructing an instance, e.g. during semantic validation.
|
|
159
|
+
*/
|
|
160
|
+
var DependencyGraph = class DependencyGraph {
|
|
161
|
+
/**
|
|
162
|
+
* Forward adjacencyMap map: key is a field id, value is the set of item ids
|
|
163
|
+
* whose conditions reference that field.
|
|
164
|
+
*/
|
|
165
|
+
graph;
|
|
166
|
+
/**
|
|
167
|
+
* Item ids in topological order. Dependencies come before dependents.
|
|
168
|
+
*/
|
|
169
|
+
topologicalOrder;
|
|
170
|
+
registry;
|
|
171
|
+
affectedCache = /* @__PURE__ */ new Map();
|
|
172
|
+
/**
|
|
173
|
+
* @param registry - The engine's field registry (built during preparation).
|
|
174
|
+
*/
|
|
175
|
+
constructor(registry) {
|
|
176
|
+
this.registry = registry;
|
|
177
|
+
this.graph = this.buildGraph();
|
|
178
|
+
this.topologicalOrder = this.buildTopologicalOrder();
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Extracts the set of field ids referenced by a condition tree.
|
|
182
|
+
*
|
|
183
|
+
* Recursively walks compound conditions (`and`/`or`) and collects the
|
|
184
|
+
* `field` property from every leaf {@link SimpleCondition}.
|
|
185
|
+
*
|
|
186
|
+
* @param condition - A simple or compound condition.
|
|
187
|
+
* @returns Set of all unique field ids that appear in the condition.
|
|
188
|
+
*/
|
|
189
|
+
static extractFieldRefs(condition) {
|
|
190
|
+
const refs = /* @__PURE__ */ new Set();
|
|
191
|
+
DependencyGraph.collectRefs(condition, refs);
|
|
192
|
+
return refs;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Detects circular dependencies in the condition graph.
|
|
196
|
+
*
|
|
197
|
+
* Uses DFS-based cycle detection (white/gray/black coloring). If a cycle
|
|
198
|
+
* is found, the function reconstructs and returns a human-readable path
|
|
199
|
+
* string (e.g. `"1 -> 2 -> 3 -> 1"`).
|
|
200
|
+
*
|
|
201
|
+
* @param registry - The engine's field registry.
|
|
202
|
+
* @returns An array of field ids forming the cycle, or `undefined` if no cycle exists.
|
|
203
|
+
*/
|
|
204
|
+
static detectCycle(registry) {
|
|
205
|
+
const allIds = new Set(registry.keys());
|
|
206
|
+
const adjacencyMap = /* @__PURE__ */ new Map();
|
|
207
|
+
for (const [id, entry] of registry) {
|
|
208
|
+
if (!entry.condition) continue;
|
|
209
|
+
const refs = DependencyGraph.extractFieldRefs(entry.condition);
|
|
210
|
+
for (const ref of refs) {
|
|
211
|
+
if (!allIds.has(ref)) continue;
|
|
212
|
+
let targets = adjacencyMap.get(ref);
|
|
213
|
+
if (!targets) {
|
|
214
|
+
targets = /* @__PURE__ */ new Set();
|
|
215
|
+
adjacencyMap.set(ref, targets);
|
|
216
|
+
}
|
|
217
|
+
targets.add(id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
221
|
+
const parent = /* @__PURE__ */ new Map();
|
|
222
|
+
for (const id of allIds) nodes.set(id, DfsVisitState.Unvisited);
|
|
223
|
+
for (const id of allIds) if (nodes.get(id) === DfsVisitState.Unvisited) {
|
|
224
|
+
const cyclePath = DependencyGraph.dfs(id, adjacencyMap, nodes, parent, allIds);
|
|
225
|
+
if (cyclePath) return cyclePath;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Returns the set of item ids whose visibility could change when the given
|
|
230
|
+
* field's value changes.
|
|
231
|
+
*
|
|
232
|
+
* Performs a transitive expansion of the forward dependency graph starting
|
|
233
|
+
* from the field's direct dependents. Results are memoized for subsequent
|
|
234
|
+
* calls with the same `fieldId`.
|
|
235
|
+
*
|
|
236
|
+
* @param fieldId - Id of the field whose value changed.
|
|
237
|
+
* @returns Set of all transitively affected item ids. Empty set if no items
|
|
238
|
+
* depend on `fieldId`.
|
|
239
|
+
*/
|
|
240
|
+
getAffectedIds(fieldId) {
|
|
241
|
+
const cached = this.affectedCache.get(fieldId);
|
|
242
|
+
if (cached) return cached;
|
|
243
|
+
const directDeps = this.graph.get(fieldId);
|
|
244
|
+
if (!directDeps || directDeps.size === 0) {
|
|
245
|
+
const empty = /* @__PURE__ */ new Set();
|
|
246
|
+
this.affectedCache.set(fieldId, empty);
|
|
247
|
+
return empty;
|
|
248
|
+
}
|
|
249
|
+
const expanded = this.expandTransitiveDependencies(directDeps);
|
|
250
|
+
this.affectedCache.set(fieldId, expanded);
|
|
251
|
+
return expanded;
|
|
252
|
+
}
|
|
253
|
+
static collectRefs(condition, refs) {
|
|
254
|
+
if ("and" in condition) for (const c of condition.and) DependencyGraph.collectRefs(c, refs);
|
|
255
|
+
else if ("or" in condition) for (const c of condition.or) DependencyGraph.collectRefs(c, refs);
|
|
256
|
+
else refs.add(condition.field);
|
|
257
|
+
}
|
|
258
|
+
static dfs(node, adjacencyMap, nodes, parent, allIds) {
|
|
259
|
+
nodes.set(node, DfsVisitState.InProgress);
|
|
260
|
+
const neighbors = adjacencyMap.get(node);
|
|
261
|
+
if (neighbors) for (const next of neighbors) {
|
|
262
|
+
if (!allIds.has(next)) continue;
|
|
263
|
+
if (nodes.get(next) === DfsVisitState.InProgress) return DependencyGraph.reconstructCycle(next, node, parent);
|
|
264
|
+
if (nodes.get(next) === DfsVisitState.Completed) continue;
|
|
265
|
+
parent.set(next, node);
|
|
266
|
+
const cycle = DependencyGraph.dfs(next, adjacencyMap, nodes, parent, allIds);
|
|
267
|
+
if (cycle) return cycle;
|
|
268
|
+
}
|
|
269
|
+
nodes.set(node, DfsVisitState.Completed);
|
|
270
|
+
}
|
|
271
|
+
static reconstructCycle(cycleStart, cycleEnd, parent) {
|
|
272
|
+
const path = [cycleStart];
|
|
273
|
+
let current = cycleEnd;
|
|
274
|
+
while (current !== cycleStart) {
|
|
275
|
+
path.push(current);
|
|
276
|
+
const next = parent.get(current);
|
|
277
|
+
if (next === void 0) break;
|
|
278
|
+
current = next;
|
|
279
|
+
}
|
|
280
|
+
path.push(cycleStart);
|
|
281
|
+
return path.reverse();
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Builds the forward dependency graph from the registry.
|
|
285
|
+
*/
|
|
286
|
+
buildGraph() {
|
|
287
|
+
const graph = /* @__PURE__ */ new Map();
|
|
288
|
+
for (const [id, entry] of this.registry) {
|
|
289
|
+
if (!entry.condition) continue;
|
|
290
|
+
const refs = DependencyGraph.extractFieldRefs(entry.condition);
|
|
291
|
+
for (const ref of refs) {
|
|
292
|
+
let deps = graph.get(ref);
|
|
293
|
+
if (!deps) {
|
|
294
|
+
deps = /* @__PURE__ */ new Set();
|
|
295
|
+
graph.set(ref, deps);
|
|
296
|
+
}
|
|
297
|
+
deps.add(id);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return graph;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Produces a topological ordering using Kahn's algorithm.
|
|
304
|
+
*/
|
|
305
|
+
buildTopologicalOrder() {
|
|
306
|
+
const allIds = new Set(this.registry.keys());
|
|
307
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
308
|
+
const adjacencyMap = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const id of allIds) inDegree.set(id, 0);
|
|
310
|
+
for (const [id, entry] of this.registry) {
|
|
311
|
+
if (!entry.condition) continue;
|
|
312
|
+
const refs = DependencyGraph.extractFieldRefs(entry.condition);
|
|
313
|
+
for (const ref of refs) {
|
|
314
|
+
if (!allIds.has(ref)) continue;
|
|
315
|
+
let targets = adjacencyMap.get(ref);
|
|
316
|
+
if (!targets) {
|
|
317
|
+
targets = /* @__PURE__ */ new Set();
|
|
318
|
+
adjacencyMap.set(ref, targets);
|
|
319
|
+
}
|
|
320
|
+
if (!targets.has(id)) {
|
|
321
|
+
targets.add(id);
|
|
322
|
+
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const [id, entry] of this.registry) {
|
|
327
|
+
if (entry.parentId === void 0) continue;
|
|
328
|
+
if (!allIds.has(entry.parentId)) continue;
|
|
329
|
+
let targets = adjacencyMap.get(entry.parentId);
|
|
330
|
+
if (!targets) {
|
|
331
|
+
targets = /* @__PURE__ */ new Set();
|
|
332
|
+
adjacencyMap.set(entry.parentId, targets);
|
|
333
|
+
}
|
|
334
|
+
if (!targets.has(id)) {
|
|
335
|
+
targets.add(id);
|
|
336
|
+
inDegree.set(id, (inDegree.get(id) ?? 0) + 1);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const queue = [];
|
|
340
|
+
for (const [id, deg] of inDegree) if (deg === 0) queue.push(id);
|
|
341
|
+
const sorted = [];
|
|
342
|
+
while (queue.length > 0) {
|
|
343
|
+
const current = queue.shift();
|
|
344
|
+
if (current === void 0) break;
|
|
345
|
+
sorted.push(current);
|
|
346
|
+
const targets = adjacencyMap.get(current);
|
|
347
|
+
if (targets) for (const target of targets) {
|
|
348
|
+
const newDeg = (inDegree.get(target) ?? 1) - 1;
|
|
349
|
+
inDegree.set(target, newDeg);
|
|
350
|
+
if (newDeg === 0) queue.push(target);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (sorted.length !== allIds.size) {
|
|
354
|
+
const cyclePath = [...allIds].filter((id) => !sorted.includes(id)).join(" -> ");
|
|
355
|
+
return sorted.length === 0 ? [] : (() => {
|
|
356
|
+
throw cyclePath;
|
|
357
|
+
})();
|
|
358
|
+
}
|
|
359
|
+
return sorted;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Expands a set of item ids to include all transitive dependents via BFS.
|
|
363
|
+
*/
|
|
364
|
+
expandTransitiveDependencies(startIds) {
|
|
365
|
+
const result = /* @__PURE__ */ new Set();
|
|
366
|
+
const queue = [...startIds];
|
|
367
|
+
while (queue.length > 0) {
|
|
368
|
+
const id = queue.shift();
|
|
369
|
+
if (id === void 0) break;
|
|
370
|
+
if (result.has(id)) continue;
|
|
371
|
+
result.add(id);
|
|
372
|
+
const deps = this.graph.get(id);
|
|
373
|
+
if (deps) {
|
|
374
|
+
for (const dep of deps) if (!result.has(dep)) queue.push(dep);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/validators/array-validator.ts
|
|
382
|
+
var ArrayValidator = class {
|
|
383
|
+
validate(ctx) {
|
|
384
|
+
const { fieldId, value, now } = ctx;
|
|
385
|
+
const { item, validateField } = ctx;
|
|
386
|
+
const validation = ctx.validation;
|
|
387
|
+
const errors = [];
|
|
388
|
+
if (value === null || value === void 0) return errors;
|
|
389
|
+
if (!Array.isArray(value)) {
|
|
390
|
+
errors.push({
|
|
391
|
+
fieldId,
|
|
392
|
+
rule: "TYPE",
|
|
393
|
+
message: "Must be an array",
|
|
394
|
+
params: { expectedType: "array" }
|
|
395
|
+
});
|
|
396
|
+
return errors;
|
|
397
|
+
}
|
|
398
|
+
if (validation?.minItems !== void 0 && value.length < validation.minItems) errors.push({
|
|
399
|
+
fieldId,
|
|
400
|
+
rule: "MIN_ITEMS",
|
|
401
|
+
message: `Must have at least ${validation.minItems} items`,
|
|
402
|
+
params: {
|
|
403
|
+
minItems: validation.minItems,
|
|
404
|
+
actual: value.length
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
if (validation?.maxItems !== void 0 && value.length > validation.maxItems) errors.push({
|
|
408
|
+
fieldId,
|
|
409
|
+
rule: "MAX_ITEMS",
|
|
410
|
+
message: `Must have at most ${validation.maxItems} items`,
|
|
411
|
+
params: {
|
|
412
|
+
maxItems: validation.maxItems,
|
|
413
|
+
actual: value.length
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
if (item) for (let i = 0; i < value.length; i++) {
|
|
417
|
+
const fakeEntry = {
|
|
418
|
+
id: fieldId,
|
|
419
|
+
type: item.type,
|
|
420
|
+
condition: void 0,
|
|
421
|
+
validation: item.validation,
|
|
422
|
+
parentId: void 0,
|
|
423
|
+
options: item.options,
|
|
424
|
+
item: void 0,
|
|
425
|
+
label: item.label,
|
|
426
|
+
title: void 0
|
|
427
|
+
};
|
|
428
|
+
const itemErrors = validateField(fieldId, value[i], fakeEntry, now);
|
|
429
|
+
errors.push(...itemErrors.map((err) => ({
|
|
430
|
+
...err,
|
|
431
|
+
itemIndex: i
|
|
432
|
+
})));
|
|
433
|
+
}
|
|
434
|
+
return errors;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/validators/boolean-validator.ts
|
|
439
|
+
var BooleanValidator = class {
|
|
440
|
+
validate(ctx) {
|
|
441
|
+
const { fieldId, value } = ctx;
|
|
442
|
+
const validation = ctx.validation;
|
|
443
|
+
const errors = [];
|
|
444
|
+
if (validation?.required && value !== true && value !== false) errors.push({
|
|
445
|
+
fieldId,
|
|
446
|
+
rule: "REQUIRED",
|
|
447
|
+
message: "Value is required"
|
|
448
|
+
});
|
|
449
|
+
return errors;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region src/validators/date-validator.ts
|
|
454
|
+
var DateValidator = class {
|
|
455
|
+
validate(ctx) {
|
|
456
|
+
const { fieldId, value, now } = ctx;
|
|
457
|
+
const validation = ctx.validation;
|
|
458
|
+
const errors = [];
|
|
459
|
+
const isEmpty = value === null || value === void 0 || value === "";
|
|
460
|
+
if (validation?.required && isEmpty) {
|
|
461
|
+
errors.push({
|
|
462
|
+
fieldId,
|
|
463
|
+
rule: "REQUIRED",
|
|
464
|
+
message: "Value is required"
|
|
465
|
+
});
|
|
466
|
+
return errors;
|
|
467
|
+
}
|
|
468
|
+
if (isEmpty) return errors;
|
|
469
|
+
if (typeof value !== "string") {
|
|
470
|
+
errors.push({
|
|
471
|
+
fieldId,
|
|
472
|
+
rule: "TYPE",
|
|
473
|
+
message: "Must be a valid date",
|
|
474
|
+
params: { expectedType: "date" }
|
|
475
|
+
});
|
|
476
|
+
return errors;
|
|
477
|
+
}
|
|
478
|
+
const timestamp = Date.parse(value);
|
|
479
|
+
if (Number.isNaN(timestamp)) {
|
|
480
|
+
errors.push({
|
|
481
|
+
fieldId,
|
|
482
|
+
rule: "INVALID_DATE",
|
|
483
|
+
message: "Must be a valid date"
|
|
484
|
+
});
|
|
485
|
+
return errors;
|
|
486
|
+
}
|
|
487
|
+
if (validation?.minDate !== void 0) {
|
|
488
|
+
const minResolved = isRelativeDate(validation.minDate) ? resolveRelativeDate(validation.minDate, now) : validation.minDate;
|
|
489
|
+
if (timestamp < Date.parse(minResolved)) errors.push({
|
|
490
|
+
fieldId,
|
|
491
|
+
rule: "MIN_DATE",
|
|
492
|
+
message: `Must be on or after ${minResolved}`,
|
|
493
|
+
params: { minDate: minResolved }
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (validation?.maxDate !== void 0) {
|
|
497
|
+
const maxResolved = isRelativeDate(validation.maxDate) ? resolveRelativeDate(validation.maxDate, now) : validation.maxDate;
|
|
498
|
+
if (timestamp > Date.parse(maxResolved)) errors.push({
|
|
499
|
+
fieldId,
|
|
500
|
+
rule: "MAX_DATE",
|
|
501
|
+
message: `Must be on or before ${maxResolved}`,
|
|
502
|
+
params: { maxDate: maxResolved }
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return errors;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/validators/file-validator.ts
|
|
510
|
+
var FileValidator = class {
|
|
511
|
+
validate(ctx) {
|
|
512
|
+
const { fieldId, value } = ctx;
|
|
513
|
+
const validation = ctx.validation;
|
|
514
|
+
const errors = [];
|
|
515
|
+
const isEmpty = value === null || value === void 0;
|
|
516
|
+
if (validation?.required && isEmpty) {
|
|
517
|
+
errors.push({
|
|
518
|
+
fieldId,
|
|
519
|
+
rule: "REQUIRED",
|
|
520
|
+
message: "Value is required"
|
|
521
|
+
});
|
|
522
|
+
return errors;
|
|
523
|
+
}
|
|
524
|
+
if (isEmpty) return errors;
|
|
525
|
+
if (typeof value !== "object" || typeof value.name !== "string" || typeof value.mimeType !== "string" || typeof value.size !== "number" || typeof value.url !== "string") errors.push({
|
|
526
|
+
fieldId,
|
|
527
|
+
rule: "TYPE",
|
|
528
|
+
message: "Must be a valid file object",
|
|
529
|
+
params: { expectedType: "file" }
|
|
530
|
+
});
|
|
531
|
+
return errors;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
//#endregion
|
|
535
|
+
//#region src/validators/number-validator.ts
|
|
536
|
+
var NumberValidator = class {
|
|
537
|
+
validate(ctx) {
|
|
538
|
+
const { fieldId, value } = ctx;
|
|
539
|
+
const validation = ctx.validation;
|
|
540
|
+
const errors = [];
|
|
541
|
+
const isEmpty = value === null || value === void 0;
|
|
542
|
+
if (validation?.required && isEmpty) {
|
|
543
|
+
errors.push({
|
|
544
|
+
fieldId,
|
|
545
|
+
rule: "REQUIRED",
|
|
546
|
+
message: "Value is required"
|
|
547
|
+
});
|
|
548
|
+
return errors;
|
|
549
|
+
}
|
|
550
|
+
if (isEmpty) return errors;
|
|
551
|
+
if (typeof value !== "number") {
|
|
552
|
+
errors.push({
|
|
553
|
+
fieldId,
|
|
554
|
+
rule: "TYPE",
|
|
555
|
+
message: "Must be a number",
|
|
556
|
+
params: { expectedType: "number" }
|
|
557
|
+
});
|
|
558
|
+
return errors;
|
|
559
|
+
}
|
|
560
|
+
if (validation?.min !== void 0 && value < validation.min) errors.push({
|
|
561
|
+
fieldId,
|
|
562
|
+
rule: "MIN",
|
|
563
|
+
message: `Must be at least ${validation.min}`,
|
|
564
|
+
params: {
|
|
565
|
+
min: validation.min,
|
|
566
|
+
actual: value
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
if (validation?.max !== void 0 && value > validation.max) errors.push({
|
|
570
|
+
fieldId,
|
|
571
|
+
rule: "MAX",
|
|
572
|
+
message: `Must be at most ${validation.max}`,
|
|
573
|
+
params: {
|
|
574
|
+
max: validation.max,
|
|
575
|
+
actual: value
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
return errors;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
//#endregion
|
|
582
|
+
//#region src/validators/select-validator.ts
|
|
583
|
+
var SelectValidator = class {
|
|
584
|
+
validate(ctx) {
|
|
585
|
+
const { fieldId, value } = ctx;
|
|
586
|
+
const validation = ctx.validation;
|
|
587
|
+
const options = ctx.options;
|
|
588
|
+
const errors = [];
|
|
589
|
+
const isEmpty = value === null || value === void 0;
|
|
590
|
+
if (validation?.required && isEmpty) {
|
|
591
|
+
errors.push({
|
|
592
|
+
fieldId,
|
|
593
|
+
rule: "REQUIRED",
|
|
594
|
+
message: "Value is required"
|
|
595
|
+
});
|
|
596
|
+
return errors;
|
|
597
|
+
}
|
|
598
|
+
if (isEmpty) return errors;
|
|
599
|
+
if (options && !options.some((opt) => opt.value === value)) errors.push({
|
|
600
|
+
fieldId,
|
|
601
|
+
rule: "INVALID_OPTION",
|
|
602
|
+
message: "Value is not a valid option"
|
|
603
|
+
});
|
|
604
|
+
return errors;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/validators/string-validator.ts
|
|
609
|
+
var StringValidator = class {
|
|
610
|
+
validate(ctx) {
|
|
611
|
+
const { fieldId, value } = ctx;
|
|
612
|
+
const validation = ctx.validation;
|
|
613
|
+
const errors = [];
|
|
614
|
+
const isEmpty = value === null || value === void 0 || value === "";
|
|
615
|
+
if (validation?.required && isEmpty) {
|
|
616
|
+
errors.push({
|
|
617
|
+
fieldId,
|
|
618
|
+
rule: "REQUIRED",
|
|
619
|
+
message: "Value is required"
|
|
620
|
+
});
|
|
621
|
+
return errors;
|
|
622
|
+
}
|
|
623
|
+
if (isEmpty) return errors;
|
|
624
|
+
if (typeof value !== "string") {
|
|
625
|
+
errors.push({
|
|
626
|
+
fieldId,
|
|
627
|
+
rule: "TYPE",
|
|
628
|
+
message: "Must be a string",
|
|
629
|
+
params: { expectedType: "string" }
|
|
630
|
+
});
|
|
631
|
+
return errors;
|
|
632
|
+
}
|
|
633
|
+
if (validation?.minLength !== void 0 && value.length < validation.minLength) errors.push({
|
|
634
|
+
fieldId,
|
|
635
|
+
rule: "MIN_LENGTH",
|
|
636
|
+
message: `Must be at least ${validation.minLength} characters`,
|
|
637
|
+
params: {
|
|
638
|
+
minLength: validation.minLength,
|
|
639
|
+
actual: value.length
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
if (validation?.maxLength !== void 0 && value.length > validation.maxLength) errors.push({
|
|
643
|
+
fieldId,
|
|
644
|
+
rule: "MAX_LENGTH",
|
|
645
|
+
message: `Must be at most ${validation.maxLength} characters`,
|
|
646
|
+
params: {
|
|
647
|
+
maxLength: validation.maxLength,
|
|
648
|
+
actual: value.length
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
if (validation?.pattern !== void 0) {
|
|
652
|
+
if (!new RegExp(validation.pattern).test(value)) errors.push({
|
|
653
|
+
fieldId,
|
|
654
|
+
rule: "PATTERN",
|
|
655
|
+
message: validation.patternMessage ?? "Value does not match the required pattern"
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return errors;
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region src/field-validator.ts
|
|
663
|
+
/**
|
|
664
|
+
* Validates form values against the schema's validation rules.
|
|
665
|
+
*
|
|
666
|
+
* **Which fields are validated:**
|
|
667
|
+
* - Only fields (not sections) are validated.
|
|
668
|
+
* - Hidden fields (those with `visibilityMap.get(id) === false`) are skipped
|
|
669
|
+
* entirely -- they produce no errors regardless of their value.
|
|
670
|
+
*
|
|
671
|
+
* **How each field type is validated:**
|
|
672
|
+
* - `string` -- `required`, `minLength`, `maxLength`, `pattern`.
|
|
673
|
+
* - `number` -- `required`, `min`, `max`.
|
|
674
|
+
* - `boolean` -- `required` (must be explicitly `true` or `false`).
|
|
675
|
+
* - `date` -- `required`, `minDate`, `maxDate`. Relative date boundaries
|
|
676
|
+
* are resolved against `now`.
|
|
677
|
+
* - `select` -- `required`, plus the value must be one of the defined options.
|
|
678
|
+
* - `array` -- `minItems`, `maxItems`, plus each item is validated
|
|
679
|
+
* individually according to the array's {@link ArrayItemDef}. Item-level
|
|
680
|
+
* errors carry an `itemIndex`.
|
|
681
|
+
*
|
|
682
|
+
* For all types, if `required` fails, no further rules are checked for that
|
|
683
|
+
* field (early return). If the value is empty/absent and `required` is not
|
|
684
|
+
* set, no errors are produced.
|
|
685
|
+
*/
|
|
686
|
+
var FieldValidator = class {
|
|
687
|
+
registry;
|
|
688
|
+
validators;
|
|
689
|
+
/**
|
|
690
|
+
* @param registry - The engine's field registry.
|
|
691
|
+
*/
|
|
692
|
+
constructor(registry) {
|
|
693
|
+
this.registry = registry;
|
|
694
|
+
this.validators = {
|
|
695
|
+
string: new StringValidator(),
|
|
696
|
+
number: new NumberValidator(),
|
|
697
|
+
boolean: new BooleanValidator(),
|
|
698
|
+
date: new DateValidator(),
|
|
699
|
+
select: new SelectValidator(),
|
|
700
|
+
array: new ArrayValidator(),
|
|
701
|
+
file: new FileValidator()
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Validates form values against the schema's validation rules.
|
|
706
|
+
*
|
|
707
|
+
* @param values - The form values to validate, keyed by stringified field id.
|
|
708
|
+
* @param visibilityMap - Pre-computed visibility map for all items.
|
|
709
|
+
* @param now - Reference date for resolving relative date expressions.
|
|
710
|
+
* Defaults to `new Date()`.
|
|
711
|
+
* @returns A {@link FormValidationResult} with `valid: true` when no errors
|
|
712
|
+
* exist, or `valid: false` with a populated `fieldErrors` map.
|
|
713
|
+
*/
|
|
714
|
+
validate(values, visibilityMap, now = /* @__PURE__ */ new Date()) {
|
|
715
|
+
const fieldErrors = /* @__PURE__ */ new Map();
|
|
716
|
+
for (const [id, entry] of this.registry) {
|
|
717
|
+
if (entry.type === "section") continue;
|
|
718
|
+
if (visibilityMap.get(id) === false) continue;
|
|
719
|
+
const value = values[String(id)];
|
|
720
|
+
const errors = this.validateField(id, value, entry, now);
|
|
721
|
+
if (errors.length > 0) fieldErrors.set(id, errors);
|
|
722
|
+
}
|
|
723
|
+
return {
|
|
724
|
+
valid: fieldErrors.size === 0,
|
|
725
|
+
fieldErrors
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
validateField(fieldId, value, entry, now) {
|
|
729
|
+
const validator = this.validators[entry.type];
|
|
730
|
+
if (!validator) return [];
|
|
731
|
+
if (entry.type === "array") {
|
|
732
|
+
const ctx = {
|
|
733
|
+
fieldId,
|
|
734
|
+
value,
|
|
735
|
+
validation: entry.validation,
|
|
736
|
+
now,
|
|
737
|
+
item: entry.item,
|
|
738
|
+
validateField: this.validateField.bind(this)
|
|
739
|
+
};
|
|
740
|
+
return validator.validate(ctx);
|
|
741
|
+
}
|
|
742
|
+
return validator.validate({
|
|
743
|
+
fieldId,
|
|
744
|
+
value,
|
|
745
|
+
validation: entry.validation,
|
|
746
|
+
now,
|
|
747
|
+
options: entry.options
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region src/form-definition-editor.ts
|
|
753
|
+
/**
|
|
754
|
+
* Mutable editor for building and modifying a {@link FormDefinition}.
|
|
755
|
+
*
|
|
756
|
+
* Operates directly on the definition tree. All mutating methods return
|
|
757
|
+
* `this` for fluent chaining.
|
|
758
|
+
*
|
|
759
|
+
* @example
|
|
760
|
+
* ```ts
|
|
761
|
+
* const editor = new FormDefinitionEditor({
|
|
762
|
+
* id: 'my-form', version: '1.0.0', title: 'My Form', content: [],
|
|
763
|
+
* })
|
|
764
|
+
* editor
|
|
765
|
+
* .addField({ type: 'string', label: 'Name', validation: { required: true } })
|
|
766
|
+
* .addSection({ type: 'section', title: 'Details' })
|
|
767
|
+
* .addField({ type: 'number', label: 'Age' }, 2) // into section id=2
|
|
768
|
+
*
|
|
769
|
+
* const definition = editor.toJSON()
|
|
770
|
+
* ```
|
|
771
|
+
*/
|
|
772
|
+
var FormDefinitionEditor = class {
|
|
773
|
+
definition;
|
|
774
|
+
constructor(definition) {
|
|
775
|
+
this.definition = JSON.parse(JSON.stringify(definition));
|
|
776
|
+
}
|
|
777
|
+
setTitle(title) {
|
|
778
|
+
this.definition.title = title;
|
|
779
|
+
return this;
|
|
780
|
+
}
|
|
781
|
+
setDescription(description) {
|
|
782
|
+
if (description === void 0) delete this.definition.description;
|
|
783
|
+
else this.definition.description = description;
|
|
784
|
+
return this;
|
|
785
|
+
}
|
|
786
|
+
setVersion(version) {
|
|
787
|
+
this.definition.version = version;
|
|
788
|
+
return this;
|
|
789
|
+
}
|
|
790
|
+
setId(id) {
|
|
791
|
+
this.definition.id = id;
|
|
792
|
+
return this;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Returns the next available numeric id (max existing + 1).
|
|
796
|
+
*/
|
|
797
|
+
nextId() {
|
|
798
|
+
let max = 0;
|
|
799
|
+
this.walkAll(this.definition.content, (item) => {
|
|
800
|
+
if (item.id > max) max = item.id;
|
|
801
|
+
});
|
|
802
|
+
return max + 1;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Adds a field to the form.
|
|
806
|
+
*
|
|
807
|
+
* @param descriptor - Field properties. `id` is auto-assigned if omitted.
|
|
808
|
+
* @param parentId - Section id to add into. `undefined` for top-level.
|
|
809
|
+
* @param index - Position within the parent's content array. Appends if omitted.
|
|
810
|
+
* @returns `this` for chaining.
|
|
811
|
+
* @throws If `parentId` references a non-existent or non-section item, or if the id already exists.
|
|
812
|
+
*/
|
|
813
|
+
addField(descriptor, parentId, index) {
|
|
814
|
+
const id = descriptor.id ?? this.nextId();
|
|
815
|
+
this.assertIdAvailable(id);
|
|
816
|
+
const field = {
|
|
817
|
+
...descriptor,
|
|
818
|
+
id
|
|
819
|
+
};
|
|
820
|
+
this.insertItem(field, parentId, index);
|
|
821
|
+
return this;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Adds a section to the form.
|
|
825
|
+
*
|
|
826
|
+
* @param descriptor - Section properties. `id` is auto-assigned if omitted.
|
|
827
|
+
* @param parentId - Parent section id. `undefined` for top-level.
|
|
828
|
+
* @param index - Position within the parent's content array. Appends if omitted.
|
|
829
|
+
* @returns `this` for chaining.
|
|
830
|
+
* @throws If `parentId` references a non-existent or non-section item, or if the id already exists.
|
|
831
|
+
*/
|
|
832
|
+
addSection(descriptor, parentId, index) {
|
|
833
|
+
const id = descriptor.id ?? this.nextId();
|
|
834
|
+
this.assertIdAvailable(id);
|
|
835
|
+
const section = {
|
|
836
|
+
...descriptor,
|
|
837
|
+
id,
|
|
838
|
+
content: descriptor.content ?? []
|
|
839
|
+
};
|
|
840
|
+
this.insertItem(section, parentId, index);
|
|
841
|
+
return this;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Updates properties of an existing field.
|
|
845
|
+
*
|
|
846
|
+
* Cannot change `id` or `type`. Use {@link removeItem} + {@link addField}
|
|
847
|
+
* to change the type.
|
|
848
|
+
*/
|
|
849
|
+
updateField(id, updates) {
|
|
850
|
+
const item = this.findItem(id);
|
|
851
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
852
|
+
if (item.type === "section") throw new Error(`Item ${id} is a section, not a field`);
|
|
853
|
+
Object.assign(item, updates);
|
|
854
|
+
return this;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Updates properties of an existing section.
|
|
858
|
+
*
|
|
859
|
+
* Cannot change `id`, `type`, or `content` directly. Use add/remove methods
|
|
860
|
+
* for content manipulation.
|
|
861
|
+
*/
|
|
862
|
+
updateSection(id, updates) {
|
|
863
|
+
const item = this.findItem(id);
|
|
864
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
865
|
+
if (item.type !== "section") throw new Error(`Item ${id} is not a section`);
|
|
866
|
+
Object.assign(item, updates);
|
|
867
|
+
return this;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Removes a field or section (and all its descendants) by id.
|
|
871
|
+
*
|
|
872
|
+
* @returns `this` for chaining.
|
|
873
|
+
* @throws If the id is not found.
|
|
874
|
+
*/
|
|
875
|
+
removeItem(id) {
|
|
876
|
+
if (!this.removeFromContent(this.definition.content, id)) throw new Error(`Item with id ${id} not found`);
|
|
877
|
+
return this;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Moves an item to a new parent and/or position.
|
|
881
|
+
*
|
|
882
|
+
* @param id - Id of the item to move.
|
|
883
|
+
* @param targetParentId - Destination section id, or `undefined` for top-level.
|
|
884
|
+
* @param index - Position in the target content array. Appends if omitted.
|
|
885
|
+
*/
|
|
886
|
+
moveItem(id, targetParentId, index) {
|
|
887
|
+
const item = this.findItem(id);
|
|
888
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
889
|
+
if (targetParentId !== void 0 && item.type === "section") {
|
|
890
|
+
if (targetParentId === id) throw new Error("Cannot move a section into itself");
|
|
891
|
+
if (this.collectDescendantIds(item).has(targetParentId)) throw new Error("Cannot move a section into its own descendant");
|
|
892
|
+
}
|
|
893
|
+
const clone = JSON.parse(JSON.stringify(item));
|
|
894
|
+
this.removeFromContent(this.definition.content, id);
|
|
895
|
+
this.insertItem(clone, targetParentId, index);
|
|
896
|
+
return this;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Returns a flat list of all content items (fields + sections) with parent info.
|
|
900
|
+
*/
|
|
901
|
+
listAll() {
|
|
902
|
+
const result = [];
|
|
903
|
+
this.walkAllWithParent(this.definition.content, void 0, (item, parentId) => {
|
|
904
|
+
result.push({
|
|
905
|
+
id: item.id,
|
|
906
|
+
type: item.type,
|
|
907
|
+
label: item.type !== "section" ? item.label : void 0,
|
|
908
|
+
title: item.type === "section" ? item.title : void 0,
|
|
909
|
+
parentId
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
return result;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Returns a flat list of all fields (excludes sections).
|
|
916
|
+
*/
|
|
917
|
+
listFields() {
|
|
918
|
+
return this.listAll().filter((i) => i.type !== "section");
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Returns a flat list of all sections.
|
|
922
|
+
*/
|
|
923
|
+
listSections() {
|
|
924
|
+
return this.listAll().filter((i) => i.type === "section");
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Returns the content item with the given id, or `undefined` if not found.
|
|
928
|
+
*/
|
|
929
|
+
getItem(id) {
|
|
930
|
+
return this.findItem(id) ?? void 0;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Sets or clears the validation rules for a field.
|
|
934
|
+
*/
|
|
935
|
+
setValidation(id, validation) {
|
|
936
|
+
const item = this.findItem(id);
|
|
937
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
938
|
+
if (item.type === "section") throw new Error("Sections do not have validation");
|
|
939
|
+
const field = item;
|
|
940
|
+
if (validation === void 0) delete field.validation;
|
|
941
|
+
else field.validation = validation;
|
|
942
|
+
return this;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Sets or clears the visibility condition for a field or section.
|
|
946
|
+
*/
|
|
947
|
+
setCondition(id, condition) {
|
|
948
|
+
const item = this.findItem(id);
|
|
949
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
950
|
+
if (condition === void 0) delete item.condition;
|
|
951
|
+
else item.condition = condition;
|
|
952
|
+
return this;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Sets the select options for a `select` field.
|
|
956
|
+
*/
|
|
957
|
+
setOptions(id, options) {
|
|
958
|
+
const item = this.findItem(id);
|
|
959
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
960
|
+
if (item.type !== "select") throw new Error(`Field ${id} is not a select field`);
|
|
961
|
+
const field = item;
|
|
962
|
+
field.options = options;
|
|
963
|
+
return this;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Sets the item definition for an `array` field.
|
|
967
|
+
*/
|
|
968
|
+
setArrayItem(id, itemDef) {
|
|
969
|
+
const item = this.findItem(id);
|
|
970
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
971
|
+
if (item.type !== "array") throw new Error(`Field ${id} is not an array field`);
|
|
972
|
+
const field = item;
|
|
973
|
+
field.item = itemDef;
|
|
974
|
+
return this;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Sets the label for a field.
|
|
978
|
+
*/
|
|
979
|
+
setLabel(id, label) {
|
|
980
|
+
const item = this.findItem(id);
|
|
981
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
982
|
+
if (item.type === "section") throw new Error("Sections use title, not label");
|
|
983
|
+
const field = item;
|
|
984
|
+
field.label = label;
|
|
985
|
+
return this;
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Sets the description for a field or section.
|
|
989
|
+
*/
|
|
990
|
+
setFieldDescription(id, description) {
|
|
991
|
+
const item = this.findItem(id);
|
|
992
|
+
if (!item) throw new Error(`Item with id ${id} not found`);
|
|
993
|
+
if (description === void 0) delete item.description;
|
|
994
|
+
else item.description = description;
|
|
995
|
+
return this;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Returns a deep clone of the current form definition.
|
|
999
|
+
*/
|
|
1000
|
+
toJSON() {
|
|
1001
|
+
return JSON.parse(JSON.stringify(this.definition));
|
|
1002
|
+
}
|
|
1003
|
+
findItem(id) {
|
|
1004
|
+
let found;
|
|
1005
|
+
this.walkAll(this.definition.content, (item) => {
|
|
1006
|
+
if (item.id === id) found = item;
|
|
1007
|
+
});
|
|
1008
|
+
return found;
|
|
1009
|
+
}
|
|
1010
|
+
assertIdAvailable(id) {
|
|
1011
|
+
if (this.findItem(id)) throw new Error(`Item with id ${id} already exists`);
|
|
1012
|
+
}
|
|
1013
|
+
insertItem(item, parentId, index) {
|
|
1014
|
+
const target = this.getTargetContent(parentId);
|
|
1015
|
+
if (index !== void 0 && index >= 0 && index < target.length) target.splice(index, 0, item);
|
|
1016
|
+
else target.push(item);
|
|
1017
|
+
}
|
|
1018
|
+
getTargetContent(parentId) {
|
|
1019
|
+
if (parentId === void 0) return this.definition.content;
|
|
1020
|
+
const parent = this.findItem(parentId);
|
|
1021
|
+
if (!parent) throw new Error(`Parent section with id ${parentId} not found`);
|
|
1022
|
+
if (parent.type !== "section") throw new Error(`Item ${parentId} is not a section`);
|
|
1023
|
+
return parent.content;
|
|
1024
|
+
}
|
|
1025
|
+
removeFromContent(content, id) {
|
|
1026
|
+
const idx = content.findIndex((item) => item.id === id);
|
|
1027
|
+
if (idx !== -1) {
|
|
1028
|
+
content.splice(idx, 1);
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
for (const item of content) if (item.type === "section") {
|
|
1032
|
+
if (this.removeFromContent(item.content, id)) return true;
|
|
1033
|
+
}
|
|
1034
|
+
return false;
|
|
1035
|
+
}
|
|
1036
|
+
walkAll(content, fn) {
|
|
1037
|
+
for (const item of content) {
|
|
1038
|
+
fn(item);
|
|
1039
|
+
if (item.type === "section") this.walkAll(item.content, fn);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
walkAllWithParent(content, parentId, fn) {
|
|
1043
|
+
for (const item of content) {
|
|
1044
|
+
fn(item, parentId);
|
|
1045
|
+
if (item.type === "section") this.walkAllWithParent(item.content, item.id, fn);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
collectDescendantIds(section) {
|
|
1049
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1050
|
+
this.walkAll(section.content, (item) => ids.add(item.id));
|
|
1051
|
+
return ids;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
//#endregion
|
|
1055
|
+
//#region src/form-definition-validator.ts
|
|
1056
|
+
const validateFn = new Ajv2020({ allErrors: true }).compile({
|
|
1057
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
1058
|
+
title: "Bluprynt Form Definition",
|
|
1059
|
+
type: "object",
|
|
1060
|
+
required: [
|
|
1061
|
+
"id",
|
|
1062
|
+
"version",
|
|
1063
|
+
"title",
|
|
1064
|
+
"content"
|
|
1065
|
+
],
|
|
1066
|
+
additionalProperties: false,
|
|
1067
|
+
properties: {
|
|
1068
|
+
"id": { "type": "string" },
|
|
1069
|
+
"version": {
|
|
1070
|
+
"type": "string",
|
|
1071
|
+
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
|
1072
|
+
},
|
|
1073
|
+
"title": { "type": "string" },
|
|
1074
|
+
"description": { "type": "string" },
|
|
1075
|
+
"content": {
|
|
1076
|
+
"type": "array",
|
|
1077
|
+
"minItems": 1,
|
|
1078
|
+
"items": { "$ref": "#/$defs/contentItem" }
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
$defs: {
|
|
1082
|
+
"conditionNoValue": {
|
|
1083
|
+
"type": "object",
|
|
1084
|
+
"required": ["field", "op"],
|
|
1085
|
+
"properties": {
|
|
1086
|
+
"field": {
|
|
1087
|
+
"type": "integer",
|
|
1088
|
+
"minimum": 1
|
|
1089
|
+
},
|
|
1090
|
+
"op": {
|
|
1091
|
+
"type": "string",
|
|
1092
|
+
"enum": ["set", "notset"]
|
|
1093
|
+
}
|
|
1094
|
+
},
|
|
1095
|
+
"additionalProperties": false
|
|
1096
|
+
},
|
|
1097
|
+
"conditionWithValue": {
|
|
1098
|
+
"type": "object",
|
|
1099
|
+
"required": [
|
|
1100
|
+
"field",
|
|
1101
|
+
"op",
|
|
1102
|
+
"value"
|
|
1103
|
+
],
|
|
1104
|
+
"properties": {
|
|
1105
|
+
"field": {
|
|
1106
|
+
"type": "integer",
|
|
1107
|
+
"minimum": 1
|
|
1108
|
+
},
|
|
1109
|
+
"op": {
|
|
1110
|
+
"type": "string",
|
|
1111
|
+
"enum": [
|
|
1112
|
+
"eq",
|
|
1113
|
+
"ne",
|
|
1114
|
+
"lt",
|
|
1115
|
+
"gt",
|
|
1116
|
+
"lte",
|
|
1117
|
+
"gte"
|
|
1118
|
+
]
|
|
1119
|
+
},
|
|
1120
|
+
"value": {}
|
|
1121
|
+
},
|
|
1122
|
+
"additionalProperties": false
|
|
1123
|
+
},
|
|
1124
|
+
"conditionWithArrayValue": {
|
|
1125
|
+
"type": "object",
|
|
1126
|
+
"required": [
|
|
1127
|
+
"field",
|
|
1128
|
+
"op",
|
|
1129
|
+
"value"
|
|
1130
|
+
],
|
|
1131
|
+
"properties": {
|
|
1132
|
+
"field": {
|
|
1133
|
+
"type": "integer",
|
|
1134
|
+
"minimum": 1
|
|
1135
|
+
},
|
|
1136
|
+
"op": {
|
|
1137
|
+
"type": "string",
|
|
1138
|
+
"enum": ["in", "notin"]
|
|
1139
|
+
},
|
|
1140
|
+
"value": { "type": "array" }
|
|
1141
|
+
},
|
|
1142
|
+
"additionalProperties": false
|
|
1143
|
+
},
|
|
1144
|
+
"simpleCondition": { "oneOf": [
|
|
1145
|
+
{ "$ref": "#/$defs/conditionNoValue" },
|
|
1146
|
+
{ "$ref": "#/$defs/conditionWithValue" },
|
|
1147
|
+
{ "$ref": "#/$defs/conditionWithArrayValue" }
|
|
1148
|
+
] },
|
|
1149
|
+
"condition": { "oneOf": [
|
|
1150
|
+
{ "$ref": "#/$defs/simpleCondition" },
|
|
1151
|
+
{
|
|
1152
|
+
"type": "object",
|
|
1153
|
+
"required": ["and"],
|
|
1154
|
+
"properties": { "and": {
|
|
1155
|
+
"type": "array",
|
|
1156
|
+
"minItems": 1,
|
|
1157
|
+
"items": { "$ref": "#/$defs/condition" }
|
|
1158
|
+
} },
|
|
1159
|
+
"additionalProperties": false
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
"type": "object",
|
|
1163
|
+
"required": ["or"],
|
|
1164
|
+
"properties": { "or": {
|
|
1165
|
+
"type": "array",
|
|
1166
|
+
"minItems": 1,
|
|
1167
|
+
"items": { "$ref": "#/$defs/condition" }
|
|
1168
|
+
} },
|
|
1169
|
+
"additionalProperties": false
|
|
1170
|
+
}
|
|
1171
|
+
] },
|
|
1172
|
+
"stringValidation": {
|
|
1173
|
+
"type": "object",
|
|
1174
|
+
"properties": {
|
|
1175
|
+
"required": { "type": "boolean" },
|
|
1176
|
+
"minLength": {
|
|
1177
|
+
"type": "integer",
|
|
1178
|
+
"minimum": 0
|
|
1179
|
+
},
|
|
1180
|
+
"maxLength": {
|
|
1181
|
+
"type": "integer",
|
|
1182
|
+
"minimum": 1
|
|
1183
|
+
},
|
|
1184
|
+
"pattern": { "type": "string" },
|
|
1185
|
+
"patternMessage": { "type": "string" }
|
|
1186
|
+
},
|
|
1187
|
+
"additionalProperties": false
|
|
1188
|
+
},
|
|
1189
|
+
"numberValidation": {
|
|
1190
|
+
"type": "object",
|
|
1191
|
+
"properties": {
|
|
1192
|
+
"required": { "type": "boolean" },
|
|
1193
|
+
"min": { "type": "number" },
|
|
1194
|
+
"max": { "type": "number" }
|
|
1195
|
+
},
|
|
1196
|
+
"additionalProperties": false
|
|
1197
|
+
},
|
|
1198
|
+
"booleanValidation": {
|
|
1199
|
+
"type": "object",
|
|
1200
|
+
"properties": { "required": { "type": "boolean" } },
|
|
1201
|
+
"additionalProperties": false
|
|
1202
|
+
},
|
|
1203
|
+
"dateValidation": {
|
|
1204
|
+
"type": "object",
|
|
1205
|
+
"properties": {
|
|
1206
|
+
"required": { "type": "boolean" },
|
|
1207
|
+
"minDate": {
|
|
1208
|
+
"type": "string",
|
|
1209
|
+
"pattern": "^([+-]\\d+[dwmy]|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)$"
|
|
1210
|
+
},
|
|
1211
|
+
"maxDate": {
|
|
1212
|
+
"type": "string",
|
|
1213
|
+
"pattern": "^([+-]\\d+[dwmy]|\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z)$"
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
"additionalProperties": false
|
|
1217
|
+
},
|
|
1218
|
+
"selectValidation": {
|
|
1219
|
+
"type": "object",
|
|
1220
|
+
"properties": { "required": { "type": "boolean" } },
|
|
1221
|
+
"additionalProperties": false
|
|
1222
|
+
},
|
|
1223
|
+
"arrayValidation": {
|
|
1224
|
+
"type": "object",
|
|
1225
|
+
"properties": {
|
|
1226
|
+
"minItems": {
|
|
1227
|
+
"type": "integer",
|
|
1228
|
+
"minimum": 0
|
|
1229
|
+
},
|
|
1230
|
+
"maxItems": {
|
|
1231
|
+
"type": "integer",
|
|
1232
|
+
"minimum": 1
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
"additionalProperties": false
|
|
1236
|
+
},
|
|
1237
|
+
"fileValidation": {
|
|
1238
|
+
"type": "object",
|
|
1239
|
+
"properties": { "required": { "type": "boolean" } },
|
|
1240
|
+
"additionalProperties": false
|
|
1241
|
+
},
|
|
1242
|
+
"selectOption": {
|
|
1243
|
+
"type": "object",
|
|
1244
|
+
"required": ["value", "label"],
|
|
1245
|
+
"properties": {
|
|
1246
|
+
"value": { "oneOf": [{ "type": "string" }, { "type": "number" }] },
|
|
1247
|
+
"label": { "type": "string" }
|
|
1248
|
+
},
|
|
1249
|
+
"additionalProperties": false
|
|
1250
|
+
},
|
|
1251
|
+
"arrayItemString": {
|
|
1252
|
+
"type": "object",
|
|
1253
|
+
"required": ["type", "label"],
|
|
1254
|
+
"properties": {
|
|
1255
|
+
"type": { "const": "string" },
|
|
1256
|
+
"label": { "type": "string" },
|
|
1257
|
+
"description": { "type": "string" },
|
|
1258
|
+
"validation": { "$ref": "#/$defs/stringValidation" }
|
|
1259
|
+
},
|
|
1260
|
+
"additionalProperties": false
|
|
1261
|
+
},
|
|
1262
|
+
"arrayItemNumber": {
|
|
1263
|
+
"type": "object",
|
|
1264
|
+
"required": ["type", "label"],
|
|
1265
|
+
"properties": {
|
|
1266
|
+
"type": { "const": "number" },
|
|
1267
|
+
"label": { "type": "string" },
|
|
1268
|
+
"description": { "type": "string" },
|
|
1269
|
+
"validation": { "$ref": "#/$defs/numberValidation" }
|
|
1270
|
+
},
|
|
1271
|
+
"additionalProperties": false
|
|
1272
|
+
},
|
|
1273
|
+
"arrayItemBoolean": {
|
|
1274
|
+
"type": "object",
|
|
1275
|
+
"required": ["type", "label"],
|
|
1276
|
+
"properties": {
|
|
1277
|
+
"type": { "const": "boolean" },
|
|
1278
|
+
"label": { "type": "string" },
|
|
1279
|
+
"description": { "type": "string" },
|
|
1280
|
+
"validation": { "$ref": "#/$defs/booleanValidation" }
|
|
1281
|
+
},
|
|
1282
|
+
"additionalProperties": false
|
|
1283
|
+
},
|
|
1284
|
+
"arrayItemDate": {
|
|
1285
|
+
"type": "object",
|
|
1286
|
+
"required": ["type", "label"],
|
|
1287
|
+
"properties": {
|
|
1288
|
+
"type": { "const": "date" },
|
|
1289
|
+
"label": { "type": "string" },
|
|
1290
|
+
"description": { "type": "string" },
|
|
1291
|
+
"validation": { "$ref": "#/$defs/dateValidation" }
|
|
1292
|
+
},
|
|
1293
|
+
"additionalProperties": false
|
|
1294
|
+
},
|
|
1295
|
+
"arrayItemSelect": {
|
|
1296
|
+
"type": "object",
|
|
1297
|
+
"required": [
|
|
1298
|
+
"type",
|
|
1299
|
+
"label",
|
|
1300
|
+
"options"
|
|
1301
|
+
],
|
|
1302
|
+
"properties": {
|
|
1303
|
+
"type": { "const": "select" },
|
|
1304
|
+
"label": { "type": "string" },
|
|
1305
|
+
"description": { "type": "string" },
|
|
1306
|
+
"options": {
|
|
1307
|
+
"type": "array",
|
|
1308
|
+
"minItems": 1,
|
|
1309
|
+
"items": { "$ref": "#/$defs/selectOption" }
|
|
1310
|
+
},
|
|
1311
|
+
"validation": { "$ref": "#/$defs/selectValidation" }
|
|
1312
|
+
},
|
|
1313
|
+
"additionalProperties": false
|
|
1314
|
+
},
|
|
1315
|
+
"arrayItemFile": {
|
|
1316
|
+
"type": "object",
|
|
1317
|
+
"required": ["type", "label"],
|
|
1318
|
+
"properties": {
|
|
1319
|
+
"type": { "const": "file" },
|
|
1320
|
+
"label": { "type": "string" },
|
|
1321
|
+
"description": { "type": "string" },
|
|
1322
|
+
"validation": { "$ref": "#/$defs/fileValidation" }
|
|
1323
|
+
},
|
|
1324
|
+
"additionalProperties": false
|
|
1325
|
+
},
|
|
1326
|
+
"arrayItem": { "oneOf": [
|
|
1327
|
+
{ "$ref": "#/$defs/arrayItemString" },
|
|
1328
|
+
{ "$ref": "#/$defs/arrayItemNumber" },
|
|
1329
|
+
{ "$ref": "#/$defs/arrayItemBoolean" },
|
|
1330
|
+
{ "$ref": "#/$defs/arrayItemDate" },
|
|
1331
|
+
{ "$ref": "#/$defs/arrayItemSelect" },
|
|
1332
|
+
{ "$ref": "#/$defs/arrayItemFile" }
|
|
1333
|
+
] },
|
|
1334
|
+
"stringField": {
|
|
1335
|
+
"type": "object",
|
|
1336
|
+
"required": [
|
|
1337
|
+
"id",
|
|
1338
|
+
"type",
|
|
1339
|
+
"label"
|
|
1340
|
+
],
|
|
1341
|
+
"properties": {
|
|
1342
|
+
"id": {
|
|
1343
|
+
"type": "integer",
|
|
1344
|
+
"minimum": 1
|
|
1345
|
+
},
|
|
1346
|
+
"type": { "const": "string" },
|
|
1347
|
+
"label": { "type": "string" },
|
|
1348
|
+
"description": { "type": "string" },
|
|
1349
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1350
|
+
"validation": { "$ref": "#/$defs/stringValidation" }
|
|
1351
|
+
},
|
|
1352
|
+
"additionalProperties": false
|
|
1353
|
+
},
|
|
1354
|
+
"numberField": {
|
|
1355
|
+
"type": "object",
|
|
1356
|
+
"required": [
|
|
1357
|
+
"id",
|
|
1358
|
+
"type",
|
|
1359
|
+
"label"
|
|
1360
|
+
],
|
|
1361
|
+
"properties": {
|
|
1362
|
+
"id": {
|
|
1363
|
+
"type": "integer",
|
|
1364
|
+
"minimum": 1
|
|
1365
|
+
},
|
|
1366
|
+
"type": { "const": "number" },
|
|
1367
|
+
"label": { "type": "string" },
|
|
1368
|
+
"description": { "type": "string" },
|
|
1369
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1370
|
+
"validation": { "$ref": "#/$defs/numberValidation" }
|
|
1371
|
+
},
|
|
1372
|
+
"additionalProperties": false
|
|
1373
|
+
},
|
|
1374
|
+
"booleanField": {
|
|
1375
|
+
"type": "object",
|
|
1376
|
+
"required": [
|
|
1377
|
+
"id",
|
|
1378
|
+
"type",
|
|
1379
|
+
"label"
|
|
1380
|
+
],
|
|
1381
|
+
"properties": {
|
|
1382
|
+
"id": {
|
|
1383
|
+
"type": "integer",
|
|
1384
|
+
"minimum": 1
|
|
1385
|
+
},
|
|
1386
|
+
"type": { "const": "boolean" },
|
|
1387
|
+
"label": { "type": "string" },
|
|
1388
|
+
"description": { "type": "string" },
|
|
1389
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1390
|
+
"validation": { "$ref": "#/$defs/booleanValidation" }
|
|
1391
|
+
},
|
|
1392
|
+
"additionalProperties": false
|
|
1393
|
+
},
|
|
1394
|
+
"dateField": {
|
|
1395
|
+
"type": "object",
|
|
1396
|
+
"required": [
|
|
1397
|
+
"id",
|
|
1398
|
+
"type",
|
|
1399
|
+
"label"
|
|
1400
|
+
],
|
|
1401
|
+
"properties": {
|
|
1402
|
+
"id": {
|
|
1403
|
+
"type": "integer",
|
|
1404
|
+
"minimum": 1
|
|
1405
|
+
},
|
|
1406
|
+
"type": { "const": "date" },
|
|
1407
|
+
"label": { "type": "string" },
|
|
1408
|
+
"description": { "type": "string" },
|
|
1409
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1410
|
+
"validation": { "$ref": "#/$defs/dateValidation" }
|
|
1411
|
+
},
|
|
1412
|
+
"additionalProperties": false
|
|
1413
|
+
},
|
|
1414
|
+
"selectField": {
|
|
1415
|
+
"type": "object",
|
|
1416
|
+
"required": [
|
|
1417
|
+
"id",
|
|
1418
|
+
"type",
|
|
1419
|
+
"label",
|
|
1420
|
+
"options"
|
|
1421
|
+
],
|
|
1422
|
+
"properties": {
|
|
1423
|
+
"id": {
|
|
1424
|
+
"type": "integer",
|
|
1425
|
+
"minimum": 1
|
|
1426
|
+
},
|
|
1427
|
+
"type": { "const": "select" },
|
|
1428
|
+
"label": { "type": "string" },
|
|
1429
|
+
"description": { "type": "string" },
|
|
1430
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1431
|
+
"options": {
|
|
1432
|
+
"type": "array",
|
|
1433
|
+
"minItems": 1,
|
|
1434
|
+
"items": { "$ref": "#/$defs/selectOption" }
|
|
1435
|
+
},
|
|
1436
|
+
"validation": { "$ref": "#/$defs/selectValidation" }
|
|
1437
|
+
},
|
|
1438
|
+
"additionalProperties": false
|
|
1439
|
+
},
|
|
1440
|
+
"arrayField": {
|
|
1441
|
+
"type": "object",
|
|
1442
|
+
"required": [
|
|
1443
|
+
"id",
|
|
1444
|
+
"type",
|
|
1445
|
+
"label",
|
|
1446
|
+
"item"
|
|
1447
|
+
],
|
|
1448
|
+
"properties": {
|
|
1449
|
+
"id": {
|
|
1450
|
+
"type": "integer",
|
|
1451
|
+
"minimum": 1
|
|
1452
|
+
},
|
|
1453
|
+
"type": { "const": "array" },
|
|
1454
|
+
"label": { "type": "string" },
|
|
1455
|
+
"description": { "type": "string" },
|
|
1456
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1457
|
+
"item": { "$ref": "#/$defs/arrayItem" },
|
|
1458
|
+
"validation": { "$ref": "#/$defs/arrayValidation" }
|
|
1459
|
+
},
|
|
1460
|
+
"additionalProperties": false
|
|
1461
|
+
},
|
|
1462
|
+
"fileField": {
|
|
1463
|
+
"type": "object",
|
|
1464
|
+
"required": [
|
|
1465
|
+
"id",
|
|
1466
|
+
"type",
|
|
1467
|
+
"label"
|
|
1468
|
+
],
|
|
1469
|
+
"properties": {
|
|
1470
|
+
"id": {
|
|
1471
|
+
"type": "integer",
|
|
1472
|
+
"minimum": 1
|
|
1473
|
+
},
|
|
1474
|
+
"type": { "const": "file" },
|
|
1475
|
+
"label": { "type": "string" },
|
|
1476
|
+
"description": { "type": "string" },
|
|
1477
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1478
|
+
"validation": { "$ref": "#/$defs/fileValidation" }
|
|
1479
|
+
},
|
|
1480
|
+
"additionalProperties": false
|
|
1481
|
+
},
|
|
1482
|
+
"fieldItem": { "oneOf": [
|
|
1483
|
+
{ "$ref": "#/$defs/stringField" },
|
|
1484
|
+
{ "$ref": "#/$defs/numberField" },
|
|
1485
|
+
{ "$ref": "#/$defs/booleanField" },
|
|
1486
|
+
{ "$ref": "#/$defs/dateField" },
|
|
1487
|
+
{ "$ref": "#/$defs/selectField" },
|
|
1488
|
+
{ "$ref": "#/$defs/arrayField" },
|
|
1489
|
+
{ "$ref": "#/$defs/fileField" }
|
|
1490
|
+
] },
|
|
1491
|
+
"section": {
|
|
1492
|
+
"type": "object",
|
|
1493
|
+
"required": [
|
|
1494
|
+
"id",
|
|
1495
|
+
"type",
|
|
1496
|
+
"title",
|
|
1497
|
+
"content"
|
|
1498
|
+
],
|
|
1499
|
+
"properties": {
|
|
1500
|
+
"id": {
|
|
1501
|
+
"type": "integer",
|
|
1502
|
+
"minimum": 1
|
|
1503
|
+
},
|
|
1504
|
+
"type": { "const": "section" },
|
|
1505
|
+
"title": { "type": "string" },
|
|
1506
|
+
"description": { "type": "string" },
|
|
1507
|
+
"condition": { "$ref": "#/$defs/condition" },
|
|
1508
|
+
"content": {
|
|
1509
|
+
"type": "array",
|
|
1510
|
+
"minItems": 1,
|
|
1511
|
+
"items": { "$ref": "#/$defs/contentItem" }
|
|
1512
|
+
}
|
|
1513
|
+
},
|
|
1514
|
+
"additionalProperties": false
|
|
1515
|
+
},
|
|
1516
|
+
"contentItem": { "oneOf": [{ "$ref": "#/$defs/fieldItem" }, { "$ref": "#/$defs/section" }] }
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
/**
|
|
1520
|
+
* Validates form definitions at both the structural (JSON Schema) and
|
|
1521
|
+
* semantic levels.
|
|
1522
|
+
*
|
|
1523
|
+
* Used by {@link FormEngine} during construction before building the engine.
|
|
1524
|
+
*
|
|
1525
|
+
* ### Schema validation (`validateSchema`)
|
|
1526
|
+
* Validates raw input against the form definition JSON Schema. Returns
|
|
1527
|
+
* `SCHEMA_INVALID` issues for every violation found.
|
|
1528
|
+
*
|
|
1529
|
+
* ### Semantic validation (`validate`)
|
|
1530
|
+
* Checks for logical issues that go beyond JSON schema validity:
|
|
1531
|
+
* 1. **Duplicate IDs** (`DUPLICATE_ID`) -- every content item id must be unique.
|
|
1532
|
+
* 2. **Nesting depth** (`NESTING_DEPTH`) -- sections may not be nested more
|
|
1533
|
+
* than 3 levels deep.
|
|
1534
|
+
* 3. **Unknown field references** (`UNKNOWN_FIELD_REF`) -- conditions must
|
|
1535
|
+
* only reference field ids that exist in the registry.
|
|
1536
|
+
* 4. **Condition references section** (`CONDITION_REFS_SECTION`) -- conditions
|
|
1537
|
+
* must not reference section ids, because sections have no values.
|
|
1538
|
+
* 5. **Constraint contradictions** (`INVALID_MIN_MAX`) -- e.g. `minLength > maxLength`,
|
|
1539
|
+
* `min > max`, `minDate > maxDate` (absolute dates only), `minItems > maxItems`.
|
|
1540
|
+
* 6. **Invalid regex** (`INVALID_REGEX`) -- string field `pattern` values must
|
|
1541
|
+
* be valid regular expressions.
|
|
1542
|
+
*/
|
|
1543
|
+
var FormDefinitionValidator = class {
|
|
1544
|
+
/**
|
|
1545
|
+
* Validates raw input against the form definition JSON schema.
|
|
1546
|
+
*
|
|
1547
|
+
* @param input - The raw input to validate.
|
|
1548
|
+
* @returns Array of `SCHEMA_INVALID` issues. Empty when the input conforms to the schema.
|
|
1549
|
+
*/
|
|
1550
|
+
validateSchema(input) {
|
|
1551
|
+
if (validateFn(input)) return [];
|
|
1552
|
+
return (validateFn.errors ?? []).map((err) => {
|
|
1553
|
+
const path = err.instancePath || "/";
|
|
1554
|
+
const message = err.message ?? "Unknown error";
|
|
1555
|
+
if (err.keyword === "additionalProperties") return {
|
|
1556
|
+
code: "SCHEMA_INVALID",
|
|
1557
|
+
message: `${path}: ${message}: '${err.params.additionalProperty}'`
|
|
1558
|
+
};
|
|
1559
|
+
return {
|
|
1560
|
+
code: "SCHEMA_INVALID",
|
|
1561
|
+
message: `${path}: ${message}`
|
|
1562
|
+
};
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Validates a form definition semantically.
|
|
1567
|
+
*
|
|
1568
|
+
* @param definition - The form definition to validate.
|
|
1569
|
+
* @param registry - The flattened field registry built from the definition.
|
|
1570
|
+
* @returns Array of issues found. Empty if the definition is semantically valid.
|
|
1571
|
+
*/
|
|
1572
|
+
validate(definition, registry) {
|
|
1573
|
+
const issues = [];
|
|
1574
|
+
this.checkDuplicateIds(definition.content, issues);
|
|
1575
|
+
this.checkNestingDepth(definition.content, 0, issues);
|
|
1576
|
+
this.checkConditionRefs(registry, issues);
|
|
1577
|
+
this.checkConditionRefsSection(registry, issues);
|
|
1578
|
+
this.checkConstraintContradictions(registry, issues);
|
|
1579
|
+
this.checkInvalidRegex(registry, issues);
|
|
1580
|
+
return issues;
|
|
1581
|
+
}
|
|
1582
|
+
checkDuplicateIds(content, issues) {
|
|
1583
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1584
|
+
this.walkItems(content, (item) => {
|
|
1585
|
+
if (seen.has(item.id)) issues.push({
|
|
1586
|
+
code: "DUPLICATE_ID",
|
|
1587
|
+
message: `Duplicate id: ${item.id}`,
|
|
1588
|
+
itemId: item.id
|
|
1589
|
+
});
|
|
1590
|
+
else seen.add(item.id);
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
checkNestingDepth(content, depth, issues) {
|
|
1594
|
+
for (const item of content) if (item.type === "section") if (depth >= 3) issues.push({
|
|
1595
|
+
code: "NESTING_DEPTH",
|
|
1596
|
+
message: `Section nesting exceeds maximum depth of 3: ${item.id}`,
|
|
1597
|
+
itemId: item.id
|
|
1598
|
+
});
|
|
1599
|
+
else this.checkNestingDepth(item.content, depth + 1, issues);
|
|
1600
|
+
}
|
|
1601
|
+
checkConditionRefs(registry, issues) {
|
|
1602
|
+
for (const [id, entry] of registry) {
|
|
1603
|
+
if (!entry.condition) continue;
|
|
1604
|
+
const refs = DependencyGraph.extractFieldRefs(entry.condition);
|
|
1605
|
+
for (const ref of refs) if (!registry.has(ref)) issues.push({
|
|
1606
|
+
code: "UNKNOWN_FIELD_REF",
|
|
1607
|
+
message: `Condition references unknown field: ${ref} (in item ${id})`,
|
|
1608
|
+
itemId: id
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
checkConditionRefsSection(registry, issues) {
|
|
1613
|
+
for (const [id, entry] of registry) {
|
|
1614
|
+
if (!entry.condition) continue;
|
|
1615
|
+
const refs = DependencyGraph.extractFieldRefs(entry.condition);
|
|
1616
|
+
for (const ref of refs) {
|
|
1617
|
+
const refEntry = registry.get(ref);
|
|
1618
|
+
if (refEntry && refEntry.type === "section") issues.push({
|
|
1619
|
+
code: "CONDITION_REFS_SECTION",
|
|
1620
|
+
message: `Condition references section ${ref}, which has no value (in item ${id})`,
|
|
1621
|
+
itemId: id
|
|
1622
|
+
});
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
checkConstraintContradictions(registry, issues) {
|
|
1627
|
+
for (const [id, entry] of registry) {
|
|
1628
|
+
if (!entry.validation) continue;
|
|
1629
|
+
switch (entry.type) {
|
|
1630
|
+
case "string": {
|
|
1631
|
+
const v = entry.validation;
|
|
1632
|
+
if (v.minLength !== void 0 && v.maxLength !== void 0 && v.maxLength < v.minLength) issues.push({
|
|
1633
|
+
code: "INVALID_MIN_MAX",
|
|
1634
|
+
message: `maxLength must be >= minLength for field ${id}`,
|
|
1635
|
+
itemId: id
|
|
1636
|
+
});
|
|
1637
|
+
break;
|
|
1638
|
+
}
|
|
1639
|
+
case "number": {
|
|
1640
|
+
const v = entry.validation;
|
|
1641
|
+
if (v.min !== void 0 && v.max !== void 0 && v.max < v.min) issues.push({
|
|
1642
|
+
code: "INVALID_MIN_MAX",
|
|
1643
|
+
message: `max must be >= min for field ${id}`,
|
|
1644
|
+
itemId: id
|
|
1645
|
+
});
|
|
1646
|
+
break;
|
|
1647
|
+
}
|
|
1648
|
+
case "date": {
|
|
1649
|
+
const v = entry.validation;
|
|
1650
|
+
if (v.minDate !== void 0 && v.maxDate !== void 0) {
|
|
1651
|
+
const minIsAbsolute = !isRelativeDate(v.minDate);
|
|
1652
|
+
const maxIsAbsolute = !isRelativeDate(v.maxDate);
|
|
1653
|
+
if (minIsAbsolute && maxIsAbsolute) {
|
|
1654
|
+
if (Date.parse(v.maxDate) < Date.parse(v.minDate)) issues.push({
|
|
1655
|
+
code: "INVALID_MIN_MAX",
|
|
1656
|
+
message: `maxDate must be >= minDate for field ${id}`,
|
|
1657
|
+
itemId: id
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1663
|
+
case "array": {
|
|
1664
|
+
const v = entry.validation;
|
|
1665
|
+
if (v.minItems !== void 0 && v.maxItems !== void 0 && v.maxItems < v.minItems) issues.push({
|
|
1666
|
+
code: "INVALID_MIN_MAX",
|
|
1667
|
+
message: `maxItems must be >= minItems for field ${id}`,
|
|
1668
|
+
itemId: id
|
|
1669
|
+
});
|
|
1670
|
+
break;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
checkInvalidRegex(registry, issues) {
|
|
1676
|
+
for (const [id, entry] of registry) {
|
|
1677
|
+
if (entry.type !== "string" || !entry.validation) continue;
|
|
1678
|
+
const v = entry.validation;
|
|
1679
|
+
if (v.pattern === void 0) continue;
|
|
1680
|
+
try {
|
|
1681
|
+
new RegExp(v.pattern);
|
|
1682
|
+
} catch (e) {
|
|
1683
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1684
|
+
issues.push({
|
|
1685
|
+
code: "INVALID_REGEX",
|
|
1686
|
+
message: `Invalid regex pattern for field ${id}: ${msg}`,
|
|
1687
|
+
itemId: id
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
walkItems(content, fn) {
|
|
1693
|
+
for (const item of content) {
|
|
1694
|
+
fn(item);
|
|
1695
|
+
if (item.type === "section") this.walkItems(item.content, fn);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
//#endregion
|
|
1700
|
+
//#region src/types/errors.ts
|
|
1701
|
+
/**
|
|
1702
|
+
* Error thrown when form definition or document validation fails.
|
|
1703
|
+
*
|
|
1704
|
+
* Inspect {@link errors} for structured programmatic access to all
|
|
1705
|
+
* validation issues.
|
|
1706
|
+
*
|
|
1707
|
+
* @example
|
|
1708
|
+
* ```ts
|
|
1709
|
+
* try {
|
|
1710
|
+
* const engine = new FormEngine(definition);
|
|
1711
|
+
* } catch (err) {
|
|
1712
|
+
* if (err instanceof DocumentError) {
|
|
1713
|
+
* for (const e of err.errors) {
|
|
1714
|
+
* console.log(e.code, e.message);
|
|
1715
|
+
* }
|
|
1716
|
+
* }
|
|
1717
|
+
* }
|
|
1718
|
+
* ```
|
|
1719
|
+
*/
|
|
1720
|
+
var DocumentError = class extends Error {
|
|
1721
|
+
/** Structured list of all validation errors. */
|
|
1722
|
+
errors;
|
|
1723
|
+
/**
|
|
1724
|
+
* @param errors - One or more validation errors that caused the error.
|
|
1725
|
+
*/
|
|
1726
|
+
constructor(errors) {
|
|
1727
|
+
const summary = errors.map((e) => e.message).join("; ");
|
|
1728
|
+
super(`Document validation failed: ${summary}`);
|
|
1729
|
+
this.name = "DocumentError";
|
|
1730
|
+
this.errors = errors;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
//#endregion
|
|
1734
|
+
//#region src/visibility-resolver.ts
|
|
1735
|
+
/**
|
|
1736
|
+
* Computes field and section visibility for a form.
|
|
1737
|
+
*
|
|
1738
|
+
* Provides two modes of visibility computation:
|
|
1739
|
+
* - **Single-item** (`isVisible`): evaluates one item's condition plus its
|
|
1740
|
+
* parent chain. Does not use the hidden-field rule.
|
|
1741
|
+
* - **Bulk** (`getVisibilityMap`): evaluates all items in topological order
|
|
1742
|
+
* with the hidden-field rule applied (references to hidden fields are
|
|
1743
|
+
* treated as "not set").
|
|
1744
|
+
*/
|
|
1745
|
+
var VisibilityResolver = class {
|
|
1746
|
+
registry;
|
|
1747
|
+
conditionEvaluator;
|
|
1748
|
+
topologicalOrder;
|
|
1749
|
+
/**
|
|
1750
|
+
* @param registry - The engine's field registry.
|
|
1751
|
+
* @param conditionEvaluator - Evaluator for condition trees.
|
|
1752
|
+
* @param topologicalOrder - Item ids in topological order (from {@link DependencyGraph}).
|
|
1753
|
+
*/
|
|
1754
|
+
constructor(registry, conditionEvaluator, topologicalOrder) {
|
|
1755
|
+
this.registry = registry;
|
|
1756
|
+
this.conditionEvaluator = conditionEvaluator;
|
|
1757
|
+
this.topologicalOrder = topologicalOrder;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Determines whether a single field or section is visible.
|
|
1761
|
+
*
|
|
1762
|
+
* Evaluation logic:
|
|
1763
|
+
* 1. If the item has its own condition, evaluate it. If `false`, the item is hidden.
|
|
1764
|
+
* 2. If the item has a parent section, recursively check parent visibility.
|
|
1765
|
+
* An item is hidden whenever any ancestor is hidden.
|
|
1766
|
+
* 3. Items without conditions and without hidden parents are visible.
|
|
1767
|
+
*
|
|
1768
|
+
* Unlike {@link getVisibilityMap}, this method does not use the
|
|
1769
|
+
* pre-computed visibility map and does not apply the hidden-field rule.
|
|
1770
|
+
* Use it for one-off visibility checks; prefer `getVisibilityMap` when
|
|
1771
|
+
* evaluating many items at once.
|
|
1772
|
+
*
|
|
1773
|
+
* @param id - Numeric id of the field or section to check.
|
|
1774
|
+
* @param values - Current form values.
|
|
1775
|
+
* @param now - Reference date for relative date expressions.
|
|
1776
|
+
* @returns `true` if the item should be displayed.
|
|
1777
|
+
*/
|
|
1778
|
+
isVisible(id, values, now) {
|
|
1779
|
+
const entry = this.registry.get(id);
|
|
1780
|
+
if (!entry) return false;
|
|
1781
|
+
if (entry.condition) {
|
|
1782
|
+
if (!this.conditionEvaluator.evalCondition(entry.condition, {
|
|
1783
|
+
values,
|
|
1784
|
+
now
|
|
1785
|
+
})) return false;
|
|
1786
|
+
}
|
|
1787
|
+
if (entry.parentId !== void 0) return this.isVisible(entry.parentId, values, now);
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Computes visibility for all fields and sections in a single pass.
|
|
1792
|
+
*
|
|
1793
|
+
* Iterates in topological order so that every item is evaluated after the
|
|
1794
|
+
* fields its condition depends on. This enables the **hidden-field rule**:
|
|
1795
|
+
* if a condition references a field that has already been determined hidden,
|
|
1796
|
+
* that field is treated as "not set".
|
|
1797
|
+
*
|
|
1798
|
+
* Cascading parent visibility is also enforced -- if a parent section is
|
|
1799
|
+
* hidden, all its children are immediately marked hidden without evaluating
|
|
1800
|
+
* their own conditions.
|
|
1801
|
+
*
|
|
1802
|
+
* @param values - Current form values.
|
|
1803
|
+
* @param now - Reference date for relative date expressions.
|
|
1804
|
+
* @returns Map from item id to visibility boolean (`true` = visible).
|
|
1805
|
+
*/
|
|
1806
|
+
getVisibilityMap(values, now) {
|
|
1807
|
+
const result = /* @__PURE__ */ new Map();
|
|
1808
|
+
for (const id of this.topologicalOrder) {
|
|
1809
|
+
const entry = this.registry.get(id);
|
|
1810
|
+
if (!entry) {
|
|
1811
|
+
result.set(id, false);
|
|
1812
|
+
continue;
|
|
1813
|
+
}
|
|
1814
|
+
if (entry.parentId !== void 0 && result.get(entry.parentId) === false) {
|
|
1815
|
+
result.set(id, false);
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
if (entry.condition) {
|
|
1819
|
+
const visible = this.conditionEvaluator.evalCondition(entry.condition, {
|
|
1820
|
+
values,
|
|
1821
|
+
visibilityMap: result,
|
|
1822
|
+
now
|
|
1823
|
+
});
|
|
1824
|
+
result.set(id, visible);
|
|
1825
|
+
} else result.set(id, true);
|
|
1826
|
+
}
|
|
1827
|
+
return result;
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
//#endregion
|
|
1831
|
+
//#region src/form-engine.ts
|
|
1832
|
+
/**
|
|
1833
|
+
* The runtime form engine.
|
|
1834
|
+
*
|
|
1835
|
+
* Created by passing a {@link FormDefinition} to the constructor. The
|
|
1836
|
+
* construction lifecycle is:
|
|
1837
|
+
*
|
|
1838
|
+
* 1. **Build field registry** -- walks the definition tree depth-first,
|
|
1839
|
+
* creating a flat {@link FieldEntry} for every field and section while
|
|
1840
|
+
* recording document-order ids in `contentOrder`.
|
|
1841
|
+
* 2. **Semantic validation** -- checks for duplicate ids, excessive nesting,
|
|
1842
|
+
* unknown/invalid condition references, constraint contradictions, and
|
|
1843
|
+
* invalid regex patterns.
|
|
1844
|
+
* 3. **Cycle detection** -- verifies that condition dependencies form a DAG
|
|
1845
|
+
* (no circular references).
|
|
1846
|
+
* 4. **Error reporting** -- if any issues were found in steps 2-3, throws a
|
|
1847
|
+
* {@link DocumentError} containing all issues.
|
|
1848
|
+
* 5. **Build dependency graph** -- creates a forward adjacency map so the
|
|
1849
|
+
* engine can quickly determine which items are affected when a field
|
|
1850
|
+
* value changes.
|
|
1851
|
+
* 6. **Topological sort** -- orders all items so that dependencies are
|
|
1852
|
+
* evaluated before dependents (used by `getVisibilityMap`).
|
|
1853
|
+
* 7. **Assemble components** -- creates internal {@link ConditionEvaluator},
|
|
1854
|
+
* {@link VisibilityResolver}, and {@link FieldValidator} instances.
|
|
1855
|
+
*
|
|
1856
|
+
* @example
|
|
1857
|
+
* ```ts
|
|
1858
|
+
* const engine = new FormEngine(myFormDefinition);
|
|
1859
|
+
* const visibility = engine.getVisibilityMap(formValues);
|
|
1860
|
+
* const result = engine.validate(formValues);
|
|
1861
|
+
* ```
|
|
1862
|
+
*/
|
|
1863
|
+
var FormEngine = class FormEngine {
|
|
1864
|
+
registry;
|
|
1865
|
+
depGraph;
|
|
1866
|
+
visibilityResolver;
|
|
1867
|
+
fieldValidator;
|
|
1868
|
+
definition;
|
|
1869
|
+
formId;
|
|
1870
|
+
formVersion;
|
|
1871
|
+
/**
|
|
1872
|
+
* Ordered list of all content item ids in depth-first document order.
|
|
1873
|
+
* Matches the order in which items appear in the form definition.
|
|
1874
|
+
*/
|
|
1875
|
+
contentOrder;
|
|
1876
|
+
/**
|
|
1877
|
+
* Compiles a {@link FormDefinition} into a ready-to-use engine.
|
|
1878
|
+
*
|
|
1879
|
+
* @param definition - A complete form definition to compile.
|
|
1880
|
+
* @throws {DocumentError} If the definition contains semantic issues
|
|
1881
|
+
* or circular condition dependencies.
|
|
1882
|
+
*/
|
|
1883
|
+
constructor(definition) {
|
|
1884
|
+
const definitionValidator = new FormDefinitionValidator();
|
|
1885
|
+
const schemaIssues = definitionValidator.validateSchema(definition);
|
|
1886
|
+
if (schemaIssues.length > 0) throw new DocumentError(schemaIssues);
|
|
1887
|
+
const registry = /* @__PURE__ */ new Map();
|
|
1888
|
+
const contentOrder = [];
|
|
1889
|
+
FormEngine.walkContent(definition.content, void 0, registry, contentOrder);
|
|
1890
|
+
const issues = definitionValidator.validate(definition, registry);
|
|
1891
|
+
const cyclePath = DependencyGraph.detectCycle(registry);
|
|
1892
|
+
if (cyclePath) issues.push({
|
|
1893
|
+
code: "CIRCULAR_DEPENDENCY",
|
|
1894
|
+
message: `Circular condition dependency detected: ${cyclePath.join(" -> ")}`
|
|
1895
|
+
});
|
|
1896
|
+
if (issues.length > 0) throw new DocumentError(issues);
|
|
1897
|
+
this.depGraph = new DependencyGraph(registry);
|
|
1898
|
+
this.visibilityResolver = new VisibilityResolver(registry, new ConditionEvaluator(), this.depGraph.topologicalOrder);
|
|
1899
|
+
this.fieldValidator = new FieldValidator(registry);
|
|
1900
|
+
this.registry = registry;
|
|
1901
|
+
this.contentOrder = contentOrder;
|
|
1902
|
+
this.definition = definition;
|
|
1903
|
+
this.formId = definition.id;
|
|
1904
|
+
this.formVersion = definition.version;
|
|
1905
|
+
}
|
|
1906
|
+
/**
|
|
1907
|
+
* Creates a {@link FormDocument} pre-populated with the form schema's
|
|
1908
|
+
* id and version.
|
|
1909
|
+
*
|
|
1910
|
+
* @param values - Optional initial field values. Defaults to an empty object.
|
|
1911
|
+
* @returns A new form document ready for use with engine methods.
|
|
1912
|
+
*/
|
|
1913
|
+
createFormDocument(values) {
|
|
1914
|
+
return {
|
|
1915
|
+
form: {
|
|
1916
|
+
id: this.formId,
|
|
1917
|
+
version: this.formVersion,
|
|
1918
|
+
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1919
|
+
},
|
|
1920
|
+
values: values ?? {}
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Serializes the form definition and document into a single {@link FormSnapshot}.
|
|
1925
|
+
*
|
|
1926
|
+
* The snapshot contains the original {@link FormDefinition} used to construct
|
|
1927
|
+
* the engine and the provided {@link FormDocument}. No validation is performed;
|
|
1928
|
+
* call {@link validate} separately if needed.
|
|
1929
|
+
*
|
|
1930
|
+
* @param doc - The form document to include in the snapshot.
|
|
1931
|
+
* @returns A snapshot containing both the definition and the document.
|
|
1932
|
+
*/
|
|
1933
|
+
dumpDocument(doc) {
|
|
1934
|
+
return {
|
|
1935
|
+
definition: this.definition,
|
|
1936
|
+
document: doc
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Loads a {@link FormDocument} from a previously created {@link FormSnapshot}.
|
|
1941
|
+
*
|
|
1942
|
+
* Verifies that the snapshot's form definition matches the engine's
|
|
1943
|
+
* compiled definition by comparing id and version. Throws a
|
|
1944
|
+
* {@link DocumentError} if there is a mismatch.
|
|
1945
|
+
*
|
|
1946
|
+
* @param snapshot - A snapshot previously produced by {@link dumpDocument}.
|
|
1947
|
+
* @returns The form document from the snapshot.
|
|
1948
|
+
* @throws {DocumentError} If the snapshot's definition id or version
|
|
1949
|
+
* does not match the engine's.
|
|
1950
|
+
*/
|
|
1951
|
+
loadDocument(snapshot) {
|
|
1952
|
+
const errors = [];
|
|
1953
|
+
if (snapshot.definition.id !== this.formId) errors.push({
|
|
1954
|
+
code: "FORM_ID_MISMATCH",
|
|
1955
|
+
message: `Snapshot form id "${snapshot.definition.id}" does not match expected "${this.formId}"`,
|
|
1956
|
+
params: {
|
|
1957
|
+
expected: this.formId,
|
|
1958
|
+
actual: snapshot.definition.id
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
if (snapshot.definition.version !== this.formVersion) errors.push({
|
|
1962
|
+
code: "FORM_VERSION_MISMATCH",
|
|
1963
|
+
message: `Snapshot form version "${snapshot.definition.version}" does not match expected "${this.formVersion}"`,
|
|
1964
|
+
params: {
|
|
1965
|
+
expected: this.formVersion,
|
|
1966
|
+
actual: snapshot.definition.version
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
if (errors.length > 0) throw new DocumentError(errors);
|
|
1970
|
+
return snapshot.document;
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Determines whether a field or section is visible given the current form document.
|
|
1974
|
+
*
|
|
1975
|
+
* Evaluates the item's own condition and walks up the parent chain --
|
|
1976
|
+
* an item is hidden if any ancestor is hidden.
|
|
1977
|
+
*
|
|
1978
|
+
* @param id - Numeric id of the field or section.
|
|
1979
|
+
* @param doc - Current form document.
|
|
1980
|
+
* @returns `true` if the item should be displayed, `false` otherwise.
|
|
1981
|
+
*/
|
|
1982
|
+
isVisible(id, doc) {
|
|
1983
|
+
return this.visibilityResolver.isVisible(id, doc.values, FormEngine.parseNow(doc));
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Computes visibility for every field and section in topological order.
|
|
1987
|
+
*
|
|
1988
|
+
* The resulting map is keyed by item id. Items whose conditions depend on
|
|
1989
|
+
* other items are evaluated after their dependencies, ensuring correct
|
|
1990
|
+
* cascading visibility (e.g. a hidden parent hides all children).
|
|
1991
|
+
*
|
|
1992
|
+
* @param doc - Current form document.
|
|
1993
|
+
* @returns Map from item id to visibility boolean.
|
|
1994
|
+
*/
|
|
1995
|
+
getVisibilityMap(doc) {
|
|
1996
|
+
return this.visibilityResolver.getVisibilityMap(doc.values, FormEngine.parseNow(doc));
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Returns the set of item ids whose visibility may change when the
|
|
2000
|
+
* specified field's value changes.
|
|
2001
|
+
*
|
|
2002
|
+
* Includes transitive dependents -- if field A controls field B, and
|
|
2003
|
+
* field B controls field C, changing A returns `{B, C}`.
|
|
2004
|
+
* Results are cached for the lifetime of the engine.
|
|
2005
|
+
*
|
|
2006
|
+
* @param fieldId - Id of the field that changed.
|
|
2007
|
+
* @returns Set of affected item ids (does not include `fieldId` itself unless
|
|
2008
|
+
* it is part of a dependency chain).
|
|
2009
|
+
*/
|
|
2010
|
+
getAffectedIds(fieldId) {
|
|
2011
|
+
return this.depGraph.getAffectedIds(fieldId);
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Validates form values against the schema's validation rules.
|
|
2015
|
+
*
|
|
2016
|
+
* Only visible fields are validated -- hidden fields are skipped entirely.
|
|
2017
|
+
* Sections are never validated directly. For array fields, each item is
|
|
2018
|
+
* validated individually according to the array's item definition.
|
|
2019
|
+
*
|
|
2020
|
+
* The reference time for relative date validation is derived from
|
|
2021
|
+
* `doc.form.submittedAt`. If that value is missing or unparseable, a
|
|
2022
|
+
* document-level error is reported and `new Date()` is used as fallback.
|
|
2023
|
+
*
|
|
2024
|
+
* @param doc - Current form document to validate.
|
|
2025
|
+
* @returns Validation result with a `valid` flag and a `fieldErrors` map.
|
|
2026
|
+
*/
|
|
2027
|
+
validate(doc) {
|
|
2028
|
+
const documentErrors = [];
|
|
2029
|
+
if (doc.form.id !== this.formId) documentErrors.push({
|
|
2030
|
+
code: "FORM_ID_MISMATCH",
|
|
2031
|
+
message: `Document form id "${doc.form.id}" does not match expected "${this.formId}"`,
|
|
2032
|
+
params: {
|
|
2033
|
+
expected: this.formId,
|
|
2034
|
+
actual: doc.form.id
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
if (doc.form.version !== this.formVersion) documentErrors.push({
|
|
2038
|
+
code: "FORM_VERSION_MISMATCH",
|
|
2039
|
+
message: `Document form version "${doc.form.version}" does not match expected "${this.formVersion}"`,
|
|
2040
|
+
params: {
|
|
2041
|
+
expected: this.formVersion,
|
|
2042
|
+
actual: doc.form.version
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
let now;
|
|
2046
|
+
if (!doc.form.submittedAt) {
|
|
2047
|
+
documentErrors.push({
|
|
2048
|
+
code: "FORM_SUBMITTED_AT_MISSING",
|
|
2049
|
+
message: "Document form submittedAt is missing"
|
|
2050
|
+
});
|
|
2051
|
+
now = /* @__PURE__ */ new Date();
|
|
2052
|
+
} else {
|
|
2053
|
+
const parsedSubmittedAt = new Date(doc.form.submittedAt);
|
|
2054
|
+
if (Number.isNaN(parsedSubmittedAt.getTime())) {
|
|
2055
|
+
documentErrors.push({
|
|
2056
|
+
code: "FORM_SUBMITTED_AT_INVALID",
|
|
2057
|
+
message: `Document form submittedAt "${doc.form.submittedAt}" is not a valid date`,
|
|
2058
|
+
params: { actual: doc.form.submittedAt }
|
|
2059
|
+
});
|
|
2060
|
+
now = /* @__PURE__ */ new Date();
|
|
2061
|
+
} else now = parsedSubmittedAt;
|
|
2062
|
+
}
|
|
2063
|
+
if (documentErrors.length > 0) return {
|
|
2064
|
+
valid: false,
|
|
2065
|
+
fieldErrors: /* @__PURE__ */ new Map(),
|
|
2066
|
+
documentErrors
|
|
2067
|
+
};
|
|
2068
|
+
const visibilityMap = this.visibilityResolver.getVisibilityMap(doc.values, now);
|
|
2069
|
+
return this.fieldValidator.validate(doc.values, visibilityMap, now);
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Retrieves the internal {@link FieldEntry} for a given id.
|
|
2073
|
+
*
|
|
2074
|
+
* @param id - Numeric id of the field or section.
|
|
2075
|
+
* @returns The field entry, or `undefined` if the id is not in the registry.
|
|
2076
|
+
*/
|
|
2077
|
+
getFieldDef(id) {
|
|
2078
|
+
return this.registry.get(id);
|
|
2079
|
+
}
|
|
2080
|
+
static parseNow(doc) {
|
|
2081
|
+
if (doc.form.submittedAt) {
|
|
2082
|
+
const parsed = new Date(doc.form.submittedAt);
|
|
2083
|
+
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
2084
|
+
}
|
|
2085
|
+
return /* @__PURE__ */ new Date();
|
|
2086
|
+
}
|
|
2087
|
+
static walkContent(content, parentId, registry, contentOrder) {
|
|
2088
|
+
for (const item of content) {
|
|
2089
|
+
const entry = {
|
|
2090
|
+
id: item.id,
|
|
2091
|
+
type: item.type,
|
|
2092
|
+
condition: item.condition,
|
|
2093
|
+
validation: item.type !== "section" ? item.validation : void 0,
|
|
2094
|
+
parentId,
|
|
2095
|
+
options: item.type === "select" ? item.options : void 0,
|
|
2096
|
+
item: item.type === "array" ? item.item : void 0,
|
|
2097
|
+
label: item.type !== "section" ? item.label : void 0,
|
|
2098
|
+
title: item.type === "section" ? item.title : void 0
|
|
2099
|
+
};
|
|
2100
|
+
registry.set(item.id, entry);
|
|
2101
|
+
contentOrder.push(item.id);
|
|
2102
|
+
if (item.type === "section") FormEngine.walkContent(item.content, item.id, registry, contentOrder);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/form-values-editor.ts
|
|
2108
|
+
/**
|
|
2109
|
+
* Mutable editor for building and modifying form values against a {@link FormDefinition}.
|
|
2110
|
+
*
|
|
2111
|
+
* Wraps a {@link FormEngine} and a mutable {@link FormDocument}. All mutating
|
|
2112
|
+
* methods return `this` for fluent chaining.
|
|
2113
|
+
*
|
|
2114
|
+
* @example
|
|
2115
|
+
* ```ts
|
|
2116
|
+
* const editor = new FormValuesEditor(definition)
|
|
2117
|
+
* editor
|
|
2118
|
+
* .setFieldValue(1, 'Alice')
|
|
2119
|
+
* .setFieldValue(2, 30)
|
|
2120
|
+
* .setSubmittedAt('2025-01-01T00:00:00Z')
|
|
2121
|
+
*
|
|
2122
|
+
* const result = editor.validate()
|
|
2123
|
+
* const doc = editor.toJSON()
|
|
2124
|
+
* ```
|
|
2125
|
+
*/
|
|
2126
|
+
var FormValuesEditor = class {
|
|
2127
|
+
engine;
|
|
2128
|
+
doc;
|
|
2129
|
+
/**
|
|
2130
|
+
* Creates a new editor for the given form definition.
|
|
2131
|
+
*
|
|
2132
|
+
* @param definition - The form definition to edit values against.
|
|
2133
|
+
* @param doc - An existing document to pre-populate. Deep-cloned internally.
|
|
2134
|
+
* When omitted a blank document is created via {@link FormEngine.createFormDocument}.
|
|
2135
|
+
*/
|
|
2136
|
+
constructor(definition, doc) {
|
|
2137
|
+
this.engine = new FormEngine(definition);
|
|
2138
|
+
this.doc = doc ? JSON.parse(JSON.stringify(doc)) : this.engine.createFormDocument();
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Returns the current value of a field.
|
|
2142
|
+
*
|
|
2143
|
+
* @param fieldId - Numeric id of the field.
|
|
2144
|
+
* @returns The field value, or `undefined` if not set.
|
|
2145
|
+
*/
|
|
2146
|
+
getFieldValue(fieldId) {
|
|
2147
|
+
return this.doc.values[String(fieldId)];
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Sets the value of a field.
|
|
2151
|
+
*
|
|
2152
|
+
* @param fieldId - Numeric id of the field.
|
|
2153
|
+
* @param value - The value to set.
|
|
2154
|
+
* @returns `this` for chaining.
|
|
2155
|
+
* @throws If `fieldId` is unknown or references a section.
|
|
2156
|
+
*/
|
|
2157
|
+
setFieldValue(fieldId, value) {
|
|
2158
|
+
this.assertField(fieldId);
|
|
2159
|
+
this.doc.values[String(fieldId)] = value;
|
|
2160
|
+
return this;
|
|
2161
|
+
}
|
|
2162
|
+
/**
|
|
2163
|
+
* Removes the value of a field.
|
|
2164
|
+
*
|
|
2165
|
+
* @param fieldId - Numeric id of the field.
|
|
2166
|
+
* @returns `this` for chaining.
|
|
2167
|
+
*/
|
|
2168
|
+
clearFieldValue(fieldId) {
|
|
2169
|
+
delete this.doc.values[String(fieldId)];
|
|
2170
|
+
return this;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Appends an item to an array field.
|
|
2174
|
+
*
|
|
2175
|
+
* If the field currently has no value, it is initialized to an empty array
|
|
2176
|
+
* before appending.
|
|
2177
|
+
*
|
|
2178
|
+
* @param fieldId - Numeric id of the array field.
|
|
2179
|
+
* @param value - The value to append. Defaults to `undefined`.
|
|
2180
|
+
* @returns `this` for chaining.
|
|
2181
|
+
* @throws If `fieldId` is not an array field.
|
|
2182
|
+
*/
|
|
2183
|
+
addArrayItem(fieldId, value) {
|
|
2184
|
+
this.getOrInitArray(fieldId).push(value);
|
|
2185
|
+
return this;
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Removes an item from an array field by index.
|
|
2189
|
+
*
|
|
2190
|
+
* @param fieldId - Numeric id of the array field.
|
|
2191
|
+
* @param index - Zero-based index of the item to remove.
|
|
2192
|
+
* @returns `this` for chaining.
|
|
2193
|
+
* @throws If `fieldId` is not an array field or the index is out of bounds.
|
|
2194
|
+
*/
|
|
2195
|
+
removeArrayItem(fieldId, index) {
|
|
2196
|
+
const arr = this.assertArray(fieldId);
|
|
2197
|
+
if (index < 0 || index >= arr.length) throw new Error(`Index ${index} is out of bounds for array field ${fieldId} (length ${arr.length})`);
|
|
2198
|
+
arr.splice(index, 1);
|
|
2199
|
+
return this;
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Moves an item within an array field from one index to another.
|
|
2203
|
+
*
|
|
2204
|
+
* @param fieldId - Numeric id of the array field.
|
|
2205
|
+
* @param fromIndex - Current zero-based index of the item.
|
|
2206
|
+
* @param toIndex - Target zero-based index.
|
|
2207
|
+
* @returns `this` for chaining.
|
|
2208
|
+
* @throws If `fieldId` is not an array field or either index is out of bounds.
|
|
2209
|
+
*/
|
|
2210
|
+
moveArrayItem(fieldId, fromIndex, toIndex) {
|
|
2211
|
+
const arr = this.assertArray(fieldId);
|
|
2212
|
+
if (fromIndex < 0 || fromIndex >= arr.length) throw new Error(`fromIndex ${fromIndex} is out of bounds for array field ${fieldId} (length ${arr.length})`);
|
|
2213
|
+
if (toIndex < 0 || toIndex >= arr.length) throw new Error(`toIndex ${toIndex} is out of bounds for array field ${fieldId} (length ${arr.length})`);
|
|
2214
|
+
const [item] = arr.splice(fromIndex, 1);
|
|
2215
|
+
arr.splice(toIndex, 0, item);
|
|
2216
|
+
return this;
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Sets the value of an item at a specific index in an array field.
|
|
2220
|
+
*
|
|
2221
|
+
* @param fieldId - Numeric id of the array field.
|
|
2222
|
+
* @param index - Zero-based index of the item to set.
|
|
2223
|
+
* @param value - The new value for the item.
|
|
2224
|
+
* @returns `this` for chaining.
|
|
2225
|
+
* @throws If `fieldId` is not an array field or the index is out of bounds.
|
|
2226
|
+
*/
|
|
2227
|
+
setArrayItem(fieldId, index, value) {
|
|
2228
|
+
const arr = this.assertArray(fieldId);
|
|
2229
|
+
if (index < 0 || index >= arr.length) throw new Error(`Index ${index} is out of bounds for array field ${fieldId} (length ${arr.length})`);
|
|
2230
|
+
arr[index] = value;
|
|
2231
|
+
return this;
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Sets the `submittedAt` timestamp on the document.
|
|
2235
|
+
*
|
|
2236
|
+
* @param submittedAt - ISO 8601 timestamp string.
|
|
2237
|
+
* @returns `this` for chaining.
|
|
2238
|
+
*/
|
|
2239
|
+
setSubmittedAt(submittedAt) {
|
|
2240
|
+
this.doc.form.submittedAt = submittedAt;
|
|
2241
|
+
return this;
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Validates the current document against the form definition.
|
|
2245
|
+
*
|
|
2246
|
+
* Delegates to {@link FormEngine.validate}.
|
|
2247
|
+
*
|
|
2248
|
+
* @returns The validation result.
|
|
2249
|
+
*/
|
|
2250
|
+
validate() {
|
|
2251
|
+
return this.engine.validate(this.doc);
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Computes visibility for every field and section.
|
|
2255
|
+
*
|
|
2256
|
+
* Delegates to {@link FormEngine.getVisibilityMap}.
|
|
2257
|
+
*
|
|
2258
|
+
* @returns Map from item id to visibility boolean.
|
|
2259
|
+
*/
|
|
2260
|
+
getVisibilityMap() {
|
|
2261
|
+
return this.engine.getVisibilityMap(this.doc);
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Determines whether a field or section is visible given current values.
|
|
2265
|
+
*
|
|
2266
|
+
* Delegates to {@link FormEngine.isVisible}.
|
|
2267
|
+
*
|
|
2268
|
+
* @param id - Numeric id of the field or section.
|
|
2269
|
+
* @returns `true` if the item should be displayed.
|
|
2270
|
+
*/
|
|
2271
|
+
isVisible(id) {
|
|
2272
|
+
return this.engine.isVisible(id, this.doc);
|
|
2273
|
+
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Returns a deep clone of the current form document.
|
|
2276
|
+
*
|
|
2277
|
+
* @returns A new serializable {@link FormDocument} instance.
|
|
2278
|
+
*/
|
|
2279
|
+
toJSON() {
|
|
2280
|
+
return JSON.parse(JSON.stringify(this.doc));
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Asserts that `fieldId` exists in the registry and is not a section.
|
|
2284
|
+
*/
|
|
2285
|
+
assertField(fieldId) {
|
|
2286
|
+
const entry = this.engine.getFieldDef(fieldId);
|
|
2287
|
+
if (!entry) throw new Error(`Field with id ${fieldId} not found`);
|
|
2288
|
+
if (entry.type === "section") throw new Error(`Item ${fieldId} is a section, not a field`);
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Asserts that `fieldId` is an array field and returns the current array value.
|
|
2292
|
+
* Throws if the field is not an array type or the current value is not an array.
|
|
2293
|
+
*/
|
|
2294
|
+
assertArray(fieldId) {
|
|
2295
|
+
const entry = this.engine.getFieldDef(fieldId);
|
|
2296
|
+
if (!entry) throw new Error(`Field with id ${fieldId} not found`);
|
|
2297
|
+
if (entry.type !== "array") throw new Error(`Field ${fieldId} is not an array field`);
|
|
2298
|
+
const val = this.doc.values[String(fieldId)];
|
|
2299
|
+
if (!Array.isArray(val)) throw new Error(`Field ${fieldId} does not currently hold an array value`);
|
|
2300
|
+
return val;
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Returns the array value for `fieldId`, initializing to `[]` if not yet set.
|
|
2304
|
+
*/
|
|
2305
|
+
getOrInitArray(fieldId) {
|
|
2306
|
+
const entry = this.engine.getFieldDef(fieldId);
|
|
2307
|
+
if (!entry) throw new Error(`Field with id ${fieldId} not found`);
|
|
2308
|
+
if (entry.type !== "array") throw new Error(`Field ${fieldId} is not an array field`);
|
|
2309
|
+
const key = String(fieldId);
|
|
2310
|
+
let val = this.doc.values[key];
|
|
2311
|
+
if (!Array.isArray(val)) {
|
|
2312
|
+
val = [];
|
|
2313
|
+
this.doc.values[key] = val;
|
|
2314
|
+
}
|
|
2315
|
+
return val;
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
//#endregion
|
|
2319
|
+
export { ConditionEvaluator, DependencyGraph, DocumentError, FieldValidator, FormDefinitionEditor, FormDefinitionValidator, FormEngine, FormValuesEditor, VisibilityResolver };
|
|
2320
|
+
|
|
2321
|
+
//# sourceMappingURL=index.mjs.map
|