@marktiderman/genesis-forms 0.3.1 → 0.4.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 +75 -1
- package/dist/destinations.cjs +159 -6
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js +159 -6
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-CPLtKVew.d.cts → engine-BkNuzlna.d.cts} +2 -1
- package/dist/{engine-DOrPgFDw.d.ts → engine-Dz9J5ej2.d.ts} +2 -1
- package/dist/index.cjs +755 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +195 -4
- package/dist/index.d.ts +195 -4
- package/dist/index.js +736 -23
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +669 -18
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +669 -18
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +671 -18
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +671 -18
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/testing.cjs
CHANGED
|
@@ -1,5 +1,408 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/formula.ts
|
|
4
|
+
var FORMULA_FUNCTIONS = ["sum", "avg", "min", "max"];
|
|
5
|
+
var MAX_FORMULA_LENGTH = 2e3;
|
|
6
|
+
var MAX_DEPTH = 64;
|
|
7
|
+
var OP_ALIASES = { "\u2212": "-", "\xD7": "*", "\xF7": "/", "\u2260": "!=", "\u2264": "<=", "\u2265": ">=", "=": "==" };
|
|
8
|
+
var TWO_CHAR = /* @__PURE__ */ new Set(["==", "!=", "<=", ">="]);
|
|
9
|
+
var ONE_CHAR = /* @__PURE__ */ new Set(["+", "-", "*", "/", "<", ">", "(", ")", "?", ":", ",", ".", "=", "\u2212", "\xD7", "\xF7", "\u2260", "\u2264", "\u2265"]);
|
|
10
|
+
var COMPARISONS = /* @__PURE__ */ new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
11
|
+
var FormulaError = class extends Error {
|
|
12
|
+
constructor(message, at) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.at = at;
|
|
15
|
+
}
|
|
16
|
+
at;
|
|
17
|
+
};
|
|
18
|
+
var isIdStart = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_";
|
|
19
|
+
var isIdPart = (c) => isIdStart(c) || c >= "0" && c <= "9";
|
|
20
|
+
var isDigit = (c) => c >= "0" && c <= "9";
|
|
21
|
+
function tokenize(source) {
|
|
22
|
+
const tokens = [];
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < source.length) {
|
|
25
|
+
const c = source[i];
|
|
26
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
27
|
+
i++;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const at = i;
|
|
31
|
+
if (isDigit(c) || c === "." && isDigit(source[i + 1] ?? "")) {
|
|
32
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
33
|
+
if (source[i] === "." && isDigit(source[i + 1] ?? "")) {
|
|
34
|
+
i++;
|
|
35
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
36
|
+
}
|
|
37
|
+
const v = Number(source.slice(at, i));
|
|
38
|
+
if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
|
|
39
|
+
tokens.push({ t: "num", v, at });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (isIdStart(c)) {
|
|
43
|
+
while (isIdPart(source[i] ?? "")) i++;
|
|
44
|
+
tokens.push({ t: "id", v: source.slice(at, i), at });
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (c === '"' || c === "'" || c === "\u201C") {
|
|
48
|
+
const close = c === "\u201C" ? "\u201D" : c;
|
|
49
|
+
const end = source.indexOf(close, i + 1);
|
|
50
|
+
if (end === -1) throw new FormulaError("This text has no closing quote.", at);
|
|
51
|
+
tokens.push({ t: "str", v: source.slice(i + 1, end), at });
|
|
52
|
+
i = end + 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const two = source.slice(i, i + 2);
|
|
56
|
+
if (TWO_CHAR.has(two)) {
|
|
57
|
+
tokens.push({ t: "op", v: two, at });
|
|
58
|
+
i += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (ONE_CHAR.has(c)) {
|
|
62
|
+
tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
throw new FormulaError(`"${c}" can't be used in a formula.`, at);
|
|
67
|
+
}
|
|
68
|
+
tokens.push({ t: "end", at: source.length });
|
|
69
|
+
return tokens;
|
|
70
|
+
}
|
|
71
|
+
function parseFormula(source) {
|
|
72
|
+
if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
|
|
73
|
+
if (source.length > MAX_FORMULA_LENGTH) {
|
|
74
|
+
return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
|
|
75
|
+
}
|
|
76
|
+
let tokens;
|
|
77
|
+
try {
|
|
78
|
+
tokens = tokenize(source);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
const err = e;
|
|
81
|
+
return { ok: false, message: err.message, at: err.at };
|
|
82
|
+
}
|
|
83
|
+
let pos = 0;
|
|
84
|
+
const refs = [];
|
|
85
|
+
const peek = () => tokens[pos];
|
|
86
|
+
const isOp = (v) => {
|
|
87
|
+
const tok = peek();
|
|
88
|
+
return tok.t === "op" && tok.v === v;
|
|
89
|
+
};
|
|
90
|
+
const expect = (v, message) => {
|
|
91
|
+
if (!isOp(v)) throw new FormulaError(message, peek().at);
|
|
92
|
+
pos++;
|
|
93
|
+
};
|
|
94
|
+
const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
|
|
95
|
+
const guard = (depth) => {
|
|
96
|
+
if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
|
|
97
|
+
};
|
|
98
|
+
const ternary = (depth) => {
|
|
99
|
+
guard(depth);
|
|
100
|
+
const test = comparison(depth);
|
|
101
|
+
if (!isOp("?")) return test;
|
|
102
|
+
pos++;
|
|
103
|
+
const then = ternary(depth + 1);
|
|
104
|
+
expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
|
|
105
|
+
const otherwise = ternary(depth + 1);
|
|
106
|
+
return { kind: "if", test, then, else: otherwise };
|
|
107
|
+
};
|
|
108
|
+
const comparison = (depth) => {
|
|
109
|
+
const left = additive(depth);
|
|
110
|
+
const tok = peek();
|
|
111
|
+
if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
|
|
112
|
+
pos++;
|
|
113
|
+
const right = additive(depth);
|
|
114
|
+
const next = peek();
|
|
115
|
+
if (next.t === "op" && COMPARISONS.has(next.v)) {
|
|
116
|
+
throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
|
|
117
|
+
}
|
|
118
|
+
return { kind: "binary", op: tok.v, left, right };
|
|
119
|
+
};
|
|
120
|
+
const additive = (depth) => {
|
|
121
|
+
let left = multiplicative(depth);
|
|
122
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
|
|
123
|
+
pos++;
|
|
124
|
+
left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
|
|
125
|
+
}
|
|
126
|
+
return left;
|
|
127
|
+
};
|
|
128
|
+
const multiplicative = (depth) => {
|
|
129
|
+
let left = unary(depth);
|
|
130
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
|
|
131
|
+
pos++;
|
|
132
|
+
left = { kind: "binary", op: tok.v, left, right: unary(depth) };
|
|
133
|
+
}
|
|
134
|
+
return left;
|
|
135
|
+
};
|
|
136
|
+
const unary = (depth) => {
|
|
137
|
+
guard(depth);
|
|
138
|
+
if (isOp("-")) {
|
|
139
|
+
pos++;
|
|
140
|
+
return { kind: "unary", op: "-", arg: unary(depth + 1) };
|
|
141
|
+
}
|
|
142
|
+
if (isOp("+")) {
|
|
143
|
+
pos++;
|
|
144
|
+
return unary(depth + 1);
|
|
145
|
+
}
|
|
146
|
+
return primary(depth);
|
|
147
|
+
};
|
|
148
|
+
const primary = (depth) => {
|
|
149
|
+
const tok = peek();
|
|
150
|
+
if (tok.t === "num") {
|
|
151
|
+
pos++;
|
|
152
|
+
return { kind: "number", value: tok.v };
|
|
153
|
+
}
|
|
154
|
+
if (tok.t === "str") {
|
|
155
|
+
pos++;
|
|
156
|
+
return { kind: "text", value: tok.v };
|
|
157
|
+
}
|
|
158
|
+
if (tok.t === "op" && tok.v === "(") {
|
|
159
|
+
pos++;
|
|
160
|
+
const inner = ternary(depth + 1);
|
|
161
|
+
expect(")", "A `(` has no matching `)`.");
|
|
162
|
+
return inner;
|
|
163
|
+
}
|
|
164
|
+
if (tok.t === "id") {
|
|
165
|
+
pos++;
|
|
166
|
+
if (isOp("(")) {
|
|
167
|
+
if (!FORMULA_FUNCTIONS.includes(tok.v)) {
|
|
168
|
+
throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
|
|
169
|
+
}
|
|
170
|
+
pos++;
|
|
171
|
+
const args = [];
|
|
172
|
+
if (!isOp(")")) {
|
|
173
|
+
args.push(ternary(depth + 1));
|
|
174
|
+
while (isOp(",")) {
|
|
175
|
+
pos++;
|
|
176
|
+
args.push(ternary(depth + 1));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
expect(")", `"${tok.v}(" has no matching ")".`);
|
|
180
|
+
if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
|
|
181
|
+
return { kind: "call", fn: tok.v, args };
|
|
182
|
+
}
|
|
183
|
+
if (isOp(".")) {
|
|
184
|
+
pos++;
|
|
185
|
+
const column = peek();
|
|
186
|
+
if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
|
|
187
|
+
pos++;
|
|
188
|
+
refs.push({ key: tok.v, column: column.v });
|
|
189
|
+
return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
|
|
190
|
+
}
|
|
191
|
+
refs.push({ key: tok.v });
|
|
192
|
+
return { kind: "ref", key: tok.v, at: tok.at };
|
|
193
|
+
}
|
|
194
|
+
throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
const ast = ternary(0);
|
|
198
|
+
const rest = peek();
|
|
199
|
+
if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
|
|
200
|
+
return { ok: true, ast, refs };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
|
|
203
|
+
return { ok: false, message: "The formula is nested too deeply.", at: 0 };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
207
|
+
function formulaNumber(v) {
|
|
208
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
209
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
210
|
+
if (typeof v === "string") {
|
|
211
|
+
const t = v.trim().replace(/,/g, "");
|
|
212
|
+
return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
var finite = (n) => Number.isFinite(n) ? n : null;
|
|
217
|
+
var scalar = (v) => Array.isArray(v) ? null : v;
|
|
218
|
+
function truthy(v) {
|
|
219
|
+
if (v === null) return false;
|
|
220
|
+
if (typeof v === "boolean") return v;
|
|
221
|
+
if (typeof v === "number") return v !== 0;
|
|
222
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
223
|
+
return v.length > 0;
|
|
224
|
+
}
|
|
225
|
+
function numbersIn(values, out = []) {
|
|
226
|
+
for (const v of values) {
|
|
227
|
+
if (Array.isArray(v)) numbersIn(v, out);
|
|
228
|
+
else {
|
|
229
|
+
const n = formulaNumber(v);
|
|
230
|
+
if (n !== null) out.push(n);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
function compare(op, a, b) {
|
|
236
|
+
const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
|
|
237
|
+
if (blank2(a) || blank2(b)) return op === "!=";
|
|
238
|
+
const x = formulaNumber(a);
|
|
239
|
+
const y = formulaNumber(b);
|
|
240
|
+
if (x !== null && y !== null) {
|
|
241
|
+
if (op === "==") return x === y;
|
|
242
|
+
if (op === "!=") return x !== y;
|
|
243
|
+
if (op === "<") return x < y;
|
|
244
|
+
if (op === "<=") return x <= y;
|
|
245
|
+
if (op === ">") return x > y;
|
|
246
|
+
return x >= y;
|
|
247
|
+
}
|
|
248
|
+
if (op === "==") return String(a) === String(b);
|
|
249
|
+
if (op === "!=") return String(a) !== String(b);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
function evaluateFormula(ast, read) {
|
|
253
|
+
const run = (node) => {
|
|
254
|
+
switch (node.kind) {
|
|
255
|
+
case "number":
|
|
256
|
+
case "text":
|
|
257
|
+
return node.value;
|
|
258
|
+
case "ref":
|
|
259
|
+
return read(node.column !== void 0 ? { key: node.key, column: node.column } : { key: node.key });
|
|
260
|
+
case "unary": {
|
|
261
|
+
const n = formulaNumber(scalar(run(node.arg)));
|
|
262
|
+
return n === null ? null : -n;
|
|
263
|
+
}
|
|
264
|
+
case "if":
|
|
265
|
+
return truthy(run(node.test)) ? run(node.then) : run(node.else);
|
|
266
|
+
case "call": {
|
|
267
|
+
const nums = numbersIn(node.args.map(run));
|
|
268
|
+
const total = nums.reduce((a, b) => a + b, 0);
|
|
269
|
+
if (node.fn === "sum") return finite(total);
|
|
270
|
+
if (!nums.length) return null;
|
|
271
|
+
if (node.fn === "avg") return finite(total / nums.length);
|
|
272
|
+
return nums.reduce((a, b) => node.fn === "min" ? Math.min(a, b) : Math.max(a, b));
|
|
273
|
+
}
|
|
274
|
+
case "binary": {
|
|
275
|
+
const a = run(node.left);
|
|
276
|
+
const b = run(node.right);
|
|
277
|
+
if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
|
|
278
|
+
const x = formulaNumber(scalar(a));
|
|
279
|
+
const y = formulaNumber(scalar(b));
|
|
280
|
+
if (x === null || y === null) return null;
|
|
281
|
+
if (node.op === "+") return finite(x + y);
|
|
282
|
+
if (node.op === "-") return finite(x - y);
|
|
283
|
+
if (node.op === "*") return finite(x * y);
|
|
284
|
+
return y === 0 ? null : finite(x / y);
|
|
285
|
+
}
|
|
286
|
+
return compare(node.op, a, b);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
return run(ast);
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/calculated.ts
|
|
298
|
+
var CALCULATED_TYPE = "calculated";
|
|
299
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
300
|
+
function calculatedProps(field) {
|
|
301
|
+
const p = field.props ?? {};
|
|
302
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
303
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
304
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
305
|
+
out.decimals = p.decimals;
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
var isCalculated = (field) => field?.type === CALCULATED_TYPE;
|
|
310
|
+
var round = (n, decimals) => {
|
|
311
|
+
if (decimals === void 0) return n;
|
|
312
|
+
const r = Number(n.toFixed(decimals));
|
|
313
|
+
return Object.is(r, -0) ? 0 : r;
|
|
314
|
+
};
|
|
315
|
+
function calculatedValue(result, field) {
|
|
316
|
+
const { format, decimals } = calculatedProps(field);
|
|
317
|
+
if (result === null || Array.isArray(result)) return void 0;
|
|
318
|
+
if (format === "number" || format === "percent") {
|
|
319
|
+
const n = formulaNumber(result);
|
|
320
|
+
return n === null ? void 0 : round(n, decimals);
|
|
321
|
+
}
|
|
322
|
+
if (format === "text") {
|
|
323
|
+
if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
|
|
324
|
+
return typeof result === "boolean" ? result ? "Yes" : "No" : result;
|
|
325
|
+
}
|
|
326
|
+
return typeof result === "number" ? round(result, decimals) : result;
|
|
327
|
+
}
|
|
328
|
+
function formatCalculated(value, field) {
|
|
329
|
+
if (value === void 0 || value === null) return "";
|
|
330
|
+
const { format, decimals } = calculatedProps(field);
|
|
331
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
332
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
333
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
334
|
+
return typeof value === "string" ? value : "";
|
|
335
|
+
}
|
|
336
|
+
var MAX_FORMULA_ITEMS = 1e4;
|
|
337
|
+
function formulaInput(value, column) {
|
|
338
|
+
if (value === void 0 || value === null) return null;
|
|
339
|
+
if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
|
|
340
|
+
if (column !== void 0) {
|
|
341
|
+
if (!Array.isArray(value)) return null;
|
|
342
|
+
return value.flatMap((row) => {
|
|
343
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
344
|
+
const cell = row[column];
|
|
345
|
+
return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
return value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
|
|
350
|
+
}
|
|
351
|
+
return typeof value === "object" ? null : value;
|
|
352
|
+
}
|
|
353
|
+
var UNREAD = /* @__PURE__ */ Symbol("unread");
|
|
354
|
+
function calculator(definition) {
|
|
355
|
+
const compiled = [];
|
|
356
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const block of definition.blocks) {
|
|
358
|
+
if (block.kind !== "field") continue;
|
|
359
|
+
idByKey.set(block.key, block.id);
|
|
360
|
+
}
|
|
361
|
+
for (const block of definition.blocks) {
|
|
362
|
+
if (block.kind !== "field" || !isCalculated(block)) continue;
|
|
363
|
+
const parsed = parseFormula(calculatedProps(block).formula);
|
|
364
|
+
const reads = parsed.ok ? parsed.refs.map((r) => idByKey.get(r.key)) : [];
|
|
365
|
+
compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, last: null });
|
|
366
|
+
}
|
|
367
|
+
const withCalculated = (values, path) => {
|
|
368
|
+
if (!compiled.length) return values;
|
|
369
|
+
const visible = new Set(path);
|
|
370
|
+
let next = values;
|
|
371
|
+
const input = (id) => id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id) ? UNREAD : next[id];
|
|
372
|
+
for (const entry of compiled) {
|
|
373
|
+
const { field, ast } = entry;
|
|
374
|
+
const inputs = entry.reads.map(input);
|
|
375
|
+
let value;
|
|
376
|
+
if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
|
|
377
|
+
value = entry.last.value;
|
|
378
|
+
} else {
|
|
379
|
+
const read = (ref) => {
|
|
380
|
+
const raw = input(idByKey.get(ref.key));
|
|
381
|
+
return raw === UNREAD ? null : formulaInput(raw, ref.column);
|
|
382
|
+
};
|
|
383
|
+
value = ast ? calculatedValue(evaluateFormula(ast, read), field) : void 0;
|
|
384
|
+
entry.last = { inputs, value };
|
|
385
|
+
}
|
|
386
|
+
const current = Object.prototype.hasOwnProperty.call(next, field.id) ? next[field.id] : void 0;
|
|
387
|
+
if (current !== value) next = { ...next, [field.id]: value };
|
|
388
|
+
}
|
|
389
|
+
return next;
|
|
390
|
+
};
|
|
391
|
+
return { active: compiled.length > 0, withCalculated };
|
|
392
|
+
}
|
|
393
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
394
|
+
var calculatedFieldType = {
|
|
395
|
+
type: CALCULATED_TYPE,
|
|
396
|
+
label: "Calculated",
|
|
397
|
+
category: "advanced",
|
|
398
|
+
value: { json: true },
|
|
399
|
+
operators: numberOps,
|
|
400
|
+
a11y: { semantics: "control" },
|
|
401
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
402
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
403
|
+
summarize: (value, field) => formatCalculated(value, field)
|
|
404
|
+
};
|
|
405
|
+
|
|
3
406
|
// src/structured.ts
|
|
4
407
|
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5
408
|
function intProp(field, name, fallback, floor = 1) {
|
|
@@ -233,7 +636,7 @@ function intRange(field, min, maxDefault) {
|
|
|
233
636
|
const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
|
|
234
637
|
return { min, max };
|
|
235
638
|
}
|
|
236
|
-
var
|
|
639
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
237
640
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
238
641
|
function maxLength(field, fallback) {
|
|
239
642
|
return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
|
|
@@ -311,23 +714,30 @@ var builtins = [
|
|
|
311
714
|
label: "Rating",
|
|
312
715
|
category: "scale",
|
|
313
716
|
value: { json: true },
|
|
314
|
-
operators:
|
|
717
|
+
operators: numberOps2,
|
|
315
718
|
a11y: { semantics: "group" },
|
|
316
719
|
validate: (value, field) => {
|
|
317
720
|
const { min, max } = intRange(field, 1, 5);
|
|
318
721
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
319
722
|
},
|
|
320
|
-
summarize: (value, field) =>
|
|
723
|
+
summarize: (value, field) => {
|
|
724
|
+
const { max } = intRange(field, 1, 5);
|
|
725
|
+
const end = scaleEndLabel(field, value, 1, max);
|
|
726
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
727
|
+
}
|
|
321
728
|
},
|
|
322
729
|
{
|
|
323
730
|
type: "nps",
|
|
324
731
|
label: "Net Promoter Score",
|
|
325
732
|
category: "scale",
|
|
326
733
|
value: { json: true },
|
|
327
|
-
operators:
|
|
734
|
+
operators: numberOps2,
|
|
328
735
|
a11y: { semantics: "group" },
|
|
329
736
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
330
|
-
summarize: (value) =>
|
|
737
|
+
summarize: (value, field) => {
|
|
738
|
+
const end = scaleEndLabel(field, value, 0, 10);
|
|
739
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
740
|
+
}
|
|
331
741
|
},
|
|
332
742
|
{
|
|
333
743
|
type: "single-choice",
|
|
@@ -361,9 +771,122 @@ var builtins = [
|
|
|
361
771
|
a11y: { semantics: "group" },
|
|
362
772
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
363
773
|
summarize: (value) => value ? "Yes" : "No"
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
type: "dropdown",
|
|
777
|
+
label: "Dropdown",
|
|
778
|
+
category: "choice",
|
|
779
|
+
value: { json: true },
|
|
780
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
781
|
+
a11y: { semantics: "control" },
|
|
782
|
+
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
783
|
+
summarize: (value, field) => optionLabel(field, String(value))
|
|
784
|
+
},
|
|
785
|
+
{
|
|
786
|
+
type: "number",
|
|
787
|
+
label: "Number",
|
|
788
|
+
category: "text",
|
|
789
|
+
value: { json: true },
|
|
790
|
+
operators: numberOps2,
|
|
791
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
792
|
+
validate: (value, field) => {
|
|
793
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
794
|
+
const { min, max, step } = numberLimits(field);
|
|
795
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
796
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
797
|
+
}
|
|
798
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
799
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
800
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
801
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
802
|
+
}
|
|
803
|
+
return null;
|
|
804
|
+
},
|
|
805
|
+
summarize: (value) => String(value),
|
|
806
|
+
samples: (field) => {
|
|
807
|
+
const { min, max } = numberLimits(field);
|
|
808
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
type: "date",
|
|
813
|
+
label: "Date",
|
|
814
|
+
category: "date",
|
|
815
|
+
value: { json: true },
|
|
816
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
817
|
+
a11y: { semantics: "control" },
|
|
818
|
+
validate: (value, field) => {
|
|
819
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
820
|
+
const date = value.trim();
|
|
821
|
+
const { min, max } = dateLimits(field);
|
|
822
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
823
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
824
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
825
|
+
return null;
|
|
826
|
+
},
|
|
827
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
828
|
+
samples: (field) => {
|
|
829
|
+
const { min, max } = dateLimits(field);
|
|
830
|
+
return [min ?? max ?? "2000-01-01"];
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
type: "consent",
|
|
835
|
+
label: "Consent checkbox",
|
|
836
|
+
category: "choice",
|
|
837
|
+
value: { json: true },
|
|
838
|
+
operators: ["answered", "empty"],
|
|
839
|
+
a11y: { semantics: "group" },
|
|
840
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
841
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
842
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
843
|
+
summarize: () => "Agreed",
|
|
844
|
+
samples: () => [true]
|
|
364
845
|
}
|
|
365
846
|
];
|
|
366
|
-
|
|
847
|
+
function scaleLabels(field) {
|
|
848
|
+
const out = {};
|
|
849
|
+
const low = field.props?.lowLabel;
|
|
850
|
+
const high = field.props?.highLabel;
|
|
851
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
852
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
853
|
+
return out;
|
|
854
|
+
}
|
|
855
|
+
function scaleEndLabel(field, value, min, max) {
|
|
856
|
+
const { low, high } = scaleLabels(field);
|
|
857
|
+
return value === min ? low : value === max ? high : void 0;
|
|
858
|
+
}
|
|
859
|
+
var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
860
|
+
function numberLimits(field) {
|
|
861
|
+
const out = {};
|
|
862
|
+
const min = finite2(field.props?.min);
|
|
863
|
+
const max = finite2(field.props?.max);
|
|
864
|
+
const step = finite2(field.props?.step);
|
|
865
|
+
if (min !== void 0) out.min = min;
|
|
866
|
+
if (max !== void 0) out.max = max;
|
|
867
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
function onStep(value, base, step) {
|
|
871
|
+
const n = (value - base) / step;
|
|
872
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
873
|
+
}
|
|
874
|
+
function isIsoDate(value) {
|
|
875
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
876
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
877
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
878
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
879
|
+
return d <= days[m - 1];
|
|
880
|
+
}
|
|
881
|
+
function dateLimits(field) {
|
|
882
|
+
const out = {};
|
|
883
|
+
const min = field.props?.min;
|
|
884
|
+
const max = field.props?.max;
|
|
885
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
886
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
887
|
+
return out;
|
|
888
|
+
}
|
|
889
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
367
890
|
|
|
368
891
|
// src/events.ts
|
|
369
892
|
function isPlainObject(value) {
|
|
@@ -608,7 +1131,7 @@ function runCustom(validator, value) {
|
|
|
608
1131
|
}
|
|
609
1132
|
function validateField(field, value, options = {}) {
|
|
610
1133
|
if (isEmptyValue(value, field)) {
|
|
611
|
-
return field.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1134
|
+
return field.required && field.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
612
1135
|
}
|
|
613
1136
|
const manifest = getFieldType(field.type);
|
|
614
1137
|
if (!manifest) return `Unknown field type "${field.type}".`;
|
|
@@ -639,9 +1162,21 @@ function build(definition, options, replaying = false) {
|
|
|
639
1162
|
if (!declared.has(name)) initialHidden[name] = value;
|
|
640
1163
|
}
|
|
641
1164
|
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
642
|
-
const
|
|
1165
|
+
const calc = calculator(definition);
|
|
1166
|
+
const settle = (values, hidden) => {
|
|
1167
|
+
let { path } = computePath(definition, scopeOf(values, hidden));
|
|
1168
|
+
if (!calc.active) return { values, path };
|
|
1169
|
+
for (let round2 = 0; round2 < 4; round2++) {
|
|
1170
|
+
const next = calc.withCalculated(values, path);
|
|
1171
|
+
if (next === values) break;
|
|
1172
|
+
values = next;
|
|
1173
|
+
path = computePath(definition, scopeOf(values, hidden)).path;
|
|
1174
|
+
}
|
|
1175
|
+
return { values, path };
|
|
1176
|
+
};
|
|
1177
|
+
const first = settle({}, initialHidden);
|
|
643
1178
|
let state = {
|
|
644
|
-
values:
|
|
1179
|
+
values: first.values,
|
|
645
1180
|
hidden: initialHidden,
|
|
646
1181
|
path: first.path,
|
|
647
1182
|
current: first.path[0] ?? null,
|
|
@@ -676,12 +1211,25 @@ function build(definition, options, replaying = false) {
|
|
|
676
1211
|
}
|
|
677
1212
|
return errors;
|
|
678
1213
|
};
|
|
1214
|
+
let unsettled = false;
|
|
1215
|
+
const settleNow = () => {
|
|
1216
|
+
if (!unsettled) return;
|
|
1217
|
+
unsettled = false;
|
|
1218
|
+
const { values, path } = settle(state.values, state.hidden);
|
|
1219
|
+
state = { ...state, values, path };
|
|
1220
|
+
};
|
|
679
1221
|
const answer = (id, value, event) => {
|
|
680
|
-
if (state.status === "completed" || own(state.values, id) === value) return;
|
|
681
|
-
const values = { ...state.values, [id]: value };
|
|
682
|
-
const { path } = computePath(definition, scopeOf(values, state.hidden));
|
|
1222
|
+
if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
|
|
683
1223
|
const errors = { ...state.errors };
|
|
684
1224
|
delete errors[id];
|
|
1225
|
+
if (batch) {
|
|
1226
|
+
state.values = { ...state.values, [id]: value };
|
|
1227
|
+
state.errors = errors;
|
|
1228
|
+
log(event);
|
|
1229
|
+
unsettled = true;
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
685
1233
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
686
1234
|
};
|
|
687
1235
|
const engine = {
|
|
@@ -753,10 +1301,14 @@ function build(definition, options, replaying = false) {
|
|
|
753
1301
|
}
|
|
754
1302
|
};
|
|
755
1303
|
if (options.initialValues) {
|
|
1304
|
+
const wasBatch = batch;
|
|
1305
|
+
batch = true;
|
|
756
1306
|
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
757
1307
|
if (!fields.has(id) || value === void 0) continue;
|
|
758
1308
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
759
1309
|
}
|
|
1310
|
+
settleNow();
|
|
1311
|
+
batch = wasBatch;
|
|
760
1312
|
}
|
|
761
1313
|
const apply = (event) => {
|
|
762
1314
|
if (event.type === "answer") {
|
|
@@ -764,11 +1316,19 @@ function build(definition, options, replaying = false) {
|
|
|
764
1316
|
const { at: _at, ...logged } = event;
|
|
765
1317
|
answer(event.field, event.value, logged);
|
|
766
1318
|
}
|
|
767
|
-
} else if (event.type === "next")
|
|
768
|
-
|
|
769
|
-
|
|
1319
|
+
} else if (event.type === "next") {
|
|
1320
|
+
settleNow();
|
|
1321
|
+
engine.next();
|
|
1322
|
+
} else if (event.type === "back") {
|
|
1323
|
+
settleNow();
|
|
1324
|
+
engine.back();
|
|
1325
|
+
} else if (event.type === "submit") {
|
|
1326
|
+
settleNow();
|
|
1327
|
+
engine.submit();
|
|
1328
|
+
}
|
|
770
1329
|
};
|
|
771
1330
|
const finish = () => {
|
|
1331
|
+
settleNow();
|
|
772
1332
|
batch = false;
|
|
773
1333
|
state = { ...state, events: [...state.events] };
|
|
774
1334
|
};
|
|
@@ -829,6 +1389,7 @@ function dedupe(values) {
|
|
|
829
1389
|
});
|
|
830
1390
|
}
|
|
831
1391
|
function domainOf(field, extra, maxOptions) {
|
|
1392
|
+
if (field.type === CALCULATED_TYPE) return dedupe([void 0, ...extra]).map((value) => ({ value }));
|
|
832
1393
|
const options = choiceOptions(field).map((o) => o.value);
|
|
833
1394
|
let base;
|
|
834
1395
|
switch (field.type) {
|
|
@@ -844,6 +1405,7 @@ function domainOf(field, extra, maxOptions) {
|
|
|
844
1405
|
base = [true, false];
|
|
845
1406
|
break;
|
|
846
1407
|
case "single-choice":
|
|
1408
|
+
case "dropdown":
|
|
847
1409
|
base = options;
|
|
848
1410
|
break;
|
|
849
1411
|
case "multi-choice": {
|
|
@@ -981,8 +1543,15 @@ var LITERAL_TYPE = {
|
|
|
981
1543
|
"single-choice": "string",
|
|
982
1544
|
"multi-choice": "string",
|
|
983
1545
|
"yes-no": "boolean",
|
|
984
|
-
list: "string"
|
|
1546
|
+
list: "string",
|
|
1547
|
+
dropdown: "string",
|
|
1548
|
+
number: "number",
|
|
1549
|
+
date: "string",
|
|
1550
|
+
consent: "boolean",
|
|
1551
|
+
// A calculated field holds a number unless its format says text; see `literalType`.
|
|
1552
|
+
calculated: "number"
|
|
985
1553
|
};
|
|
1554
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
986
1555
|
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
987
1556
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
988
1557
|
"eq",
|
|
@@ -1062,7 +1631,44 @@ function parseForm(input) {
|
|
|
1062
1631
|
} else if (type === "text-highlights") {
|
|
1063
1632
|
optionalText(props.text, `${path}.text`);
|
|
1064
1633
|
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1634
|
+
} else if (type === CALCULATED_TYPE) {
|
|
1635
|
+
if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
|
|
1636
|
+
issue(`${path}.format`, "Must be number, percent or text.");
|
|
1637
|
+
}
|
|
1638
|
+
if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
1639
|
+
issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1644
|
+
const fieldProps = (type, props, path) => {
|
|
1645
|
+
if (type === "rating" || type === "nps") {
|
|
1646
|
+
optionalText(props.lowLabel, `${path}.lowLabel`);
|
|
1647
|
+
optionalText(props.highLabel, `${path}.highLabel`);
|
|
1648
|
+
}
|
|
1649
|
+
if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
|
|
1650
|
+
issue(`${path}.display`, "Must be numbers or stars.");
|
|
1651
|
+
}
|
|
1652
|
+
if (type === "number") {
|
|
1653
|
+
for (const k of ["min", "max", "step"]) {
|
|
1654
|
+
if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
|
|
1655
|
+
}
|
|
1656
|
+
if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
|
|
1657
|
+
if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
|
|
1658
|
+
issue(`${path}.min`, "Can't be more than the largest number.");
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (type === "date") {
|
|
1662
|
+
for (const k of ["min", "max"]) {
|
|
1663
|
+
if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
|
|
1664
|
+
issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
|
|
1668
|
+
issue(`${path}.min`, "Can't be after the latest date.");
|
|
1669
|
+
}
|
|
1065
1670
|
}
|
|
1671
|
+
if (type === "consent") optionalText(props.text, `${path}.text`);
|
|
1066
1672
|
};
|
|
1067
1673
|
if (!isObject2(input)) {
|
|
1068
1674
|
issue("", "A form definition must be an object.");
|
|
@@ -1097,7 +1703,9 @@ function parseForm(input) {
|
|
|
1097
1703
|
const keys = /* @__PURE__ */ new Map();
|
|
1098
1704
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
1099
1705
|
const order = /* @__PURE__ */ new Map();
|
|
1706
|
+
const tableColumnKeys = /* @__PURE__ */ new Map();
|
|
1100
1707
|
const choices = /* @__PURE__ */ new Map();
|
|
1708
|
+
const calculatedLiteral = /* @__PURE__ */ new Map();
|
|
1101
1709
|
input.blocks.forEach((block, i) => {
|
|
1102
1710
|
const path = `blocks[${i}]`;
|
|
1103
1711
|
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
@@ -1147,12 +1755,24 @@ function parseForm(input) {
|
|
|
1147
1755
|
}
|
|
1148
1756
|
if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
|
|
1149
1757
|
structuredProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1758
|
+
fieldProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1150
1759
|
}
|
|
1151
1760
|
if (validKey) {
|
|
1152
1761
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
1153
1762
|
keys.set(block.key, block.id);
|
|
1154
1763
|
}
|
|
1155
1764
|
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
1765
|
+
if (block.type === CALCULATED_TYPE) {
|
|
1766
|
+
const format = isObject2(block.props) ? block.props.format : void 0;
|
|
1767
|
+
if (format === "text") calculatedLiteral.set(block.id, "string");
|
|
1768
|
+
else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
|
|
1769
|
+
}
|
|
1770
|
+
if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
|
|
1771
|
+
tableColumnKeys.set(
|
|
1772
|
+
block.id,
|
|
1773
|
+
new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1156
1776
|
} else if (block.kind === "content") {
|
|
1157
1777
|
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
1158
1778
|
text(block.text, `${path}.text`);
|
|
@@ -1162,6 +1782,38 @@ function parseForm(input) {
|
|
|
1162
1782
|
issue(`${path}.kind`, "Unknown block kind.");
|
|
1163
1783
|
}
|
|
1164
1784
|
});
|
|
1785
|
+
input.blocks.forEach((block, i) => {
|
|
1786
|
+
if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
|
|
1787
|
+
const path = `blocks[${i}].props.formula`;
|
|
1788
|
+
if (block.required === true) {
|
|
1789
|
+
warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
|
|
1790
|
+
}
|
|
1791
|
+
const formula = isObject2(block.props) ? block.props.formula : void 0;
|
|
1792
|
+
if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
|
|
1793
|
+
const parsed = parseFormula(formula);
|
|
1794
|
+
if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
|
|
1795
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1796
|
+
for (const ref of parsed.refs) {
|
|
1797
|
+
const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
|
|
1798
|
+
if (reported.has(name2)) continue;
|
|
1799
|
+
reported.add(name2);
|
|
1800
|
+
const target = keys.get(ref.key);
|
|
1801
|
+
const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
|
|
1802
|
+
const at = target === void 0 ? void 0 : order.get(target);
|
|
1803
|
+
if (target === void 0 || targetType === void 0) issue(path, `The formula uses "${ref.key}", which is not the answer name of a question in this form.`);
|
|
1804
|
+
else if (target === block.id) issue(path, "The formula uses this field's own value.");
|
|
1805
|
+
else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
|
|
1806
|
+
issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
|
|
1807
|
+
} else if (targetType === "table") {
|
|
1808
|
+
const columns = tableColumnKeys.get(target);
|
|
1809
|
+
if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
|
|
1810
|
+
else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
|
|
1811
|
+
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1812
|
+
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1813
|
+
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1165
1817
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
1166
1818
|
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
1167
1819
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
@@ -1245,8 +1897,8 @@ function parseForm(input) {
|
|
|
1245
1897
|
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1246
1898
|
const fieldType = fieldTypes.get(left.field);
|
|
1247
1899
|
what = `"${keyOf(left.field)}"`;
|
|
1248
|
-
type = LITERAL_TYPE[fieldType];
|
|
1249
|
-
if (fieldType
|
|
1900
|
+
type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
|
|
1901
|
+
if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
|
|
1250
1902
|
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1251
1903
|
what = `hidden input "${left.hidden}"`;
|
|
1252
1904
|
type = hiddenTypes.get(left.hidden);
|
|
@@ -1401,6 +2053,7 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
1401
2053
|
const fields = fieldMap(definition);
|
|
1402
2054
|
const accept = (id) => {
|
|
1403
2055
|
if (!fields.has(id)) return void ignored.add(id);
|
|
2056
|
+
if (isCalculated(fields.get(id))) return false;
|
|
1404
2057
|
if (!withheld(definition, fields.get(id))) return true;
|
|
1405
2058
|
ignored.add(id);
|
|
1406
2059
|
return false;
|