@marktiderman/genesis-forms 0.3.0 → 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 +210 -33
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js +210 -33
- 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 +758 -23
- 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 +739 -24
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +672 -19
- 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 +672 -19
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +674 -19
- 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 +674 -19
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,3 +1,406 @@
|
|
|
1
|
+
// src/formula.ts
|
|
2
|
+
var FORMULA_FUNCTIONS = ["sum", "avg", "min", "max"];
|
|
3
|
+
var MAX_FORMULA_LENGTH = 2e3;
|
|
4
|
+
var MAX_DEPTH = 64;
|
|
5
|
+
var OP_ALIASES = { "\u2212": "-", "\xD7": "*", "\xF7": "/", "\u2260": "!=", "\u2264": "<=", "\u2265": ">=", "=": "==" };
|
|
6
|
+
var TWO_CHAR = /* @__PURE__ */ new Set(["==", "!=", "<=", ">="]);
|
|
7
|
+
var ONE_CHAR = /* @__PURE__ */ new Set(["+", "-", "*", "/", "<", ">", "(", ")", "?", ":", ",", ".", "=", "\u2212", "\xD7", "\xF7", "\u2260", "\u2264", "\u2265"]);
|
|
8
|
+
var COMPARISONS = /* @__PURE__ */ new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
9
|
+
var FormulaError = class extends Error {
|
|
10
|
+
constructor(message, at) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.at = at;
|
|
13
|
+
}
|
|
14
|
+
at;
|
|
15
|
+
};
|
|
16
|
+
var isIdStart = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_";
|
|
17
|
+
var isIdPart = (c) => isIdStart(c) || c >= "0" && c <= "9";
|
|
18
|
+
var isDigit = (c) => c >= "0" && c <= "9";
|
|
19
|
+
function tokenize(source) {
|
|
20
|
+
const tokens = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < source.length) {
|
|
23
|
+
const c = source[i];
|
|
24
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const at = i;
|
|
29
|
+
if (isDigit(c) || c === "." && isDigit(source[i + 1] ?? "")) {
|
|
30
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
31
|
+
if (source[i] === "." && isDigit(source[i + 1] ?? "")) {
|
|
32
|
+
i++;
|
|
33
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
34
|
+
}
|
|
35
|
+
const v = Number(source.slice(at, i));
|
|
36
|
+
if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
|
|
37
|
+
tokens.push({ t: "num", v, at });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (isIdStart(c)) {
|
|
41
|
+
while (isIdPart(source[i] ?? "")) i++;
|
|
42
|
+
tokens.push({ t: "id", v: source.slice(at, i), at });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (c === '"' || c === "'" || c === "\u201C") {
|
|
46
|
+
const close = c === "\u201C" ? "\u201D" : c;
|
|
47
|
+
const end = source.indexOf(close, i + 1);
|
|
48
|
+
if (end === -1) throw new FormulaError("This text has no closing quote.", at);
|
|
49
|
+
tokens.push({ t: "str", v: source.slice(i + 1, end), at });
|
|
50
|
+
i = end + 1;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const two = source.slice(i, i + 2);
|
|
54
|
+
if (TWO_CHAR.has(two)) {
|
|
55
|
+
tokens.push({ t: "op", v: two, at });
|
|
56
|
+
i += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ONE_CHAR.has(c)) {
|
|
60
|
+
tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
throw new FormulaError(`"${c}" can't be used in a formula.`, at);
|
|
65
|
+
}
|
|
66
|
+
tokens.push({ t: "end", at: source.length });
|
|
67
|
+
return tokens;
|
|
68
|
+
}
|
|
69
|
+
function parseFormula(source) {
|
|
70
|
+
if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
|
|
71
|
+
if (source.length > MAX_FORMULA_LENGTH) {
|
|
72
|
+
return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
|
|
73
|
+
}
|
|
74
|
+
let tokens;
|
|
75
|
+
try {
|
|
76
|
+
tokens = tokenize(source);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
const err = e;
|
|
79
|
+
return { ok: false, message: err.message, at: err.at };
|
|
80
|
+
}
|
|
81
|
+
let pos = 0;
|
|
82
|
+
const refs = [];
|
|
83
|
+
const peek = () => tokens[pos];
|
|
84
|
+
const isOp = (v) => {
|
|
85
|
+
const tok = peek();
|
|
86
|
+
return tok.t === "op" && tok.v === v;
|
|
87
|
+
};
|
|
88
|
+
const expect = (v, message) => {
|
|
89
|
+
if (!isOp(v)) throw new FormulaError(message, peek().at);
|
|
90
|
+
pos++;
|
|
91
|
+
};
|
|
92
|
+
const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
|
|
93
|
+
const guard = (depth) => {
|
|
94
|
+
if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
|
|
95
|
+
};
|
|
96
|
+
const ternary = (depth) => {
|
|
97
|
+
guard(depth);
|
|
98
|
+
const test = comparison(depth);
|
|
99
|
+
if (!isOp("?")) return test;
|
|
100
|
+
pos++;
|
|
101
|
+
const then = ternary(depth + 1);
|
|
102
|
+
expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
|
|
103
|
+
const otherwise = ternary(depth + 1);
|
|
104
|
+
return { kind: "if", test, then, else: otherwise };
|
|
105
|
+
};
|
|
106
|
+
const comparison = (depth) => {
|
|
107
|
+
const left = additive(depth);
|
|
108
|
+
const tok = peek();
|
|
109
|
+
if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
|
|
110
|
+
pos++;
|
|
111
|
+
const right = additive(depth);
|
|
112
|
+
const next = peek();
|
|
113
|
+
if (next.t === "op" && COMPARISONS.has(next.v)) {
|
|
114
|
+
throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
|
|
115
|
+
}
|
|
116
|
+
return { kind: "binary", op: tok.v, left, right };
|
|
117
|
+
};
|
|
118
|
+
const additive = (depth) => {
|
|
119
|
+
let left = multiplicative(depth);
|
|
120
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
|
|
121
|
+
pos++;
|
|
122
|
+
left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
|
|
123
|
+
}
|
|
124
|
+
return left;
|
|
125
|
+
};
|
|
126
|
+
const multiplicative = (depth) => {
|
|
127
|
+
let left = unary(depth);
|
|
128
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
|
|
129
|
+
pos++;
|
|
130
|
+
left = { kind: "binary", op: tok.v, left, right: unary(depth) };
|
|
131
|
+
}
|
|
132
|
+
return left;
|
|
133
|
+
};
|
|
134
|
+
const unary = (depth) => {
|
|
135
|
+
guard(depth);
|
|
136
|
+
if (isOp("-")) {
|
|
137
|
+
pos++;
|
|
138
|
+
return { kind: "unary", op: "-", arg: unary(depth + 1) };
|
|
139
|
+
}
|
|
140
|
+
if (isOp("+")) {
|
|
141
|
+
pos++;
|
|
142
|
+
return unary(depth + 1);
|
|
143
|
+
}
|
|
144
|
+
return primary(depth);
|
|
145
|
+
};
|
|
146
|
+
const primary = (depth) => {
|
|
147
|
+
const tok = peek();
|
|
148
|
+
if (tok.t === "num") {
|
|
149
|
+
pos++;
|
|
150
|
+
return { kind: "number", value: tok.v };
|
|
151
|
+
}
|
|
152
|
+
if (tok.t === "str") {
|
|
153
|
+
pos++;
|
|
154
|
+
return { kind: "text", value: tok.v };
|
|
155
|
+
}
|
|
156
|
+
if (tok.t === "op" && tok.v === "(") {
|
|
157
|
+
pos++;
|
|
158
|
+
const inner = ternary(depth + 1);
|
|
159
|
+
expect(")", "A `(` has no matching `)`.");
|
|
160
|
+
return inner;
|
|
161
|
+
}
|
|
162
|
+
if (tok.t === "id") {
|
|
163
|
+
pos++;
|
|
164
|
+
if (isOp("(")) {
|
|
165
|
+
if (!FORMULA_FUNCTIONS.includes(tok.v)) {
|
|
166
|
+
throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
|
|
167
|
+
}
|
|
168
|
+
pos++;
|
|
169
|
+
const args = [];
|
|
170
|
+
if (!isOp(")")) {
|
|
171
|
+
args.push(ternary(depth + 1));
|
|
172
|
+
while (isOp(",")) {
|
|
173
|
+
pos++;
|
|
174
|
+
args.push(ternary(depth + 1));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
expect(")", `"${tok.v}(" has no matching ")".`);
|
|
178
|
+
if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
|
|
179
|
+
return { kind: "call", fn: tok.v, args };
|
|
180
|
+
}
|
|
181
|
+
if (isOp(".")) {
|
|
182
|
+
pos++;
|
|
183
|
+
const column = peek();
|
|
184
|
+
if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
|
|
185
|
+
pos++;
|
|
186
|
+
refs.push({ key: tok.v, column: column.v });
|
|
187
|
+
return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
|
|
188
|
+
}
|
|
189
|
+
refs.push({ key: tok.v });
|
|
190
|
+
return { kind: "ref", key: tok.v, at: tok.at };
|
|
191
|
+
}
|
|
192
|
+
throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
const ast = ternary(0);
|
|
196
|
+
const rest = peek();
|
|
197
|
+
if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
|
|
198
|
+
return { ok: true, ast, refs };
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
|
|
201
|
+
return { ok: false, message: "The formula is nested too deeply.", at: 0 };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
205
|
+
function formulaNumber(v) {
|
|
206
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
207
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
208
|
+
if (typeof v === "string") {
|
|
209
|
+
const t = v.trim().replace(/,/g, "");
|
|
210
|
+
return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
var finite = (n) => Number.isFinite(n) ? n : null;
|
|
215
|
+
var scalar = (v) => Array.isArray(v) ? null : v;
|
|
216
|
+
function truthy(v) {
|
|
217
|
+
if (v === null) return false;
|
|
218
|
+
if (typeof v === "boolean") return v;
|
|
219
|
+
if (typeof v === "number") return v !== 0;
|
|
220
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
221
|
+
return v.length > 0;
|
|
222
|
+
}
|
|
223
|
+
function numbersIn(values, out = []) {
|
|
224
|
+
for (const v of values) {
|
|
225
|
+
if (Array.isArray(v)) numbersIn(v, out);
|
|
226
|
+
else {
|
|
227
|
+
const n = formulaNumber(v);
|
|
228
|
+
if (n !== null) out.push(n);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
function compare(op, a, b) {
|
|
234
|
+
const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
|
|
235
|
+
if (blank2(a) || blank2(b)) return op === "!=";
|
|
236
|
+
const x = formulaNumber(a);
|
|
237
|
+
const y = formulaNumber(b);
|
|
238
|
+
if (x !== null && y !== null) {
|
|
239
|
+
if (op === "==") return x === y;
|
|
240
|
+
if (op === "!=") return x !== y;
|
|
241
|
+
if (op === "<") return x < y;
|
|
242
|
+
if (op === "<=") return x <= y;
|
|
243
|
+
if (op === ">") return x > y;
|
|
244
|
+
return x >= y;
|
|
245
|
+
}
|
|
246
|
+
if (op === "==") return String(a) === String(b);
|
|
247
|
+
if (op === "!=") return String(a) !== String(b);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
function evaluateFormula(ast, read) {
|
|
251
|
+
const run = (node) => {
|
|
252
|
+
switch (node.kind) {
|
|
253
|
+
case "number":
|
|
254
|
+
case "text":
|
|
255
|
+
return node.value;
|
|
256
|
+
case "ref":
|
|
257
|
+
return read(node.column !== void 0 ? { key: node.key, column: node.column } : { key: node.key });
|
|
258
|
+
case "unary": {
|
|
259
|
+
const n = formulaNumber(scalar(run(node.arg)));
|
|
260
|
+
return n === null ? null : -n;
|
|
261
|
+
}
|
|
262
|
+
case "if":
|
|
263
|
+
return truthy(run(node.test)) ? run(node.then) : run(node.else);
|
|
264
|
+
case "call": {
|
|
265
|
+
const nums = numbersIn(node.args.map(run));
|
|
266
|
+
const total = nums.reduce((a, b) => a + b, 0);
|
|
267
|
+
if (node.fn === "sum") return finite(total);
|
|
268
|
+
if (!nums.length) return null;
|
|
269
|
+
if (node.fn === "avg") return finite(total / nums.length);
|
|
270
|
+
return nums.reduce((a, b) => node.fn === "min" ? Math.min(a, b) : Math.max(a, b));
|
|
271
|
+
}
|
|
272
|
+
case "binary": {
|
|
273
|
+
const a = run(node.left);
|
|
274
|
+
const b = run(node.right);
|
|
275
|
+
if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
|
|
276
|
+
const x = formulaNumber(scalar(a));
|
|
277
|
+
const y = formulaNumber(scalar(b));
|
|
278
|
+
if (x === null || y === null) return null;
|
|
279
|
+
if (node.op === "+") return finite(x + y);
|
|
280
|
+
if (node.op === "-") return finite(x - y);
|
|
281
|
+
if (node.op === "*") return finite(x * y);
|
|
282
|
+
return y === 0 ? null : finite(x / y);
|
|
283
|
+
}
|
|
284
|
+
return compare(node.op, a, b);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
return run(ast);
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/calculated.ts
|
|
296
|
+
var CALCULATED_TYPE = "calculated";
|
|
297
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
298
|
+
function calculatedProps(field) {
|
|
299
|
+
const p = field.props ?? {};
|
|
300
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
301
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
302
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
303
|
+
out.decimals = p.decimals;
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
var isCalculated = (field) => field?.type === CALCULATED_TYPE;
|
|
308
|
+
var round = (n, decimals) => {
|
|
309
|
+
if (decimals === void 0) return n;
|
|
310
|
+
const r = Number(n.toFixed(decimals));
|
|
311
|
+
return Object.is(r, -0) ? 0 : r;
|
|
312
|
+
};
|
|
313
|
+
function calculatedValue(result, field) {
|
|
314
|
+
const { format, decimals } = calculatedProps(field);
|
|
315
|
+
if (result === null || Array.isArray(result)) return void 0;
|
|
316
|
+
if (format === "number" || format === "percent") {
|
|
317
|
+
const n = formulaNumber(result);
|
|
318
|
+
return n === null ? void 0 : round(n, decimals);
|
|
319
|
+
}
|
|
320
|
+
if (format === "text") {
|
|
321
|
+
if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
|
|
322
|
+
return typeof result === "boolean" ? result ? "Yes" : "No" : result;
|
|
323
|
+
}
|
|
324
|
+
return typeof result === "number" ? round(result, decimals) : result;
|
|
325
|
+
}
|
|
326
|
+
function formatCalculated(value, field) {
|
|
327
|
+
if (value === void 0 || value === null) return "";
|
|
328
|
+
const { format, decimals } = calculatedProps(field);
|
|
329
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
330
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
331
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
332
|
+
return typeof value === "string" ? value : "";
|
|
333
|
+
}
|
|
334
|
+
var MAX_FORMULA_ITEMS = 1e4;
|
|
335
|
+
function formulaInput(value, column) {
|
|
336
|
+
if (value === void 0 || value === null) return null;
|
|
337
|
+
if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
|
|
338
|
+
if (column !== void 0) {
|
|
339
|
+
if (!Array.isArray(value)) return null;
|
|
340
|
+
return value.flatMap((row) => {
|
|
341
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
342
|
+
const cell = row[column];
|
|
343
|
+
return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (Array.isArray(value)) {
|
|
347
|
+
return value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
|
|
348
|
+
}
|
|
349
|
+
return typeof value === "object" ? null : value;
|
|
350
|
+
}
|
|
351
|
+
var UNREAD = /* @__PURE__ */ Symbol("unread");
|
|
352
|
+
function calculator(definition) {
|
|
353
|
+
const compiled = [];
|
|
354
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
355
|
+
for (const block of definition.blocks) {
|
|
356
|
+
if (block.kind !== "field") continue;
|
|
357
|
+
idByKey.set(block.key, block.id);
|
|
358
|
+
}
|
|
359
|
+
for (const block of definition.blocks) {
|
|
360
|
+
if (block.kind !== "field" || !isCalculated(block)) continue;
|
|
361
|
+
const parsed = parseFormula(calculatedProps(block).formula);
|
|
362
|
+
const reads = parsed.ok ? parsed.refs.map((r) => idByKey.get(r.key)) : [];
|
|
363
|
+
compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, last: null });
|
|
364
|
+
}
|
|
365
|
+
const withCalculated = (values, path) => {
|
|
366
|
+
if (!compiled.length) return values;
|
|
367
|
+
const visible = new Set(path);
|
|
368
|
+
let next = values;
|
|
369
|
+
const input = (id) => id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id) ? UNREAD : next[id];
|
|
370
|
+
for (const entry of compiled) {
|
|
371
|
+
const { field, ast } = entry;
|
|
372
|
+
const inputs = entry.reads.map(input);
|
|
373
|
+
let value;
|
|
374
|
+
if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
|
|
375
|
+
value = entry.last.value;
|
|
376
|
+
} else {
|
|
377
|
+
const read = (ref) => {
|
|
378
|
+
const raw = input(idByKey.get(ref.key));
|
|
379
|
+
return raw === UNREAD ? null : formulaInput(raw, ref.column);
|
|
380
|
+
};
|
|
381
|
+
value = ast ? calculatedValue(evaluateFormula(ast, read), field) : void 0;
|
|
382
|
+
entry.last = { inputs, value };
|
|
383
|
+
}
|
|
384
|
+
const current = Object.prototype.hasOwnProperty.call(next, field.id) ? next[field.id] : void 0;
|
|
385
|
+
if (current !== value) next = { ...next, [field.id]: value };
|
|
386
|
+
}
|
|
387
|
+
return next;
|
|
388
|
+
};
|
|
389
|
+
return { active: compiled.length > 0, withCalculated };
|
|
390
|
+
}
|
|
391
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
392
|
+
var calculatedFieldType = {
|
|
393
|
+
type: CALCULATED_TYPE,
|
|
394
|
+
label: "Calculated",
|
|
395
|
+
category: "advanced",
|
|
396
|
+
value: { json: true },
|
|
397
|
+
operators: numberOps,
|
|
398
|
+
a11y: { semantics: "control" },
|
|
399
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
400
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
401
|
+
summarize: (value, field) => formatCalculated(value, field)
|
|
402
|
+
};
|
|
403
|
+
|
|
1
404
|
// src/structured.ts
|
|
2
405
|
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3
406
|
function intProp(field, name, fallback, floor = 1) {
|
|
@@ -231,7 +634,7 @@ function intRange(field, min, maxDefault) {
|
|
|
231
634
|
const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
|
|
232
635
|
return { min, max };
|
|
233
636
|
}
|
|
234
|
-
var
|
|
637
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
235
638
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
236
639
|
function maxLength(field, fallback) {
|
|
237
640
|
return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
|
|
@@ -309,23 +712,30 @@ var builtins = [
|
|
|
309
712
|
label: "Rating",
|
|
310
713
|
category: "scale",
|
|
311
714
|
value: { json: true },
|
|
312
|
-
operators:
|
|
715
|
+
operators: numberOps2,
|
|
313
716
|
a11y: { semantics: "group" },
|
|
314
717
|
validate: (value, field) => {
|
|
315
718
|
const { min, max } = intRange(field, 1, 5);
|
|
316
719
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
317
720
|
},
|
|
318
|
-
summarize: (value, field) =>
|
|
721
|
+
summarize: (value, field) => {
|
|
722
|
+
const { max } = intRange(field, 1, 5);
|
|
723
|
+
const end = scaleEndLabel(field, value, 1, max);
|
|
724
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
725
|
+
}
|
|
319
726
|
},
|
|
320
727
|
{
|
|
321
728
|
type: "nps",
|
|
322
729
|
label: "Net Promoter Score",
|
|
323
730
|
category: "scale",
|
|
324
731
|
value: { json: true },
|
|
325
|
-
operators:
|
|
732
|
+
operators: numberOps2,
|
|
326
733
|
a11y: { semantics: "group" },
|
|
327
734
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
328
|
-
summarize: (value) =>
|
|
735
|
+
summarize: (value, field) => {
|
|
736
|
+
const end = scaleEndLabel(field, value, 0, 10);
|
|
737
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
738
|
+
}
|
|
329
739
|
},
|
|
330
740
|
{
|
|
331
741
|
type: "single-choice",
|
|
@@ -359,9 +769,122 @@ var builtins = [
|
|
|
359
769
|
a11y: { semantics: "group" },
|
|
360
770
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
361
771
|
summarize: (value) => value ? "Yes" : "No"
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
type: "dropdown",
|
|
775
|
+
label: "Dropdown",
|
|
776
|
+
category: "choice",
|
|
777
|
+
value: { json: true },
|
|
778
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
779
|
+
a11y: { semantics: "control" },
|
|
780
|
+
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
781
|
+
summarize: (value, field) => optionLabel(field, String(value))
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
type: "number",
|
|
785
|
+
label: "Number",
|
|
786
|
+
category: "text",
|
|
787
|
+
value: { json: true },
|
|
788
|
+
operators: numberOps2,
|
|
789
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
790
|
+
validate: (value, field) => {
|
|
791
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
792
|
+
const { min, max, step } = numberLimits(field);
|
|
793
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
794
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
795
|
+
}
|
|
796
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
797
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
798
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
799
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
},
|
|
803
|
+
summarize: (value) => String(value),
|
|
804
|
+
samples: (field) => {
|
|
805
|
+
const { min, max } = numberLimits(field);
|
|
806
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
type: "date",
|
|
811
|
+
label: "Date",
|
|
812
|
+
category: "date",
|
|
813
|
+
value: { json: true },
|
|
814
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
815
|
+
a11y: { semantics: "control" },
|
|
816
|
+
validate: (value, field) => {
|
|
817
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
818
|
+
const date = value.trim();
|
|
819
|
+
const { min, max } = dateLimits(field);
|
|
820
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
821
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
822
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
823
|
+
return null;
|
|
824
|
+
},
|
|
825
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
826
|
+
samples: (field) => {
|
|
827
|
+
const { min, max } = dateLimits(field);
|
|
828
|
+
return [min ?? max ?? "2000-01-01"];
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
type: "consent",
|
|
833
|
+
label: "Consent checkbox",
|
|
834
|
+
category: "choice",
|
|
835
|
+
value: { json: true },
|
|
836
|
+
operators: ["answered", "empty"],
|
|
837
|
+
a11y: { semantics: "group" },
|
|
838
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
839
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
840
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
841
|
+
summarize: () => "Agreed",
|
|
842
|
+
samples: () => [true]
|
|
362
843
|
}
|
|
363
844
|
];
|
|
364
|
-
|
|
845
|
+
function scaleLabels(field) {
|
|
846
|
+
const out = {};
|
|
847
|
+
const low = field.props?.lowLabel;
|
|
848
|
+
const high = field.props?.highLabel;
|
|
849
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
850
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
851
|
+
return out;
|
|
852
|
+
}
|
|
853
|
+
function scaleEndLabel(field, value, min, max) {
|
|
854
|
+
const { low, high } = scaleLabels(field);
|
|
855
|
+
return value === min ? low : value === max ? high : void 0;
|
|
856
|
+
}
|
|
857
|
+
var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
858
|
+
function numberLimits(field) {
|
|
859
|
+
const out = {};
|
|
860
|
+
const min = finite2(field.props?.min);
|
|
861
|
+
const max = finite2(field.props?.max);
|
|
862
|
+
const step = finite2(field.props?.step);
|
|
863
|
+
if (min !== void 0) out.min = min;
|
|
864
|
+
if (max !== void 0) out.max = max;
|
|
865
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
function onStep(value, base, step) {
|
|
869
|
+
const n = (value - base) / step;
|
|
870
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
871
|
+
}
|
|
872
|
+
function isIsoDate(value) {
|
|
873
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
874
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
875
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
876
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
877
|
+
return d <= days[m - 1];
|
|
878
|
+
}
|
|
879
|
+
function dateLimits(field) {
|
|
880
|
+
const out = {};
|
|
881
|
+
const min = field.props?.min;
|
|
882
|
+
const max = field.props?.max;
|
|
883
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
884
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
885
|
+
return out;
|
|
886
|
+
}
|
|
887
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
365
888
|
|
|
366
889
|
// src/events.ts
|
|
367
890
|
function isPlainObject(value) {
|
|
@@ -606,7 +1129,7 @@ function runCustom(validator, value) {
|
|
|
606
1129
|
}
|
|
607
1130
|
function validateField(field, value, options = {}) {
|
|
608
1131
|
if (isEmptyValue(value, field)) {
|
|
609
|
-
return field.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1132
|
+
return field.required && field.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
610
1133
|
}
|
|
611
1134
|
const manifest = getFieldType(field.type);
|
|
612
1135
|
if (!manifest) return `Unknown field type "${field.type}".`;
|
|
@@ -637,9 +1160,21 @@ function build(definition, options, replaying = false) {
|
|
|
637
1160
|
if (!declared.has(name)) initialHidden[name] = value;
|
|
638
1161
|
}
|
|
639
1162
|
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
640
|
-
const
|
|
1163
|
+
const calc = calculator(definition);
|
|
1164
|
+
const settle = (values, hidden) => {
|
|
1165
|
+
let { path } = computePath(definition, scopeOf(values, hidden));
|
|
1166
|
+
if (!calc.active) return { values, path };
|
|
1167
|
+
for (let round2 = 0; round2 < 4; round2++) {
|
|
1168
|
+
const next = calc.withCalculated(values, path);
|
|
1169
|
+
if (next === values) break;
|
|
1170
|
+
values = next;
|
|
1171
|
+
path = computePath(definition, scopeOf(values, hidden)).path;
|
|
1172
|
+
}
|
|
1173
|
+
return { values, path };
|
|
1174
|
+
};
|
|
1175
|
+
const first = settle({}, initialHidden);
|
|
641
1176
|
let state = {
|
|
642
|
-
values:
|
|
1177
|
+
values: first.values,
|
|
643
1178
|
hidden: initialHidden,
|
|
644
1179
|
path: first.path,
|
|
645
1180
|
current: first.path[0] ?? null,
|
|
@@ -674,12 +1209,25 @@ function build(definition, options, replaying = false) {
|
|
|
674
1209
|
}
|
|
675
1210
|
return errors;
|
|
676
1211
|
};
|
|
1212
|
+
let unsettled = false;
|
|
1213
|
+
const settleNow = () => {
|
|
1214
|
+
if (!unsettled) return;
|
|
1215
|
+
unsettled = false;
|
|
1216
|
+
const { values, path } = settle(state.values, state.hidden);
|
|
1217
|
+
state = { ...state, values, path };
|
|
1218
|
+
};
|
|
677
1219
|
const answer = (id, value, event) => {
|
|
678
|
-
if (state.status === "completed" || own(state.values, id) === value) return;
|
|
679
|
-
const values = { ...state.values, [id]: value };
|
|
680
|
-
const { path } = computePath(definition, scopeOf(values, state.hidden));
|
|
1220
|
+
if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
|
|
681
1221
|
const errors = { ...state.errors };
|
|
682
1222
|
delete errors[id];
|
|
1223
|
+
if (batch) {
|
|
1224
|
+
state.values = { ...state.values, [id]: value };
|
|
1225
|
+
state.errors = errors;
|
|
1226
|
+
log(event);
|
|
1227
|
+
unsettled = true;
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
683
1231
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
684
1232
|
};
|
|
685
1233
|
const engine = {
|
|
@@ -751,10 +1299,14 @@ function build(definition, options, replaying = false) {
|
|
|
751
1299
|
}
|
|
752
1300
|
};
|
|
753
1301
|
if (options.initialValues) {
|
|
1302
|
+
const wasBatch = batch;
|
|
1303
|
+
batch = true;
|
|
754
1304
|
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
755
1305
|
if (!fields.has(id) || value === void 0) continue;
|
|
756
1306
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
757
1307
|
}
|
|
1308
|
+
settleNow();
|
|
1309
|
+
batch = wasBatch;
|
|
758
1310
|
}
|
|
759
1311
|
const apply = (event) => {
|
|
760
1312
|
if (event.type === "answer") {
|
|
@@ -762,11 +1314,19 @@ function build(definition, options, replaying = false) {
|
|
|
762
1314
|
const { at: _at, ...logged } = event;
|
|
763
1315
|
answer(event.field, event.value, logged);
|
|
764
1316
|
}
|
|
765
|
-
} else if (event.type === "next")
|
|
766
|
-
|
|
767
|
-
|
|
1317
|
+
} else if (event.type === "next") {
|
|
1318
|
+
settleNow();
|
|
1319
|
+
engine.next();
|
|
1320
|
+
} else if (event.type === "back") {
|
|
1321
|
+
settleNow();
|
|
1322
|
+
engine.back();
|
|
1323
|
+
} else if (event.type === "submit") {
|
|
1324
|
+
settleNow();
|
|
1325
|
+
engine.submit();
|
|
1326
|
+
}
|
|
768
1327
|
};
|
|
769
1328
|
const finish = () => {
|
|
1329
|
+
settleNow();
|
|
770
1330
|
batch = false;
|
|
771
1331
|
state = { ...state, events: [...state.events] };
|
|
772
1332
|
};
|
|
@@ -802,8 +1362,15 @@ var LITERAL_TYPE = {
|
|
|
802
1362
|
"single-choice": "string",
|
|
803
1363
|
"multi-choice": "string",
|
|
804
1364
|
"yes-no": "boolean",
|
|
805
|
-
list: "string"
|
|
1365
|
+
list: "string",
|
|
1366
|
+
dropdown: "string",
|
|
1367
|
+
number: "number",
|
|
1368
|
+
date: "string",
|
|
1369
|
+
consent: "boolean",
|
|
1370
|
+
// A calculated field holds a number unless its format says text; see `literalType`.
|
|
1371
|
+
calculated: "number"
|
|
806
1372
|
};
|
|
1373
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
807
1374
|
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
808
1375
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
809
1376
|
"eq",
|
|
@@ -883,7 +1450,44 @@ function parseForm(input) {
|
|
|
883
1450
|
} else if (type === "text-highlights") {
|
|
884
1451
|
optionalText(props.text, `${path}.text`);
|
|
885
1452
|
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1453
|
+
} else if (type === CALCULATED_TYPE) {
|
|
1454
|
+
if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
|
|
1455
|
+
issue(`${path}.format`, "Must be number, percent or text.");
|
|
1456
|
+
}
|
|
1457
|
+
if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
1458
|
+
issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1463
|
+
const fieldProps = (type, props, path) => {
|
|
1464
|
+
if (type === "rating" || type === "nps") {
|
|
1465
|
+
optionalText(props.lowLabel, `${path}.lowLabel`);
|
|
1466
|
+
optionalText(props.highLabel, `${path}.highLabel`);
|
|
1467
|
+
}
|
|
1468
|
+
if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
|
|
1469
|
+
issue(`${path}.display`, "Must be numbers or stars.");
|
|
1470
|
+
}
|
|
1471
|
+
if (type === "number") {
|
|
1472
|
+
for (const k of ["min", "max", "step"]) {
|
|
1473
|
+
if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
|
|
1474
|
+
}
|
|
1475
|
+
if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
|
|
1476
|
+
if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
|
|
1477
|
+
issue(`${path}.min`, "Can't be more than the largest number.");
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (type === "date") {
|
|
1481
|
+
for (const k of ["min", "max"]) {
|
|
1482
|
+
if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
|
|
1483
|
+
issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
|
|
1487
|
+
issue(`${path}.min`, "Can't be after the latest date.");
|
|
1488
|
+
}
|
|
886
1489
|
}
|
|
1490
|
+
if (type === "consent") optionalText(props.text, `${path}.text`);
|
|
887
1491
|
};
|
|
888
1492
|
if (!isObject2(input)) {
|
|
889
1493
|
issue("", "A form definition must be an object.");
|
|
@@ -918,7 +1522,9 @@ function parseForm(input) {
|
|
|
918
1522
|
const keys = /* @__PURE__ */ new Map();
|
|
919
1523
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
920
1524
|
const order = /* @__PURE__ */ new Map();
|
|
1525
|
+
const tableColumnKeys = /* @__PURE__ */ new Map();
|
|
921
1526
|
const choices = /* @__PURE__ */ new Map();
|
|
1527
|
+
const calculatedLiteral = /* @__PURE__ */ new Map();
|
|
922
1528
|
input.blocks.forEach((block, i) => {
|
|
923
1529
|
const path = `blocks[${i}]`;
|
|
924
1530
|
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
@@ -965,13 +1571,27 @@ function parseForm(input) {
|
|
|
965
1571
|
if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
|
|
966
1572
|
issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
|
|
967
1573
|
}
|
|
968
|
-
|
|
1574
|
+
}
|
|
1575
|
+
if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
|
|
1576
|
+
structuredProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1577
|
+
fieldProps(block.type, block.props ?? {}, `${path}.props`);
|
|
969
1578
|
}
|
|
970
1579
|
if (validKey) {
|
|
971
1580
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
972
1581
|
keys.set(block.key, block.id);
|
|
973
1582
|
}
|
|
974
1583
|
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
1584
|
+
if (block.type === CALCULATED_TYPE) {
|
|
1585
|
+
const format = isObject2(block.props) ? block.props.format : void 0;
|
|
1586
|
+
if (format === "text") calculatedLiteral.set(block.id, "string");
|
|
1587
|
+
else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
|
|
1588
|
+
}
|
|
1589
|
+
if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
|
|
1590
|
+
tableColumnKeys.set(
|
|
1591
|
+
block.id,
|
|
1592
|
+
new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
|
|
1593
|
+
);
|
|
1594
|
+
}
|
|
975
1595
|
} else if (block.kind === "content") {
|
|
976
1596
|
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
977
1597
|
text(block.text, `${path}.text`);
|
|
@@ -981,6 +1601,38 @@ function parseForm(input) {
|
|
|
981
1601
|
issue(`${path}.kind`, "Unknown block kind.");
|
|
982
1602
|
}
|
|
983
1603
|
});
|
|
1604
|
+
input.blocks.forEach((block, i) => {
|
|
1605
|
+
if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
|
|
1606
|
+
const path = `blocks[${i}].props.formula`;
|
|
1607
|
+
if (block.required === true) {
|
|
1608
|
+
warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
|
|
1609
|
+
}
|
|
1610
|
+
const formula = isObject2(block.props) ? block.props.formula : void 0;
|
|
1611
|
+
if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
|
|
1612
|
+
const parsed = parseFormula(formula);
|
|
1613
|
+
if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
|
|
1614
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1615
|
+
for (const ref of parsed.refs) {
|
|
1616
|
+
const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
|
|
1617
|
+
if (reported.has(name2)) continue;
|
|
1618
|
+
reported.add(name2);
|
|
1619
|
+
const target = keys.get(ref.key);
|
|
1620
|
+
const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
|
|
1621
|
+
const at = target === void 0 ? void 0 : order.get(target);
|
|
1622
|
+
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.`);
|
|
1623
|
+
else if (target === block.id) issue(path, "The formula uses this field's own value.");
|
|
1624
|
+
else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
|
|
1625
|
+
issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
|
|
1626
|
+
} else if (targetType === "table") {
|
|
1627
|
+
const columns = tableColumnKeys.get(target);
|
|
1628
|
+
if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
|
|
1629
|
+
else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
|
|
1630
|
+
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1631
|
+
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1632
|
+
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
984
1636
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
985
1637
|
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
986
1638
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
@@ -1064,8 +1716,8 @@ function parseForm(input) {
|
|
|
1064
1716
|
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1065
1717
|
const fieldType = fieldTypes.get(left.field);
|
|
1066
1718
|
what = `"${keyOf(left.field)}"`;
|
|
1067
|
-
type = LITERAL_TYPE[fieldType];
|
|
1068
|
-
if (fieldType
|
|
1719
|
+
type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
|
|
1720
|
+
if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
|
|
1069
1721
|
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1070
1722
|
what = `hidden input "${left.hidden}"`;
|
|
1071
1723
|
type = hiddenTypes.get(left.hidden);
|
|
@@ -1220,6 +1872,7 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
1220
1872
|
const fields = fieldMap(definition);
|
|
1221
1873
|
const accept = (id) => {
|
|
1222
1874
|
if (!fields.has(id)) return void ignored.add(id);
|
|
1875
|
+
if (isCalculated(fields.get(id))) return false;
|
|
1223
1876
|
if (!withheld(definition, fields.get(id))) return true;
|
|
1224
1877
|
ignored.add(id);
|
|
1225
1878
|
return false;
|