@marktiderman/genesis-forms 0.3.1 → 0.4.1
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 +85 -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 +859 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -4
- package/dist/index.d.ts +205 -4
- package/dist/index.js +838 -23
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +767 -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 +767 -18
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +769 -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 +769 -18
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/server.cjs
CHANGED
|
@@ -1,5 +1,500 @@
|
|
|
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
|
+
if (isIdStart(source[i] ?? "")) throw new FormulaError("Write a number in digits only, like 1000000.", at);
|
|
38
|
+
const v = Number(source.slice(at, i));
|
|
39
|
+
if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
|
|
40
|
+
tokens.push({ t: "num", v, at });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (isIdStart(c)) {
|
|
44
|
+
while (isIdPart(source[i] ?? "")) i++;
|
|
45
|
+
tokens.push({ t: "id", v: source.slice(at, i), at });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (c === '"' || c === "'" || c === "\u201C") {
|
|
49
|
+
const close = c === "\u201C" ? "\u201D" : c;
|
|
50
|
+
const end = source.indexOf(close, i + 1);
|
|
51
|
+
if (end === -1) throw new FormulaError("This text has no closing quote.", at);
|
|
52
|
+
tokens.push({ t: "str", v: source.slice(i + 1, end), at });
|
|
53
|
+
i = end + 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const two = source.slice(i, i + 2);
|
|
57
|
+
if (TWO_CHAR.has(two)) {
|
|
58
|
+
tokens.push({ t: "op", v: two, at });
|
|
59
|
+
i += 2;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ONE_CHAR.has(c)) {
|
|
63
|
+
tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
|
|
64
|
+
i++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
throw new FormulaError(`"${c}" can't be used in a formula.`, at);
|
|
68
|
+
}
|
|
69
|
+
tokens.push({ t: "end", at: source.length });
|
|
70
|
+
return tokens;
|
|
71
|
+
}
|
|
72
|
+
function parseFormula(source) {
|
|
73
|
+
if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
|
|
74
|
+
if (source.length > MAX_FORMULA_LENGTH) {
|
|
75
|
+
return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
|
|
76
|
+
}
|
|
77
|
+
let tokens;
|
|
78
|
+
try {
|
|
79
|
+
tokens = tokenize(source);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
const err = e;
|
|
82
|
+
return { ok: false, message: err.message, at: err.at };
|
|
83
|
+
}
|
|
84
|
+
let pos = 0;
|
|
85
|
+
const refs = [];
|
|
86
|
+
const peek = () => tokens[pos];
|
|
87
|
+
const isOp = (v) => {
|
|
88
|
+
const tok = peek();
|
|
89
|
+
return tok.t === "op" && tok.v === v;
|
|
90
|
+
};
|
|
91
|
+
const expect = (v, message) => {
|
|
92
|
+
if (!isOp(v)) throw new FormulaError(message, peek().at);
|
|
93
|
+
pos++;
|
|
94
|
+
};
|
|
95
|
+
const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
|
|
96
|
+
const guard = (depth) => {
|
|
97
|
+
if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
|
|
98
|
+
};
|
|
99
|
+
const ternary = (depth) => {
|
|
100
|
+
guard(depth);
|
|
101
|
+
const test = comparison(depth);
|
|
102
|
+
if (!isOp("?")) return test;
|
|
103
|
+
pos++;
|
|
104
|
+
const then = ternary(depth + 1);
|
|
105
|
+
expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
|
|
106
|
+
const otherwise = ternary(depth + 1);
|
|
107
|
+
return { kind: "if", test, then, else: otherwise };
|
|
108
|
+
};
|
|
109
|
+
const comparison = (depth) => {
|
|
110
|
+
const left = additive(depth);
|
|
111
|
+
const tok = peek();
|
|
112
|
+
if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
|
|
113
|
+
pos++;
|
|
114
|
+
const right = additive(depth);
|
|
115
|
+
const next = peek();
|
|
116
|
+
if (next.t === "op" && COMPARISONS.has(next.v)) {
|
|
117
|
+
throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
|
|
118
|
+
}
|
|
119
|
+
return { kind: "binary", op: tok.v, left, right };
|
|
120
|
+
};
|
|
121
|
+
const additive = (depth) => {
|
|
122
|
+
let left = multiplicative(depth);
|
|
123
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
|
|
124
|
+
pos++;
|
|
125
|
+
left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
|
|
126
|
+
}
|
|
127
|
+
return left;
|
|
128
|
+
};
|
|
129
|
+
const multiplicative = (depth) => {
|
|
130
|
+
let left = unary(depth);
|
|
131
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
|
|
132
|
+
pos++;
|
|
133
|
+
left = { kind: "binary", op: tok.v, left, right: unary(depth) };
|
|
134
|
+
}
|
|
135
|
+
return left;
|
|
136
|
+
};
|
|
137
|
+
const unary = (depth) => {
|
|
138
|
+
guard(depth);
|
|
139
|
+
if (isOp("-")) {
|
|
140
|
+
pos++;
|
|
141
|
+
return { kind: "unary", op: "-", arg: unary(depth + 1) };
|
|
142
|
+
}
|
|
143
|
+
if (isOp("+")) {
|
|
144
|
+
pos++;
|
|
145
|
+
return unary(depth + 1);
|
|
146
|
+
}
|
|
147
|
+
return primary(depth);
|
|
148
|
+
};
|
|
149
|
+
const primary = (depth) => {
|
|
150
|
+
const tok = peek();
|
|
151
|
+
if (tok.t === "num") {
|
|
152
|
+
pos++;
|
|
153
|
+
return { kind: "number", value: tok.v };
|
|
154
|
+
}
|
|
155
|
+
if (tok.t === "str") {
|
|
156
|
+
pos++;
|
|
157
|
+
return { kind: "text", value: tok.v };
|
|
158
|
+
}
|
|
159
|
+
if (tok.t === "op" && tok.v === "(") {
|
|
160
|
+
pos++;
|
|
161
|
+
const inner = ternary(depth + 1);
|
|
162
|
+
expect(")", "A `(` has no matching `)`.");
|
|
163
|
+
return inner;
|
|
164
|
+
}
|
|
165
|
+
if (tok.t === "id") {
|
|
166
|
+
pos++;
|
|
167
|
+
if (isOp("(")) {
|
|
168
|
+
if (!FORMULA_FUNCTIONS.includes(tok.v)) {
|
|
169
|
+
throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
|
|
170
|
+
}
|
|
171
|
+
pos++;
|
|
172
|
+
const args = [];
|
|
173
|
+
if (!isOp(")")) {
|
|
174
|
+
args.push(ternary(depth + 1));
|
|
175
|
+
while (isOp(",")) {
|
|
176
|
+
pos++;
|
|
177
|
+
args.push(ternary(depth + 1));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
expect(")", `"${tok.v}(" has no matching ")".`);
|
|
181
|
+
if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
|
|
182
|
+
return { kind: "call", fn: tok.v, args };
|
|
183
|
+
}
|
|
184
|
+
if (isOp(".")) {
|
|
185
|
+
pos++;
|
|
186
|
+
const column = peek();
|
|
187
|
+
if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
|
|
188
|
+
pos++;
|
|
189
|
+
refs.push({ key: tok.v, column: column.v });
|
|
190
|
+
return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
|
|
191
|
+
}
|
|
192
|
+
refs.push({ key: tok.v });
|
|
193
|
+
return { kind: "ref", key: tok.v, at: tok.at };
|
|
194
|
+
}
|
|
195
|
+
throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
const ast = ternary(0);
|
|
199
|
+
const rest = peek();
|
|
200
|
+
if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
|
|
201
|
+
return { ok: true, ast, refs };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
|
|
204
|
+
return { ok: false, message: "The formula is nested too deeply.", at: 0 };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
208
|
+
function formulaNumber(v) {
|
|
209
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
210
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
211
|
+
if (typeof v === "string") {
|
|
212
|
+
const t = v.trim().replace(/,/g, "");
|
|
213
|
+
return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
var finite = (n) => Number.isFinite(n) ? n : null;
|
|
218
|
+
var scalar = (v) => Array.isArray(v) ? null : v;
|
|
219
|
+
function truthy(v) {
|
|
220
|
+
if (v === null) return false;
|
|
221
|
+
if (typeof v === "boolean") return v;
|
|
222
|
+
if (typeof v === "number") return v !== 0;
|
|
223
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
224
|
+
return v.length > 0;
|
|
225
|
+
}
|
|
226
|
+
function numbersIn(values, out = []) {
|
|
227
|
+
for (const v of values) {
|
|
228
|
+
if (Array.isArray(v)) numbersIn(v, out);
|
|
229
|
+
else {
|
|
230
|
+
const n = formulaNumber(v);
|
|
231
|
+
if (n !== null) out.push(n);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
var listTotals = /* @__PURE__ */ new WeakMap();
|
|
237
|
+
var ownLists = /* @__PURE__ */ new WeakSet();
|
|
238
|
+
function ownFormulaList(list) {
|
|
239
|
+
Object.freeze(list);
|
|
240
|
+
ownLists.add(list);
|
|
241
|
+
return list;
|
|
242
|
+
}
|
|
243
|
+
function totalsOf(list) {
|
|
244
|
+
const own2 = ownLists.has(list);
|
|
245
|
+
let t = own2 ? listTotals.get(list) : void 0;
|
|
246
|
+
if (t) return t;
|
|
247
|
+
const nums = numbersIn(list);
|
|
248
|
+
t = { sum: 0, count: nums.length, min: Infinity, max: -Infinity };
|
|
249
|
+
for (const n of nums) {
|
|
250
|
+
t.sum += n;
|
|
251
|
+
if (n < t.min) t.min = n;
|
|
252
|
+
if (n > t.max) t.max = n;
|
|
253
|
+
}
|
|
254
|
+
if (own2) listTotals.set(list, t);
|
|
255
|
+
return t;
|
|
256
|
+
}
|
|
257
|
+
function compare(op, a, b) {
|
|
258
|
+
const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
|
|
259
|
+
if (blank2(a) || blank2(b)) return op === "!=";
|
|
260
|
+
const x = formulaNumber(a);
|
|
261
|
+
const y = formulaNumber(b);
|
|
262
|
+
if (x !== null && y !== null) {
|
|
263
|
+
if (op === "==") return x === y;
|
|
264
|
+
if (op === "!=") return x !== y;
|
|
265
|
+
if (op === "<") return x < y;
|
|
266
|
+
if (op === "<=") return x <= y;
|
|
267
|
+
if (op === ">") return x > y;
|
|
268
|
+
return x >= y;
|
|
269
|
+
}
|
|
270
|
+
if (op === "==") return String(a) === String(b);
|
|
271
|
+
if (op === "!=") return String(a) !== String(b);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
function evaluateFormula(ast, read) {
|
|
275
|
+
const run = (node) => {
|
|
276
|
+
switch (node.kind) {
|
|
277
|
+
case "number":
|
|
278
|
+
case "text":
|
|
279
|
+
return node.value;
|
|
280
|
+
case "ref":
|
|
281
|
+
return read(node);
|
|
282
|
+
case "unary": {
|
|
283
|
+
const n = formulaNumber(scalar(run(node.arg)));
|
|
284
|
+
return n === null ? null : -n;
|
|
285
|
+
}
|
|
286
|
+
case "if":
|
|
287
|
+
return truthy(run(node.test)) ? run(node.then) : run(node.else);
|
|
288
|
+
case "call": {
|
|
289
|
+
let total = 0;
|
|
290
|
+
let count = 0;
|
|
291
|
+
let min = Infinity;
|
|
292
|
+
let max = -Infinity;
|
|
293
|
+
for (const arg of node.args) {
|
|
294
|
+
const v = run(arg);
|
|
295
|
+
if (Array.isArray(v)) {
|
|
296
|
+
const t = totalsOf(v);
|
|
297
|
+
total += t.sum;
|
|
298
|
+
count += t.count;
|
|
299
|
+
if (t.min < min) min = t.min;
|
|
300
|
+
if (t.max > max) max = t.max;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const n = formulaNumber(v);
|
|
304
|
+
if (n === null) continue;
|
|
305
|
+
total += n;
|
|
306
|
+
count++;
|
|
307
|
+
if (n < min) min = n;
|
|
308
|
+
if (n > max) max = n;
|
|
309
|
+
}
|
|
310
|
+
if (node.fn === "sum") return finite(total);
|
|
311
|
+
if (!count) return null;
|
|
312
|
+
if (node.fn === "avg") return finite(total / count);
|
|
313
|
+
return node.fn === "min" ? min : max;
|
|
314
|
+
}
|
|
315
|
+
case "binary": {
|
|
316
|
+
const a = run(node.left);
|
|
317
|
+
const b = run(node.right);
|
|
318
|
+
if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
|
|
319
|
+
const x = formulaNumber(scalar(a));
|
|
320
|
+
const y = formulaNumber(scalar(b));
|
|
321
|
+
if (x === null || y === null) return null;
|
|
322
|
+
if (node.op === "+") return finite(x + y);
|
|
323
|
+
if (node.op === "-") return finite(x - y);
|
|
324
|
+
if (node.op === "*") return finite(x * y);
|
|
325
|
+
return y === 0 ? null : finite(x / y);
|
|
326
|
+
}
|
|
327
|
+
return compare(node.op, a, b);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
return run(ast);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/calculated.ts
|
|
339
|
+
var CALCULATED_TYPE = "calculated";
|
|
340
|
+
var MAX_FORM_FORMULA_LENGTH = 2e4;
|
|
341
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
342
|
+
function calculatedProps(field) {
|
|
343
|
+
const p = field.props ?? {};
|
|
344
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
345
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
346
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
347
|
+
out.decimals = p.decimals;
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
var isCalculated = (field) => field?.type === CALCULATED_TYPE;
|
|
352
|
+
var round = (n, decimals) => {
|
|
353
|
+
if (decimals === void 0) return n;
|
|
354
|
+
const r = Number(n.toFixed(decimals));
|
|
355
|
+
return Object.is(r, -0) ? 0 : r;
|
|
356
|
+
};
|
|
357
|
+
function calculatedValue(result, field) {
|
|
358
|
+
const { format, decimals } = calculatedProps(field);
|
|
359
|
+
if (result === null || Array.isArray(result)) return void 0;
|
|
360
|
+
if (format === "number" || format === "percent") {
|
|
361
|
+
const n = formulaNumber(result);
|
|
362
|
+
return n === null ? void 0 : round(n, decimals);
|
|
363
|
+
}
|
|
364
|
+
if (format === "text") {
|
|
365
|
+
if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
|
|
366
|
+
return typeof result === "boolean" ? result ? "Yes" : "No" : result;
|
|
367
|
+
}
|
|
368
|
+
return typeof result === "number" ? round(result, decimals) : result;
|
|
369
|
+
}
|
|
370
|
+
function formatCalculated(value, field) {
|
|
371
|
+
if (value === void 0 || value === null) return "";
|
|
372
|
+
const { format, decimals } = calculatedProps(field);
|
|
373
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
374
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
375
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
376
|
+
return typeof value === "string" ? value : "";
|
|
377
|
+
}
|
|
378
|
+
var MAX_FORMULA_ITEMS = 1e4;
|
|
379
|
+
var MAX_SETTLE_ITEMS = 5e4;
|
|
380
|
+
var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
|
|
381
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
382
|
+
const cell = row[column];
|
|
383
|
+
return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
|
|
384
|
+
}) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
|
|
385
|
+
var extracted = /* @__PURE__ */ new WeakMap();
|
|
386
|
+
function cachedList(value, column) {
|
|
387
|
+
let byColumn = extracted.get(value);
|
|
388
|
+
if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
|
|
389
|
+
const key = column ?? "";
|
|
390
|
+
let list = byColumn.get(key);
|
|
391
|
+
if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
|
|
392
|
+
return list;
|
|
393
|
+
}
|
|
394
|
+
function readAnswer(value, column) {
|
|
395
|
+
if (Array.isArray(value)) return cachedList(value, column);
|
|
396
|
+
return column !== void 0 ? null : formulaInput(value);
|
|
397
|
+
}
|
|
398
|
+
function formulaInput(value, column) {
|
|
399
|
+
if (value === void 0 || value === null) return null;
|
|
400
|
+
if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
|
|
401
|
+
if (Array.isArray(value)) return extract(value, void 0);
|
|
402
|
+
return typeof value === "object" ? null : value;
|
|
403
|
+
}
|
|
404
|
+
function refNodes(node, out = []) {
|
|
405
|
+
if (node.kind === "ref") out.push(node);
|
|
406
|
+
else if (node.kind === "unary") refNodes(node.arg, out);
|
|
407
|
+
else if (node.kind === "binary") refNodes(node.left, out), refNodes(node.right, out);
|
|
408
|
+
else if (node.kind === "if") refNodes(node.test, out), refNodes(node.then, out), refNodes(node.else, out);
|
|
409
|
+
else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
var UNREAD = /* @__PURE__ */ Symbol("unread");
|
|
413
|
+
var OVER = /* @__PURE__ */ Symbol("over");
|
|
414
|
+
function calculator(definition) {
|
|
415
|
+
const compiled = [];
|
|
416
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
417
|
+
for (const block of definition.blocks) {
|
|
418
|
+
if (block.kind !== "field") continue;
|
|
419
|
+
idByKey.set(block.key, block.id);
|
|
420
|
+
}
|
|
421
|
+
for (const block of definition.blocks) {
|
|
422
|
+
if (block.kind !== "field" || !isCalculated(block)) continue;
|
|
423
|
+
const parsed = parseFormula(calculatedProps(block).formula);
|
|
424
|
+
const reads = [];
|
|
425
|
+
const slots = /* @__PURE__ */ new Map();
|
|
426
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
427
|
+
for (const ref of parsed.ok ? refNodes(parsed.ast) : []) {
|
|
428
|
+
const id = idByKey.get(ref.key);
|
|
429
|
+
const key = `${id ?? ""}\0${ref.column ?? ""}`;
|
|
430
|
+
let slot = byKey.get(key);
|
|
431
|
+
if (slot === void 0) {
|
|
432
|
+
slot = reads.push({ id, column: ref.column, key }) - 1;
|
|
433
|
+
byKey.set(key, slot);
|
|
434
|
+
}
|
|
435
|
+
slots.set(ref, slot);
|
|
436
|
+
}
|
|
437
|
+
compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
|
|
438
|
+
}
|
|
439
|
+
const withCalculated = (values, path) => {
|
|
440
|
+
if (!compiled.length) return values;
|
|
441
|
+
const visible = new Set(path);
|
|
442
|
+
let next = values;
|
|
443
|
+
let budget = MAX_SETTLE_ITEMS;
|
|
444
|
+
const within = /* @__PURE__ */ new Map();
|
|
445
|
+
const input = ({ id, key }) => {
|
|
446
|
+
if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
|
|
447
|
+
const value = next[id];
|
|
448
|
+
if (!Array.isArray(value)) return value;
|
|
449
|
+
let ok = within.get(key);
|
|
450
|
+
if (ok === void 0) {
|
|
451
|
+
ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
|
|
452
|
+
if (ok) budget -= value.length;
|
|
453
|
+
within.set(key, ok);
|
|
454
|
+
}
|
|
455
|
+
return ok ? value : OVER;
|
|
456
|
+
};
|
|
457
|
+
for (const entry of compiled) {
|
|
458
|
+
const { field, ast } = entry;
|
|
459
|
+
const inputs = entry.reads.map(input);
|
|
460
|
+
let value;
|
|
461
|
+
if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
|
|
462
|
+
value = entry.last.value;
|
|
463
|
+
} else {
|
|
464
|
+
const resolved = new Array(inputs.length);
|
|
465
|
+
const read = (ref) => {
|
|
466
|
+
const slot = entry.slots.get(ref);
|
|
467
|
+
if (slot === void 0) return null;
|
|
468
|
+
let v = resolved[slot];
|
|
469
|
+
if (v === void 0) {
|
|
470
|
+
const raw = inputs[slot];
|
|
471
|
+
v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
|
|
472
|
+
}
|
|
473
|
+
return v;
|
|
474
|
+
};
|
|
475
|
+
value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field) : void 0;
|
|
476
|
+
entry.last = { inputs, value };
|
|
477
|
+
}
|
|
478
|
+
const current = Object.prototype.hasOwnProperty.call(next, field.id) ? next[field.id] : void 0;
|
|
479
|
+
if (current !== value) next = { ...next, [field.id]: value };
|
|
480
|
+
}
|
|
481
|
+
return next;
|
|
482
|
+
};
|
|
483
|
+
return { active: compiled.length > 0, withCalculated };
|
|
484
|
+
}
|
|
485
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
486
|
+
var calculatedFieldType = {
|
|
487
|
+
type: CALCULATED_TYPE,
|
|
488
|
+
label: "Calculated",
|
|
489
|
+
category: "advanced",
|
|
490
|
+
value: { json: true },
|
|
491
|
+
operators: numberOps,
|
|
492
|
+
a11y: { semantics: "control" },
|
|
493
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
494
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
495
|
+
summarize: (value, field) => formatCalculated(value, field)
|
|
496
|
+
};
|
|
497
|
+
|
|
3
498
|
// src/structured.ts
|
|
4
499
|
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5
500
|
function intProp(field, name, fallback, floor = 1) {
|
|
@@ -233,7 +728,7 @@ function intRange(field, min, maxDefault) {
|
|
|
233
728
|
const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
|
|
234
729
|
return { min, max };
|
|
235
730
|
}
|
|
236
|
-
var
|
|
731
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
237
732
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
238
733
|
function maxLength(field, fallback) {
|
|
239
734
|
return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
|
|
@@ -311,23 +806,30 @@ var builtins = [
|
|
|
311
806
|
label: "Rating",
|
|
312
807
|
category: "scale",
|
|
313
808
|
value: { json: true },
|
|
314
|
-
operators:
|
|
809
|
+
operators: numberOps2,
|
|
315
810
|
a11y: { semantics: "group" },
|
|
316
811
|
validate: (value, field) => {
|
|
317
812
|
const { min, max } = intRange(field, 1, 5);
|
|
318
813
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
319
814
|
},
|
|
320
|
-
summarize: (value, field) =>
|
|
815
|
+
summarize: (value, field) => {
|
|
816
|
+
const { max } = intRange(field, 1, 5);
|
|
817
|
+
const end = scaleEndLabel(field, value, 1, max);
|
|
818
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
819
|
+
}
|
|
321
820
|
},
|
|
322
821
|
{
|
|
323
822
|
type: "nps",
|
|
324
823
|
label: "Net Promoter Score",
|
|
325
824
|
category: "scale",
|
|
326
825
|
value: { json: true },
|
|
327
|
-
operators:
|
|
826
|
+
operators: numberOps2,
|
|
328
827
|
a11y: { semantics: "group" },
|
|
329
828
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
330
|
-
summarize: (value) =>
|
|
829
|
+
summarize: (value, field) => {
|
|
830
|
+
const end = scaleEndLabel(field, value, 0, 10);
|
|
831
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
832
|
+
}
|
|
331
833
|
},
|
|
332
834
|
{
|
|
333
835
|
type: "single-choice",
|
|
@@ -361,9 +863,122 @@ var builtins = [
|
|
|
361
863
|
a11y: { semantics: "group" },
|
|
362
864
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
363
865
|
summarize: (value) => value ? "Yes" : "No"
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
type: "dropdown",
|
|
869
|
+
label: "Dropdown",
|
|
870
|
+
category: "choice",
|
|
871
|
+
value: { json: true },
|
|
872
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
873
|
+
a11y: { semantics: "control" },
|
|
874
|
+
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
875
|
+
summarize: (value, field) => optionLabel(field, String(value))
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
type: "number",
|
|
879
|
+
label: "Number",
|
|
880
|
+
category: "text",
|
|
881
|
+
value: { json: true },
|
|
882
|
+
operators: numberOps2,
|
|
883
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
884
|
+
validate: (value, field) => {
|
|
885
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
886
|
+
const { min, max, step } = numberLimits(field);
|
|
887
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
888
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
889
|
+
}
|
|
890
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
891
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
892
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
893
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
},
|
|
897
|
+
summarize: (value) => String(value),
|
|
898
|
+
samples: (field) => {
|
|
899
|
+
const { min, max } = numberLimits(field);
|
|
900
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
type: "date",
|
|
905
|
+
label: "Date",
|
|
906
|
+
category: "date",
|
|
907
|
+
value: { json: true },
|
|
908
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
909
|
+
a11y: { semantics: "control" },
|
|
910
|
+
validate: (value, field) => {
|
|
911
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
912
|
+
const date = value.trim();
|
|
913
|
+
const { min, max } = dateLimits(field);
|
|
914
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
915
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
916
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
917
|
+
return null;
|
|
918
|
+
},
|
|
919
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
920
|
+
samples: (field) => {
|
|
921
|
+
const { min, max } = dateLimits(field);
|
|
922
|
+
return [min ?? max ?? "2000-01-01"];
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
{
|
|
926
|
+
type: "consent",
|
|
927
|
+
label: "Consent checkbox",
|
|
928
|
+
category: "choice",
|
|
929
|
+
value: { json: true },
|
|
930
|
+
operators: ["answered", "empty"],
|
|
931
|
+
a11y: { semantics: "group" },
|
|
932
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
933
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
934
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
935
|
+
summarize: () => "Agreed",
|
|
936
|
+
samples: () => [true]
|
|
364
937
|
}
|
|
365
938
|
];
|
|
366
|
-
|
|
939
|
+
function scaleLabels(field) {
|
|
940
|
+
const out = {};
|
|
941
|
+
const low = field.props?.lowLabel;
|
|
942
|
+
const high = field.props?.highLabel;
|
|
943
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
944
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
945
|
+
return out;
|
|
946
|
+
}
|
|
947
|
+
function scaleEndLabel(field, value, min, max) {
|
|
948
|
+
const { low, high } = scaleLabels(field);
|
|
949
|
+
return value === min ? low : value === max ? high : void 0;
|
|
950
|
+
}
|
|
951
|
+
var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
952
|
+
function numberLimits(field) {
|
|
953
|
+
const out = {};
|
|
954
|
+
const min = finite2(field.props?.min);
|
|
955
|
+
const max = finite2(field.props?.max);
|
|
956
|
+
const step = finite2(field.props?.step);
|
|
957
|
+
if (min !== void 0) out.min = min;
|
|
958
|
+
if (max !== void 0) out.max = max;
|
|
959
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
960
|
+
return out;
|
|
961
|
+
}
|
|
962
|
+
function onStep(value, base, step) {
|
|
963
|
+
const n = (value - base) / step;
|
|
964
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
965
|
+
}
|
|
966
|
+
function isIsoDate(value) {
|
|
967
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
968
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
969
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
970
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
971
|
+
return d <= days[m - 1];
|
|
972
|
+
}
|
|
973
|
+
function dateLimits(field) {
|
|
974
|
+
const out = {};
|
|
975
|
+
const min = field.props?.min;
|
|
976
|
+
const max = field.props?.max;
|
|
977
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
978
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
367
982
|
|
|
368
983
|
// src/events.ts
|
|
369
984
|
function isPlainObject(value) {
|
|
@@ -608,7 +1223,7 @@ function runCustom(validator, value) {
|
|
|
608
1223
|
}
|
|
609
1224
|
function validateField(field, value, options = {}) {
|
|
610
1225
|
if (isEmptyValue(value, field)) {
|
|
611
|
-
return field.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1226
|
+
return field.required && field.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
612
1227
|
}
|
|
613
1228
|
const manifest = getFieldType(field.type);
|
|
614
1229
|
if (!manifest) return `Unknown field type "${field.type}".`;
|
|
@@ -639,9 +1254,21 @@ function build(definition, options, replaying = false) {
|
|
|
639
1254
|
if (!declared.has(name)) initialHidden[name] = value;
|
|
640
1255
|
}
|
|
641
1256
|
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
642
|
-
const
|
|
1257
|
+
const calc = calculator(definition);
|
|
1258
|
+
const settle = (values, hidden) => {
|
|
1259
|
+
let { path } = computePath(definition, scopeOf(values, hidden));
|
|
1260
|
+
if (!calc.active) return { values, path };
|
|
1261
|
+
for (let round2 = 0; round2 < 4; round2++) {
|
|
1262
|
+
const next = calc.withCalculated(values, path);
|
|
1263
|
+
if (next === values) break;
|
|
1264
|
+
values = next;
|
|
1265
|
+
path = computePath(definition, scopeOf(values, hidden)).path;
|
|
1266
|
+
}
|
|
1267
|
+
return { values, path };
|
|
1268
|
+
};
|
|
1269
|
+
const first = settle({}, initialHidden);
|
|
643
1270
|
let state = {
|
|
644
|
-
values:
|
|
1271
|
+
values: first.values,
|
|
645
1272
|
hidden: initialHidden,
|
|
646
1273
|
path: first.path,
|
|
647
1274
|
current: first.path[0] ?? null,
|
|
@@ -676,12 +1303,25 @@ function build(definition, options, replaying = false) {
|
|
|
676
1303
|
}
|
|
677
1304
|
return errors;
|
|
678
1305
|
};
|
|
1306
|
+
let unsettled = false;
|
|
1307
|
+
const settleNow = () => {
|
|
1308
|
+
if (!unsettled) return;
|
|
1309
|
+
unsettled = false;
|
|
1310
|
+
const { values, path } = settle(state.values, state.hidden);
|
|
1311
|
+
state = { ...state, values, path };
|
|
1312
|
+
};
|
|
679
1313
|
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));
|
|
1314
|
+
if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
|
|
683
1315
|
const errors = { ...state.errors };
|
|
684
1316
|
delete errors[id];
|
|
1317
|
+
if (batch) {
|
|
1318
|
+
state.values = { ...state.values, [id]: value };
|
|
1319
|
+
state.errors = errors;
|
|
1320
|
+
log(event);
|
|
1321
|
+
unsettled = true;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
685
1325
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
686
1326
|
};
|
|
687
1327
|
const engine = {
|
|
@@ -753,10 +1393,14 @@ function build(definition, options, replaying = false) {
|
|
|
753
1393
|
}
|
|
754
1394
|
};
|
|
755
1395
|
if (options.initialValues) {
|
|
1396
|
+
const wasBatch = batch;
|
|
1397
|
+
batch = true;
|
|
756
1398
|
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
757
1399
|
if (!fields.has(id) || value === void 0) continue;
|
|
758
1400
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
759
1401
|
}
|
|
1402
|
+
settleNow();
|
|
1403
|
+
batch = wasBatch;
|
|
760
1404
|
}
|
|
761
1405
|
const apply = (event) => {
|
|
762
1406
|
if (event.type === "answer") {
|
|
@@ -764,11 +1408,19 @@ function build(definition, options, replaying = false) {
|
|
|
764
1408
|
const { at: _at, ...logged } = event;
|
|
765
1409
|
answer(event.field, event.value, logged);
|
|
766
1410
|
}
|
|
767
|
-
} else if (event.type === "next")
|
|
768
|
-
|
|
769
|
-
|
|
1411
|
+
} else if (event.type === "next") {
|
|
1412
|
+
settleNow();
|
|
1413
|
+
engine.next();
|
|
1414
|
+
} else if (event.type === "back") {
|
|
1415
|
+
settleNow();
|
|
1416
|
+
engine.back();
|
|
1417
|
+
} else if (event.type === "submit") {
|
|
1418
|
+
settleNow();
|
|
1419
|
+
engine.submit();
|
|
1420
|
+
}
|
|
770
1421
|
};
|
|
771
1422
|
const finish = () => {
|
|
1423
|
+
settleNow();
|
|
772
1424
|
batch = false;
|
|
773
1425
|
state = { ...state, events: [...state.events] };
|
|
774
1426
|
};
|
|
@@ -804,8 +1456,15 @@ var LITERAL_TYPE = {
|
|
|
804
1456
|
"single-choice": "string",
|
|
805
1457
|
"multi-choice": "string",
|
|
806
1458
|
"yes-no": "boolean",
|
|
807
|
-
list: "string"
|
|
1459
|
+
list: "string",
|
|
1460
|
+
dropdown: "string",
|
|
1461
|
+
number: "number",
|
|
1462
|
+
date: "string",
|
|
1463
|
+
consent: "boolean",
|
|
1464
|
+
// A calculated field holds a number unless its format says text; see `literalType`.
|
|
1465
|
+
calculated: "number"
|
|
808
1466
|
};
|
|
1467
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
809
1468
|
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
810
1469
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
811
1470
|
"eq",
|
|
@@ -885,7 +1544,44 @@ function parseForm(input) {
|
|
|
885
1544
|
} else if (type === "text-highlights") {
|
|
886
1545
|
optionalText(props.text, `${path}.text`);
|
|
887
1546
|
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1547
|
+
} else if (type === CALCULATED_TYPE) {
|
|
1548
|
+
if (props.format !== void 0 && props.format !== "number" && props.format !== "percent" && props.format !== "text") {
|
|
1549
|
+
issue(`${path}.format`, "Must be number, percent or text.");
|
|
1550
|
+
}
|
|
1551
|
+
if (props.decimals !== void 0 && !(isInt(props.decimals) && props.decimals >= 0 && props.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
1552
|
+
issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1557
|
+
const fieldProps = (type, props, path) => {
|
|
1558
|
+
if (type === "rating" || type === "nps") {
|
|
1559
|
+
optionalText(props.lowLabel, `${path}.lowLabel`);
|
|
1560
|
+
optionalText(props.highLabel, `${path}.highLabel`);
|
|
1561
|
+
}
|
|
1562
|
+
if (type === "rating" && props.display !== void 0 && props.display !== "numbers" && props.display !== "stars") {
|
|
1563
|
+
issue(`${path}.display`, "Must be numbers or stars.");
|
|
1564
|
+
}
|
|
1565
|
+
if (type === "number") {
|
|
1566
|
+
for (const k of ["min", "max", "step"]) {
|
|
1567
|
+
if (props[k] !== void 0 && !isFiniteNumber(props[k])) issue(`${path}.${k}`, "Must be a number.");
|
|
1568
|
+
}
|
|
1569
|
+
if (isFiniteNumber(props.step) && props.step <= 0) issue(`${path}.step`, "Must be above 0.");
|
|
1570
|
+
if (isFiniteNumber(props.min) && isFiniteNumber(props.max) && props.min > props.max) {
|
|
1571
|
+
issue(`${path}.min`, "Can't be more than the largest number.");
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
if (type === "date") {
|
|
1575
|
+
for (const k of ["min", "max"]) {
|
|
1576
|
+
if (props[k] !== void 0 && !(typeof props[k] === "string" && isIsoDate(props[k]))) {
|
|
1577
|
+
issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (typeof props.min === "string" && typeof props.max === "string" && isIsoDate(props.min) && isIsoDate(props.max) && props.min > props.max) {
|
|
1581
|
+
issue(`${path}.min`, "Can't be after the latest date.");
|
|
1582
|
+
}
|
|
888
1583
|
}
|
|
1584
|
+
if (type === "consent") optionalText(props.text, `${path}.text`);
|
|
889
1585
|
};
|
|
890
1586
|
if (!isObject2(input)) {
|
|
891
1587
|
issue("", "A form definition must be an object.");
|
|
@@ -920,7 +1616,9 @@ function parseForm(input) {
|
|
|
920
1616
|
const keys = /* @__PURE__ */ new Map();
|
|
921
1617
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
922
1618
|
const order = /* @__PURE__ */ new Map();
|
|
1619
|
+
const tableColumnKeys = /* @__PURE__ */ new Map();
|
|
923
1620
|
const choices = /* @__PURE__ */ new Map();
|
|
1621
|
+
const calculatedLiteral = /* @__PURE__ */ new Map();
|
|
924
1622
|
input.blocks.forEach((block, i) => {
|
|
925
1623
|
const path = `blocks[${i}]`;
|
|
926
1624
|
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
@@ -970,12 +1668,24 @@ function parseForm(input) {
|
|
|
970
1668
|
}
|
|
971
1669
|
if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
|
|
972
1670
|
structuredProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1671
|
+
fieldProps(block.type, block.props ?? {}, `${path}.props`);
|
|
973
1672
|
}
|
|
974
1673
|
if (validKey) {
|
|
975
1674
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
976
1675
|
keys.set(block.key, block.id);
|
|
977
1676
|
}
|
|
978
1677
|
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
1678
|
+
if (block.type === CALCULATED_TYPE) {
|
|
1679
|
+
const format = isObject2(block.props) ? block.props.format : void 0;
|
|
1680
|
+
if (format === "text") calculatedLiteral.set(block.id, "string");
|
|
1681
|
+
else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
|
|
1682
|
+
}
|
|
1683
|
+
if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
|
|
1684
|
+
tableColumnKeys.set(
|
|
1685
|
+
block.id,
|
|
1686
|
+
new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
979
1689
|
} else if (block.kind === "content") {
|
|
980
1690
|
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
981
1691
|
text(block.text, `${path}.text`);
|
|
@@ -985,6 +1695,44 @@ function parseForm(input) {
|
|
|
985
1695
|
issue(`${path}.kind`, "Unknown block kind.");
|
|
986
1696
|
}
|
|
987
1697
|
});
|
|
1698
|
+
let formulaChars = 0;
|
|
1699
|
+
input.blocks.forEach((block, i) => {
|
|
1700
|
+
if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
|
|
1701
|
+
const path = `blocks[${i}].props.formula`;
|
|
1702
|
+
const source = isObject2(block.props) && typeof block.props.formula === "string" ? block.props.formula : "";
|
|
1703
|
+
formulaChars += source.length;
|
|
1704
|
+
if (formulaChars > MAX_FORM_FORMULA_LENGTH && formulaChars - source.length <= MAX_FORM_FORMULA_LENGTH) {
|
|
1705
|
+
issue(path, `The form's formulas together are over ${MAX_FORM_FORMULA_LENGTH} characters; shorten some, or work part out once in an earlier calculated field.`);
|
|
1706
|
+
}
|
|
1707
|
+
if (block.required === true) {
|
|
1708
|
+
warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
|
|
1709
|
+
}
|
|
1710
|
+
const formula = isObject2(block.props) ? block.props.formula : void 0;
|
|
1711
|
+
if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
|
|
1712
|
+
const parsed = parseFormula(formula);
|
|
1713
|
+
if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
|
|
1714
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1715
|
+
for (const ref of parsed.refs) {
|
|
1716
|
+
const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
|
|
1717
|
+
if (reported.has(name2)) continue;
|
|
1718
|
+
reported.add(name2);
|
|
1719
|
+
const target = keys.get(ref.key);
|
|
1720
|
+
const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
|
|
1721
|
+
const at = target === void 0 ? void 0 : order.get(target);
|
|
1722
|
+
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.`);
|
|
1723
|
+
else if (target === block.id) issue(path, "The formula uses this field's own value.");
|
|
1724
|
+
else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
|
|
1725
|
+
issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
|
|
1726
|
+
} else if (targetType === "table") {
|
|
1727
|
+
const columns = tableColumnKeys.get(target);
|
|
1728
|
+
if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
|
|
1729
|
+
else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
|
|
1730
|
+
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1731
|
+
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1732
|
+
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
988
1736
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
989
1737
|
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
990
1738
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
@@ -1068,8 +1816,8 @@ function parseForm(input) {
|
|
|
1068
1816
|
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1069
1817
|
const fieldType = fieldTypes.get(left.field);
|
|
1070
1818
|
what = `"${keyOf(left.field)}"`;
|
|
1071
|
-
type = LITERAL_TYPE[fieldType];
|
|
1072
|
-
if (fieldType
|
|
1819
|
+
type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
|
|
1820
|
+
if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
|
|
1073
1821
|
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1074
1822
|
what = `hidden input "${left.hidden}"`;
|
|
1075
1823
|
type = hiddenTypes.get(left.hidden);
|
|
@@ -1224,6 +1972,7 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
1224
1972
|
const fields = fieldMap(definition);
|
|
1225
1973
|
const accept = (id) => {
|
|
1226
1974
|
if (!fields.has(id)) return void ignored.add(id);
|
|
1975
|
+
if (isCalculated(fields.get(id))) return false;
|
|
1227
1976
|
if (!withheld(definition, fields.get(id))) return true;
|
|
1228
1977
|
ignored.add(id);
|
|
1229
1978
|
return false;
|