@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/index.cjs
CHANGED
|
@@ -30,6 +30,537 @@ function fingerprint(value) {
|
|
|
30
30
|
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
// src/formula.ts
|
|
34
|
+
var FORMULA_FUNCTIONS = ["sum", "avg", "min", "max"];
|
|
35
|
+
var MAX_FORMULA_LENGTH = 2e3;
|
|
36
|
+
var MAX_DEPTH = 64;
|
|
37
|
+
var OP_ALIASES = { "\u2212": "-", "\xD7": "*", "\xF7": "/", "\u2260": "!=", "\u2264": "<=", "\u2265": ">=", "=": "==" };
|
|
38
|
+
var TWO_CHAR = /* @__PURE__ */ new Set(["==", "!=", "<=", ">="]);
|
|
39
|
+
var ONE_CHAR = /* @__PURE__ */ new Set(["+", "-", "*", "/", "<", ">", "(", ")", "?", ":", ",", ".", "=", "\u2212", "\xD7", "\xF7", "\u2260", "\u2264", "\u2265"]);
|
|
40
|
+
var COMPARISONS = /* @__PURE__ */ new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
41
|
+
var FormulaError = class extends Error {
|
|
42
|
+
constructor(message, at) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.at = at;
|
|
45
|
+
}
|
|
46
|
+
at;
|
|
47
|
+
};
|
|
48
|
+
var isIdStart = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c === "_";
|
|
49
|
+
var isIdPart = (c) => isIdStart(c) || c >= "0" && c <= "9";
|
|
50
|
+
var isDigit = (c) => c >= "0" && c <= "9";
|
|
51
|
+
function tokenize(source) {
|
|
52
|
+
const tokens = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < source.length) {
|
|
55
|
+
const c = source[i];
|
|
56
|
+
if (c === " " || c === " " || c === "\n" || c === "\r") {
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const at = i;
|
|
61
|
+
if (isDigit(c) || c === "." && isDigit(source[i + 1] ?? "")) {
|
|
62
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
63
|
+
if (source[i] === "." && isDigit(source[i + 1] ?? "")) {
|
|
64
|
+
i++;
|
|
65
|
+
while (isDigit(source[i] ?? "")) i++;
|
|
66
|
+
}
|
|
67
|
+
if (isIdStart(source[i] ?? "")) throw new FormulaError("Write a number in digits only, like 1000000.", at);
|
|
68
|
+
const v = Number(source.slice(at, i));
|
|
69
|
+
if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
|
|
70
|
+
tokens.push({ t: "num", v, at });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (isIdStart(c)) {
|
|
74
|
+
while (isIdPart(source[i] ?? "")) i++;
|
|
75
|
+
tokens.push({ t: "id", v: source.slice(at, i), at });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (c === '"' || c === "'" || c === "\u201C") {
|
|
79
|
+
const close = c === "\u201C" ? "\u201D" : c;
|
|
80
|
+
const end = source.indexOf(close, i + 1);
|
|
81
|
+
if (end === -1) throw new FormulaError("This text has no closing quote.", at);
|
|
82
|
+
tokens.push({ t: "str", v: source.slice(i + 1, end), at });
|
|
83
|
+
i = end + 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const two = source.slice(i, i + 2);
|
|
87
|
+
if (TWO_CHAR.has(two)) {
|
|
88
|
+
tokens.push({ t: "op", v: two, at });
|
|
89
|
+
i += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (ONE_CHAR.has(c)) {
|
|
93
|
+
tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
throw new FormulaError(`"${c}" can't be used in a formula.`, at);
|
|
98
|
+
}
|
|
99
|
+
tokens.push({ t: "end", at: source.length });
|
|
100
|
+
return tokens;
|
|
101
|
+
}
|
|
102
|
+
function parseFormula(source) {
|
|
103
|
+
if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
|
|
104
|
+
if (source.length > MAX_FORMULA_LENGTH) {
|
|
105
|
+
return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
|
|
106
|
+
}
|
|
107
|
+
let tokens;
|
|
108
|
+
try {
|
|
109
|
+
tokens = tokenize(source);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
const err = e;
|
|
112
|
+
return { ok: false, message: err.message, at: err.at };
|
|
113
|
+
}
|
|
114
|
+
let pos = 0;
|
|
115
|
+
const refs = [];
|
|
116
|
+
const peek = () => tokens[pos];
|
|
117
|
+
const isOp = (v) => {
|
|
118
|
+
const tok = peek();
|
|
119
|
+
return tok.t === "op" && tok.v === v;
|
|
120
|
+
};
|
|
121
|
+
const expect = (v, message) => {
|
|
122
|
+
if (!isOp(v)) throw new FormulaError(message, peek().at);
|
|
123
|
+
pos++;
|
|
124
|
+
};
|
|
125
|
+
const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
|
|
126
|
+
const guard = (depth) => {
|
|
127
|
+
if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
|
|
128
|
+
};
|
|
129
|
+
const ternary = (depth) => {
|
|
130
|
+
guard(depth);
|
|
131
|
+
const test = comparison(depth);
|
|
132
|
+
if (!isOp("?")) return test;
|
|
133
|
+
pos++;
|
|
134
|
+
const then = ternary(depth + 1);
|
|
135
|
+
expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
|
|
136
|
+
const otherwise = ternary(depth + 1);
|
|
137
|
+
return { kind: "if", test, then, else: otherwise };
|
|
138
|
+
};
|
|
139
|
+
const comparison = (depth) => {
|
|
140
|
+
const left = additive(depth);
|
|
141
|
+
const tok = peek();
|
|
142
|
+
if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
|
|
143
|
+
pos++;
|
|
144
|
+
const right = additive(depth);
|
|
145
|
+
const next = peek();
|
|
146
|
+
if (next.t === "op" && COMPARISONS.has(next.v)) {
|
|
147
|
+
throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
|
|
148
|
+
}
|
|
149
|
+
return { kind: "binary", op: tok.v, left, right };
|
|
150
|
+
};
|
|
151
|
+
const additive = (depth) => {
|
|
152
|
+
let left = multiplicative(depth);
|
|
153
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
|
|
154
|
+
pos++;
|
|
155
|
+
left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
|
|
156
|
+
}
|
|
157
|
+
return left;
|
|
158
|
+
};
|
|
159
|
+
const multiplicative = (depth) => {
|
|
160
|
+
let left = unary(depth);
|
|
161
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
|
|
162
|
+
pos++;
|
|
163
|
+
left = { kind: "binary", op: tok.v, left, right: unary(depth) };
|
|
164
|
+
}
|
|
165
|
+
return left;
|
|
166
|
+
};
|
|
167
|
+
const unary = (depth) => {
|
|
168
|
+
guard(depth);
|
|
169
|
+
if (isOp("-")) {
|
|
170
|
+
pos++;
|
|
171
|
+
return { kind: "unary", op: "-", arg: unary(depth + 1) };
|
|
172
|
+
}
|
|
173
|
+
if (isOp("+")) {
|
|
174
|
+
pos++;
|
|
175
|
+
return unary(depth + 1);
|
|
176
|
+
}
|
|
177
|
+
return primary(depth);
|
|
178
|
+
};
|
|
179
|
+
const primary = (depth) => {
|
|
180
|
+
const tok = peek();
|
|
181
|
+
if (tok.t === "num") {
|
|
182
|
+
pos++;
|
|
183
|
+
return { kind: "number", value: tok.v };
|
|
184
|
+
}
|
|
185
|
+
if (tok.t === "str") {
|
|
186
|
+
pos++;
|
|
187
|
+
return { kind: "text", value: tok.v };
|
|
188
|
+
}
|
|
189
|
+
if (tok.t === "op" && tok.v === "(") {
|
|
190
|
+
pos++;
|
|
191
|
+
const inner = ternary(depth + 1);
|
|
192
|
+
expect(")", "A `(` has no matching `)`.");
|
|
193
|
+
return inner;
|
|
194
|
+
}
|
|
195
|
+
if (tok.t === "id") {
|
|
196
|
+
pos++;
|
|
197
|
+
if (isOp("(")) {
|
|
198
|
+
if (!FORMULA_FUNCTIONS.includes(tok.v)) {
|
|
199
|
+
throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
|
|
200
|
+
}
|
|
201
|
+
pos++;
|
|
202
|
+
const args = [];
|
|
203
|
+
if (!isOp(")")) {
|
|
204
|
+
args.push(ternary(depth + 1));
|
|
205
|
+
while (isOp(",")) {
|
|
206
|
+
pos++;
|
|
207
|
+
args.push(ternary(depth + 1));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
expect(")", `"${tok.v}(" has no matching ")".`);
|
|
211
|
+
if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
|
|
212
|
+
return { kind: "call", fn: tok.v, args };
|
|
213
|
+
}
|
|
214
|
+
if (isOp(".")) {
|
|
215
|
+
pos++;
|
|
216
|
+
const column = peek();
|
|
217
|
+
if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
|
|
218
|
+
pos++;
|
|
219
|
+
refs.push({ key: tok.v, column: column.v });
|
|
220
|
+
return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
|
|
221
|
+
}
|
|
222
|
+
refs.push({ key: tok.v });
|
|
223
|
+
return { kind: "ref", key: tok.v, at: tok.at };
|
|
224
|
+
}
|
|
225
|
+
throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
const ast = ternary(0);
|
|
229
|
+
const rest = peek();
|
|
230
|
+
if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
|
|
231
|
+
return { ok: true, ast, refs };
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
|
|
234
|
+
return { ok: false, message: "The formula is nested too deeply.", at: 0 };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function formulaRefs(source) {
|
|
238
|
+
const parsed = parseFormula(source);
|
|
239
|
+
if (!parsed.ok) return [];
|
|
240
|
+
const seen = /* @__PURE__ */ new Set();
|
|
241
|
+
return parsed.refs.filter((r) => {
|
|
242
|
+
const id = `${r.key}\0${r.column ?? ""}`;
|
|
243
|
+
if (seen.has(id)) return false;
|
|
244
|
+
seen.add(id);
|
|
245
|
+
return true;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function renameFormulaKey(source, from, to) {
|
|
249
|
+
let tokens;
|
|
250
|
+
try {
|
|
251
|
+
tokens = tokenize(source);
|
|
252
|
+
} catch {
|
|
253
|
+
return source;
|
|
254
|
+
}
|
|
255
|
+
let out = "";
|
|
256
|
+
let last = 0;
|
|
257
|
+
tokens.forEach((tok, i) => {
|
|
258
|
+
if (tok.t !== "id" || tok.v !== from) return;
|
|
259
|
+
const prev = tokens[i - 1];
|
|
260
|
+
const next = tokens[i + 1];
|
|
261
|
+
if (prev?.t === "op" && prev.v === ".") return;
|
|
262
|
+
if (next?.t === "op" && next.v === "(") return;
|
|
263
|
+
out += source.slice(last, tok.at) + to;
|
|
264
|
+
last = tok.at + from.length;
|
|
265
|
+
});
|
|
266
|
+
return out + source.slice(last);
|
|
267
|
+
}
|
|
268
|
+
var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
269
|
+
function formulaNumber(v) {
|
|
270
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
271
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
272
|
+
if (typeof v === "string") {
|
|
273
|
+
const t = v.trim().replace(/,/g, "");
|
|
274
|
+
return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
var finite = (n) => Number.isFinite(n) ? n : null;
|
|
279
|
+
var scalar = (v) => Array.isArray(v) ? null : v;
|
|
280
|
+
function truthy(v) {
|
|
281
|
+
if (v === null) return false;
|
|
282
|
+
if (typeof v === "boolean") return v;
|
|
283
|
+
if (typeof v === "number") return v !== 0;
|
|
284
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
285
|
+
return v.length > 0;
|
|
286
|
+
}
|
|
287
|
+
function numbersIn(values, out = []) {
|
|
288
|
+
for (const v of values) {
|
|
289
|
+
if (Array.isArray(v)) numbersIn(v, out);
|
|
290
|
+
else {
|
|
291
|
+
const n = formulaNumber(v);
|
|
292
|
+
if (n !== null) out.push(n);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
var listTotals = /* @__PURE__ */ new WeakMap();
|
|
298
|
+
var ownLists = /* @__PURE__ */ new WeakSet();
|
|
299
|
+
function ownFormulaList(list) {
|
|
300
|
+
Object.freeze(list);
|
|
301
|
+
ownLists.add(list);
|
|
302
|
+
return list;
|
|
303
|
+
}
|
|
304
|
+
function totalsOf(list) {
|
|
305
|
+
const own2 = ownLists.has(list);
|
|
306
|
+
let t = own2 ? listTotals.get(list) : void 0;
|
|
307
|
+
if (t) return t;
|
|
308
|
+
const nums = numbersIn(list);
|
|
309
|
+
t = { sum: 0, count: nums.length, min: Infinity, max: -Infinity };
|
|
310
|
+
for (const n of nums) {
|
|
311
|
+
t.sum += n;
|
|
312
|
+
if (n < t.min) t.min = n;
|
|
313
|
+
if (n > t.max) t.max = n;
|
|
314
|
+
}
|
|
315
|
+
if (own2) listTotals.set(list, t);
|
|
316
|
+
return t;
|
|
317
|
+
}
|
|
318
|
+
function compare(op, a, b) {
|
|
319
|
+
const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
|
|
320
|
+
if (blank2(a) || blank2(b)) return op === "!=";
|
|
321
|
+
const x = formulaNumber(a);
|
|
322
|
+
const y = formulaNumber(b);
|
|
323
|
+
if (x !== null && y !== null) {
|
|
324
|
+
if (op === "==") return x === y;
|
|
325
|
+
if (op === "!=") return x !== y;
|
|
326
|
+
if (op === "<") return x < y;
|
|
327
|
+
if (op === "<=") return x <= y;
|
|
328
|
+
if (op === ">") return x > y;
|
|
329
|
+
return x >= y;
|
|
330
|
+
}
|
|
331
|
+
if (op === "==") return String(a) === String(b);
|
|
332
|
+
if (op === "!=") return String(a) !== String(b);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
function evaluateFormula(ast, read) {
|
|
336
|
+
const run = (node) => {
|
|
337
|
+
switch (node.kind) {
|
|
338
|
+
case "number":
|
|
339
|
+
case "text":
|
|
340
|
+
return node.value;
|
|
341
|
+
case "ref":
|
|
342
|
+
return read(node);
|
|
343
|
+
case "unary": {
|
|
344
|
+
const n = formulaNumber(scalar(run(node.arg)));
|
|
345
|
+
return n === null ? null : -n;
|
|
346
|
+
}
|
|
347
|
+
case "if":
|
|
348
|
+
return truthy(run(node.test)) ? run(node.then) : run(node.else);
|
|
349
|
+
case "call": {
|
|
350
|
+
let total = 0;
|
|
351
|
+
let count = 0;
|
|
352
|
+
let min = Infinity;
|
|
353
|
+
let max = -Infinity;
|
|
354
|
+
for (const arg of node.args) {
|
|
355
|
+
const v = run(arg);
|
|
356
|
+
if (Array.isArray(v)) {
|
|
357
|
+
const t = totalsOf(v);
|
|
358
|
+
total += t.sum;
|
|
359
|
+
count += t.count;
|
|
360
|
+
if (t.min < min) min = t.min;
|
|
361
|
+
if (t.max > max) max = t.max;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const n = formulaNumber(v);
|
|
365
|
+
if (n === null) continue;
|
|
366
|
+
total += n;
|
|
367
|
+
count++;
|
|
368
|
+
if (n < min) min = n;
|
|
369
|
+
if (n > max) max = n;
|
|
370
|
+
}
|
|
371
|
+
if (node.fn === "sum") return finite(total);
|
|
372
|
+
if (!count) return null;
|
|
373
|
+
if (node.fn === "avg") return finite(total / count);
|
|
374
|
+
return node.fn === "min" ? min : max;
|
|
375
|
+
}
|
|
376
|
+
case "binary": {
|
|
377
|
+
const a = run(node.left);
|
|
378
|
+
const b = run(node.right);
|
|
379
|
+
if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
|
|
380
|
+
const x = formulaNumber(scalar(a));
|
|
381
|
+
const y = formulaNumber(scalar(b));
|
|
382
|
+
if (x === null || y === null) return null;
|
|
383
|
+
if (node.op === "+") return finite(x + y);
|
|
384
|
+
if (node.op === "-") return finite(x - y);
|
|
385
|
+
if (node.op === "*") return finite(x * y);
|
|
386
|
+
return y === 0 ? null : finite(x / y);
|
|
387
|
+
}
|
|
388
|
+
return compare(node.op, a, b);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
try {
|
|
393
|
+
return run(ast);
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/calculated.ts
|
|
400
|
+
var CALCULATED_TYPE = "calculated";
|
|
401
|
+
var COMPUTED_FIELD_TYPES = [CALCULATED_TYPE];
|
|
402
|
+
var MAX_FORM_FORMULA_LENGTH = 2e4;
|
|
403
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
404
|
+
function calculatedProps(field2) {
|
|
405
|
+
const p = field2.props ?? {};
|
|
406
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
407
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
408
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
409
|
+
out.decimals = p.decimals;
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
var isCalculated = (field2) => field2?.type === CALCULATED_TYPE;
|
|
414
|
+
var round = (n, decimals) => {
|
|
415
|
+
if (decimals === void 0) return n;
|
|
416
|
+
const r = Number(n.toFixed(decimals));
|
|
417
|
+
return Object.is(r, -0) ? 0 : r;
|
|
418
|
+
};
|
|
419
|
+
function calculatedValue(result, field2) {
|
|
420
|
+
const { format, decimals } = calculatedProps(field2);
|
|
421
|
+
if (result === null || Array.isArray(result)) return void 0;
|
|
422
|
+
if (format === "number" || format === "percent") {
|
|
423
|
+
const n = formulaNumber(result);
|
|
424
|
+
return n === null ? void 0 : round(n, decimals);
|
|
425
|
+
}
|
|
426
|
+
if (format === "text") {
|
|
427
|
+
if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
|
|
428
|
+
return typeof result === "boolean" ? result ? "Yes" : "No" : result;
|
|
429
|
+
}
|
|
430
|
+
return typeof result === "number" ? round(result, decimals) : result;
|
|
431
|
+
}
|
|
432
|
+
function formatCalculated(value, field2) {
|
|
433
|
+
if (value === void 0 || value === null) return "";
|
|
434
|
+
const { format, decimals } = calculatedProps(field2);
|
|
435
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
436
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
437
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
438
|
+
return typeof value === "string" ? value : "";
|
|
439
|
+
}
|
|
440
|
+
var MAX_FORMULA_ITEMS = 1e4;
|
|
441
|
+
var MAX_SETTLE_ITEMS = 5e4;
|
|
442
|
+
var extract = (value, column) => column !== void 0 ? value.flatMap((row) => {
|
|
443
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
444
|
+
const cell = row[column];
|
|
445
|
+
return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
|
|
446
|
+
}) : value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
|
|
447
|
+
var extracted = /* @__PURE__ */ new WeakMap();
|
|
448
|
+
function cachedList(value, column) {
|
|
449
|
+
let byColumn = extracted.get(value);
|
|
450
|
+
if (!byColumn) extracted.set(value, byColumn = /* @__PURE__ */ new Map());
|
|
451
|
+
const key = column ?? "";
|
|
452
|
+
let list = byColumn.get(key);
|
|
453
|
+
if (!list) byColumn.set(key, list = ownFormulaList(extract(value, column)));
|
|
454
|
+
return list;
|
|
455
|
+
}
|
|
456
|
+
function readAnswer(value, column) {
|
|
457
|
+
if (Array.isArray(value)) return cachedList(value, column);
|
|
458
|
+
return column !== void 0 ? null : formulaInput(value);
|
|
459
|
+
}
|
|
460
|
+
function formulaInput(value, column) {
|
|
461
|
+
if (value === void 0 || value === null) return null;
|
|
462
|
+
if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
|
|
463
|
+
if (column !== void 0) {
|
|
464
|
+
if (!Array.isArray(value)) return null;
|
|
465
|
+
return extract(value, column);
|
|
466
|
+
}
|
|
467
|
+
if (Array.isArray(value)) return extract(value, void 0);
|
|
468
|
+
return typeof value === "object" ? null : value;
|
|
469
|
+
}
|
|
470
|
+
function refNodes(node, out = []) {
|
|
471
|
+
if (node.kind === "ref") out.push(node);
|
|
472
|
+
else if (node.kind === "unary") refNodes(node.arg, out);
|
|
473
|
+
else if (node.kind === "binary") refNodes(node.left, out), refNodes(node.right, out);
|
|
474
|
+
else if (node.kind === "if") refNodes(node.test, out), refNodes(node.then, out), refNodes(node.else, out);
|
|
475
|
+
else if (node.kind === "call") for (const a of node.args) refNodes(a, out);
|
|
476
|
+
return out;
|
|
477
|
+
}
|
|
478
|
+
var UNREAD = /* @__PURE__ */ Symbol("unread");
|
|
479
|
+
var OVER = /* @__PURE__ */ Symbol("over");
|
|
480
|
+
function calculator(definition) {
|
|
481
|
+
const compiled = [];
|
|
482
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
483
|
+
for (const block of definition.blocks) {
|
|
484
|
+
if (block.kind !== "field") continue;
|
|
485
|
+
idByKey.set(block.key, block.id);
|
|
486
|
+
}
|
|
487
|
+
for (const block of definition.blocks) {
|
|
488
|
+
if (block.kind !== "field" || !isCalculated(block)) continue;
|
|
489
|
+
const parsed = parseFormula(calculatedProps(block).formula);
|
|
490
|
+
const reads = [];
|
|
491
|
+
const slots = /* @__PURE__ */ new Map();
|
|
492
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
493
|
+
for (const ref of parsed.ok ? refNodes(parsed.ast) : []) {
|
|
494
|
+
const id = idByKey.get(ref.key);
|
|
495
|
+
const key = `${id ?? ""}\0${ref.column ?? ""}`;
|
|
496
|
+
let slot = byKey.get(key);
|
|
497
|
+
if (slot === void 0) {
|
|
498
|
+
slot = reads.push({ id, column: ref.column, key }) - 1;
|
|
499
|
+
byKey.set(key, slot);
|
|
500
|
+
}
|
|
501
|
+
slots.set(ref, slot);
|
|
502
|
+
}
|
|
503
|
+
compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, slots, last: null });
|
|
504
|
+
}
|
|
505
|
+
const withCalculated = (values, path) => {
|
|
506
|
+
if (!compiled.length) return values;
|
|
507
|
+
const visible = new Set(path);
|
|
508
|
+
let next = values;
|
|
509
|
+
let budget = MAX_SETTLE_ITEMS;
|
|
510
|
+
const within = /* @__PURE__ */ new Map();
|
|
511
|
+
const input = ({ id, key }) => {
|
|
512
|
+
if (id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id)) return UNREAD;
|
|
513
|
+
const value = next[id];
|
|
514
|
+
if (!Array.isArray(value)) return value;
|
|
515
|
+
let ok = within.get(key);
|
|
516
|
+
if (ok === void 0) {
|
|
517
|
+
ok = value.length <= MAX_FORMULA_ITEMS && value.length <= budget;
|
|
518
|
+
if (ok) budget -= value.length;
|
|
519
|
+
within.set(key, ok);
|
|
520
|
+
}
|
|
521
|
+
return ok ? value : OVER;
|
|
522
|
+
};
|
|
523
|
+
for (const entry of compiled) {
|
|
524
|
+
const { field: field2, ast } = entry;
|
|
525
|
+
const inputs = entry.reads.map(input);
|
|
526
|
+
let value;
|
|
527
|
+
if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
|
|
528
|
+
value = entry.last.value;
|
|
529
|
+
} else {
|
|
530
|
+
const resolved = new Array(inputs.length);
|
|
531
|
+
const read = (ref) => {
|
|
532
|
+
const slot = entry.slots.get(ref);
|
|
533
|
+
if (slot === void 0) return null;
|
|
534
|
+
let v = resolved[slot];
|
|
535
|
+
if (v === void 0) {
|
|
536
|
+
const raw = inputs[slot];
|
|
537
|
+
v = resolved[slot] = raw === UNREAD ? null : readAnswer(raw, entry.reads[slot].column);
|
|
538
|
+
}
|
|
539
|
+
return v;
|
|
540
|
+
};
|
|
541
|
+
value = ast && !inputs.includes(OVER) ? calculatedValue(evaluateFormula(ast, read), field2) : void 0;
|
|
542
|
+
entry.last = { inputs, value };
|
|
543
|
+
}
|
|
544
|
+
const current = Object.prototype.hasOwnProperty.call(next, field2.id) ? next[field2.id] : void 0;
|
|
545
|
+
if (current !== value) next = { ...next, [field2.id]: value };
|
|
546
|
+
}
|
|
547
|
+
return next;
|
|
548
|
+
};
|
|
549
|
+
return { active: compiled.length > 0, withCalculated };
|
|
550
|
+
}
|
|
551
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
552
|
+
var calculatedFieldType = {
|
|
553
|
+
type: CALCULATED_TYPE,
|
|
554
|
+
label: "Calculated",
|
|
555
|
+
category: "advanced",
|
|
556
|
+
value: { json: true },
|
|
557
|
+
operators: numberOps,
|
|
558
|
+
a11y: { semantics: "control" },
|
|
559
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
560
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
561
|
+
summarize: (value, field2) => formatCalculated(value, field2)
|
|
562
|
+
};
|
|
563
|
+
|
|
33
564
|
// src/events.ts
|
|
34
565
|
function isPlainObject(value) {
|
|
35
566
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -324,7 +855,7 @@ function intRange(field2, min, maxDefault) {
|
|
|
324
855
|
const max = typeof field2.props?.max === "number" ? field2.props.max : maxDefault;
|
|
325
856
|
return { min, max };
|
|
326
857
|
}
|
|
327
|
-
var
|
|
858
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
328
859
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
329
860
|
function maxLength(field2, fallback) {
|
|
330
861
|
return typeof field2.props?.maxLength === "number" ? field2.props.maxLength : fallback;
|
|
@@ -402,23 +933,30 @@ var builtins = [
|
|
|
402
933
|
label: "Rating",
|
|
403
934
|
category: "scale",
|
|
404
935
|
value: { json: true },
|
|
405
|
-
operators:
|
|
936
|
+
operators: numberOps2,
|
|
406
937
|
a11y: { semantics: "group" },
|
|
407
938
|
validate: (value, field2) => {
|
|
408
939
|
const { min, max } = intRange(field2, 1, 5);
|
|
409
940
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
410
941
|
},
|
|
411
|
-
summarize: (value, field2) =>
|
|
942
|
+
summarize: (value, field2) => {
|
|
943
|
+
const { max } = intRange(field2, 1, 5);
|
|
944
|
+
const end = scaleEndLabel(field2, value, 1, max);
|
|
945
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
946
|
+
}
|
|
412
947
|
},
|
|
413
948
|
{
|
|
414
949
|
type: "nps",
|
|
415
950
|
label: "Net Promoter Score",
|
|
416
951
|
category: "scale",
|
|
417
952
|
value: { json: true },
|
|
418
|
-
operators:
|
|
953
|
+
operators: numberOps2,
|
|
419
954
|
a11y: { semantics: "group" },
|
|
420
955
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
421
|
-
summarize: (value) =>
|
|
956
|
+
summarize: (value, field2) => {
|
|
957
|
+
const end = scaleEndLabel(field2, value, 0, 10);
|
|
958
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
959
|
+
}
|
|
422
960
|
},
|
|
423
961
|
{
|
|
424
962
|
type: "single-choice",
|
|
@@ -452,9 +990,125 @@ var builtins = [
|
|
|
452
990
|
a11y: { semantics: "group" },
|
|
453
991
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
454
992
|
summarize: (value) => value ? "Yes" : "No"
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
type: "dropdown",
|
|
996
|
+
label: "Dropdown",
|
|
997
|
+
category: "choice",
|
|
998
|
+
value: { json: true },
|
|
999
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
1000
|
+
a11y: { semantics: "control" },
|
|
1001
|
+
validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
1002
|
+
summarize: (value, field2) => optionLabel(field2, String(value))
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
type: "number",
|
|
1006
|
+
label: "Number",
|
|
1007
|
+
category: "text",
|
|
1008
|
+
value: { json: true },
|
|
1009
|
+
operators: numberOps2,
|
|
1010
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
1011
|
+
validate: (value, field2) => {
|
|
1012
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
1013
|
+
const { min, max, step } = numberLimits(field2);
|
|
1014
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
1015
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
1016
|
+
}
|
|
1017
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
1018
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
1019
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
1020
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
1021
|
+
}
|
|
1022
|
+
return null;
|
|
1023
|
+
},
|
|
1024
|
+
summarize: (value) => String(value),
|
|
1025
|
+
samples: (field2) => {
|
|
1026
|
+
const { min, max } = numberLimits(field2);
|
|
1027
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
type: "date",
|
|
1032
|
+
label: "Date",
|
|
1033
|
+
category: "date",
|
|
1034
|
+
value: { json: true },
|
|
1035
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
1036
|
+
a11y: { semantics: "control" },
|
|
1037
|
+
validate: (value, field2) => {
|
|
1038
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
1039
|
+
const date = value.trim();
|
|
1040
|
+
const { min, max } = dateLimits(field2);
|
|
1041
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
1042
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
1043
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
1044
|
+
return null;
|
|
1045
|
+
},
|
|
1046
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
1047
|
+
samples: (field2) => {
|
|
1048
|
+
const { min, max } = dateLimits(field2);
|
|
1049
|
+
return [min ?? max ?? "2000-01-01"];
|
|
1050
|
+
}
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
type: "consent",
|
|
1054
|
+
label: "Consent checkbox",
|
|
1055
|
+
category: "choice",
|
|
1056
|
+
value: { json: true },
|
|
1057
|
+
operators: ["answered", "empty"],
|
|
1058
|
+
a11y: { semantics: "group" },
|
|
1059
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
1060
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
1061
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
1062
|
+
summarize: () => "Agreed",
|
|
1063
|
+
samples: () => [true]
|
|
455
1064
|
}
|
|
456
1065
|
];
|
|
457
|
-
|
|
1066
|
+
function scaleLabels(field2) {
|
|
1067
|
+
const out = {};
|
|
1068
|
+
const low = field2.props?.lowLabel;
|
|
1069
|
+
const high = field2.props?.highLabel;
|
|
1070
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
1071
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
1072
|
+
return out;
|
|
1073
|
+
}
|
|
1074
|
+
function ratingDisplay(field2) {
|
|
1075
|
+
return field2.props?.display === "stars" ? "stars" : "numbers";
|
|
1076
|
+
}
|
|
1077
|
+
function scaleEndLabel(field2, value, min, max) {
|
|
1078
|
+
const { low, high } = scaleLabels(field2);
|
|
1079
|
+
return value === min ? low : value === max ? high : void 0;
|
|
1080
|
+
}
|
|
1081
|
+
var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
1082
|
+
function numberLimits(field2) {
|
|
1083
|
+
const out = {};
|
|
1084
|
+
const min = finite2(field2.props?.min);
|
|
1085
|
+
const max = finite2(field2.props?.max);
|
|
1086
|
+
const step = finite2(field2.props?.step);
|
|
1087
|
+
if (min !== void 0) out.min = min;
|
|
1088
|
+
if (max !== void 0) out.max = max;
|
|
1089
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
1090
|
+
return out;
|
|
1091
|
+
}
|
|
1092
|
+
function onStep(value, base, step) {
|
|
1093
|
+
const n = (value - base) / step;
|
|
1094
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
1095
|
+
}
|
|
1096
|
+
function isIsoDate(value) {
|
|
1097
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
1098
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
1099
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
1100
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
1101
|
+
return d <= days[m - 1];
|
|
1102
|
+
}
|
|
1103
|
+
function dateLimits(field2) {
|
|
1104
|
+
const out = {};
|
|
1105
|
+
const min = field2.props?.min;
|
|
1106
|
+
const max = field2.props?.max;
|
|
1107
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
1108
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
1109
|
+
return out;
|
|
1110
|
+
}
|
|
1111
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
458
1112
|
|
|
459
1113
|
// src/logic.ts
|
|
460
1114
|
function own(record, key) {
|
|
@@ -626,8 +1280,15 @@ var LITERAL_TYPE = {
|
|
|
626
1280
|
"single-choice": "string",
|
|
627
1281
|
"multi-choice": "string",
|
|
628
1282
|
"yes-no": "boolean",
|
|
629
|
-
list: "string"
|
|
1283
|
+
list: "string",
|
|
1284
|
+
dropdown: "string",
|
|
1285
|
+
number: "number",
|
|
1286
|
+
date: "string",
|
|
1287
|
+
consent: "boolean",
|
|
1288
|
+
// A calculated field holds a number unless its format says text; see `literalType`.
|
|
1289
|
+
calculated: "number"
|
|
630
1290
|
};
|
|
1291
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
631
1292
|
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
632
1293
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
633
1294
|
"eq",
|
|
@@ -707,8 +1368,45 @@ function parseForm(input) {
|
|
|
707
1368
|
} else if (type === "text-highlights") {
|
|
708
1369
|
optionalText(props2.text, `${path}.text`);
|
|
709
1370
|
optionalInt(props2.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1371
|
+
} else if (type === CALCULATED_TYPE) {
|
|
1372
|
+
if (props2.format !== void 0 && props2.format !== "number" && props2.format !== "percent" && props2.format !== "text") {
|
|
1373
|
+
issue(`${path}.format`, "Must be number, percent or text.");
|
|
1374
|
+
}
|
|
1375
|
+
if (props2.decimals !== void 0 && !(isInt(props2.decimals) && props2.decimals >= 0 && props2.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
1376
|
+
issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
1377
|
+
}
|
|
710
1378
|
}
|
|
711
1379
|
};
|
|
1380
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1381
|
+
const fieldProps = (type, props2, path) => {
|
|
1382
|
+
if (type === "rating" || type === "nps") {
|
|
1383
|
+
optionalText(props2.lowLabel, `${path}.lowLabel`);
|
|
1384
|
+
optionalText(props2.highLabel, `${path}.highLabel`);
|
|
1385
|
+
}
|
|
1386
|
+
if (type === "rating" && props2.display !== void 0 && props2.display !== "numbers" && props2.display !== "stars") {
|
|
1387
|
+
issue(`${path}.display`, "Must be numbers or stars.");
|
|
1388
|
+
}
|
|
1389
|
+
if (type === "number") {
|
|
1390
|
+
for (const k of ["min", "max", "step"]) {
|
|
1391
|
+
if (props2[k] !== void 0 && !isFiniteNumber(props2[k])) issue(`${path}.${k}`, "Must be a number.");
|
|
1392
|
+
}
|
|
1393
|
+
if (isFiniteNumber(props2.step) && props2.step <= 0) issue(`${path}.step`, "Must be above 0.");
|
|
1394
|
+
if (isFiniteNumber(props2.min) && isFiniteNumber(props2.max) && props2.min > props2.max) {
|
|
1395
|
+
issue(`${path}.min`, "Can't be more than the largest number.");
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
if (type === "date") {
|
|
1399
|
+
for (const k of ["min", "max"]) {
|
|
1400
|
+
if (props2[k] !== void 0 && !(typeof props2[k] === "string" && isIsoDate(props2[k]))) {
|
|
1401
|
+
issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (typeof props2.min === "string" && typeof props2.max === "string" && isIsoDate(props2.min) && isIsoDate(props2.max) && props2.min > props2.max) {
|
|
1405
|
+
issue(`${path}.min`, "Can't be after the latest date.");
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (type === "consent") optionalText(props2.text, `${path}.text`);
|
|
1409
|
+
};
|
|
712
1410
|
if (!isObject2(input)) {
|
|
713
1411
|
issue("", "A form definition must be an object.");
|
|
714
1412
|
return fail();
|
|
@@ -742,7 +1440,9 @@ function parseForm(input) {
|
|
|
742
1440
|
const keys = /* @__PURE__ */ new Map();
|
|
743
1441
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
744
1442
|
const order = /* @__PURE__ */ new Map();
|
|
1443
|
+
const tableColumnKeys = /* @__PURE__ */ new Map();
|
|
745
1444
|
const choices = /* @__PURE__ */ new Map();
|
|
1445
|
+
const calculatedLiteral = /* @__PURE__ */ new Map();
|
|
746
1446
|
input.blocks.forEach((block, i) => {
|
|
747
1447
|
const path = `blocks[${i}]`;
|
|
748
1448
|
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
@@ -792,12 +1492,24 @@ function parseForm(input) {
|
|
|
792
1492
|
}
|
|
793
1493
|
if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
|
|
794
1494
|
structuredProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1495
|
+
fieldProps(block.type, block.props ?? {}, `${path}.props`);
|
|
795
1496
|
}
|
|
796
1497
|
if (validKey) {
|
|
797
1498
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
798
1499
|
keys.set(block.key, block.id);
|
|
799
1500
|
}
|
|
800
1501
|
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
1502
|
+
if (block.type === CALCULATED_TYPE) {
|
|
1503
|
+
const format = isObject2(block.props) ? block.props.format : void 0;
|
|
1504
|
+
if (format === "text") calculatedLiteral.set(block.id, "string");
|
|
1505
|
+
else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
|
|
1506
|
+
}
|
|
1507
|
+
if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
|
|
1508
|
+
tableColumnKeys.set(
|
|
1509
|
+
block.id,
|
|
1510
|
+
new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
|
|
1511
|
+
);
|
|
1512
|
+
}
|
|
801
1513
|
} else if (block.kind === "content") {
|
|
802
1514
|
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
803
1515
|
text(block.text, `${path}.text`);
|
|
@@ -807,6 +1519,44 @@ function parseForm(input) {
|
|
|
807
1519
|
issue(`${path}.kind`, "Unknown block kind.");
|
|
808
1520
|
}
|
|
809
1521
|
});
|
|
1522
|
+
let formulaChars = 0;
|
|
1523
|
+
input.blocks.forEach((block, i) => {
|
|
1524
|
+
if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
|
|
1525
|
+
const path = `blocks[${i}].props.formula`;
|
|
1526
|
+
const source = isObject2(block.props) && typeof block.props.formula === "string" ? block.props.formula : "";
|
|
1527
|
+
formulaChars += source.length;
|
|
1528
|
+
if (formulaChars > MAX_FORM_FORMULA_LENGTH && formulaChars - source.length <= MAX_FORM_FORMULA_LENGTH) {
|
|
1529
|
+
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.`);
|
|
1530
|
+
}
|
|
1531
|
+
if (block.required === true) {
|
|
1532
|
+
warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
|
|
1533
|
+
}
|
|
1534
|
+
const formula = isObject2(block.props) ? block.props.formula : void 0;
|
|
1535
|
+
if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
|
|
1536
|
+
const parsed = parseFormula(formula);
|
|
1537
|
+
if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
|
|
1538
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1539
|
+
for (const ref of parsed.refs) {
|
|
1540
|
+
const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
|
|
1541
|
+
if (reported.has(name2)) continue;
|
|
1542
|
+
reported.add(name2);
|
|
1543
|
+
const target = keys.get(ref.key);
|
|
1544
|
+
const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
|
|
1545
|
+
const at = target === void 0 ? void 0 : order.get(target);
|
|
1546
|
+
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.`);
|
|
1547
|
+
else if (target === block.id) issue(path, "The formula uses this field's own value.");
|
|
1548
|
+
else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
|
|
1549
|
+
issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
|
|
1550
|
+
} else if (targetType === "table") {
|
|
1551
|
+
const columns = tableColumnKeys.get(target);
|
|
1552
|
+
if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
|
|
1553
|
+
else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
|
|
1554
|
+
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1555
|
+
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1556
|
+
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
810
1560
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
811
1561
|
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
812
1562
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
@@ -890,8 +1640,8 @@ function parseForm(input) {
|
|
|
890
1640
|
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
891
1641
|
const fieldType = fieldTypes.get(left.field);
|
|
892
1642
|
what = `"${keyOf(left.field)}"`;
|
|
893
|
-
type = LITERAL_TYPE[fieldType];
|
|
894
|
-
if (fieldType
|
|
1643
|
+
type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
|
|
1644
|
+
if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
|
|
895
1645
|
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
896
1646
|
what = `hidden input "${left.hidden}"`;
|
|
897
1647
|
type = hiddenTypes.get(left.hidden);
|
|
@@ -1037,11 +1787,33 @@ var f = {
|
|
|
1037
1787
|
longText: (key, o) => field("long-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
|
|
1038
1788
|
email: (key, o) => field("email", key, o),
|
|
1039
1789
|
phone: (key, o) => field("phone", key, o),
|
|
1040
|
-
|
|
1041
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* A 1-to-`max` scale. `lowLabel` and `highLabel` are optional words under
|
|
1792
|
+
* its ends ("Poor", "Great"); `display: "stars"` draws stars instead of numbers.
|
|
1793
|
+
*/
|
|
1794
|
+
rating: (key, o) => field(
|
|
1795
|
+
"rating",
|
|
1796
|
+
key,
|
|
1797
|
+
o,
|
|
1798
|
+
props({ max: o.max ?? 5, lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0, display: o.display })
|
|
1799
|
+
),
|
|
1800
|
+
/** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
|
|
1801
|
+
nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
|
|
1042
1802
|
singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
|
|
1043
1803
|
multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
|
|
1044
1804
|
yesNo: (key, o) => field("yes-no", key, o),
|
|
1805
|
+
/** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
|
|
1806
|
+
dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
|
|
1807
|
+
/** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
|
|
1808
|
+
number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
|
|
1809
|
+
/** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
|
|
1810
|
+
date: (key, o) => field("date", key, o, props({ min: o.min, max: o.max })),
|
|
1811
|
+
/**
|
|
1812
|
+
* A single checkbox to agree to something. `text` is the checkbox's own
|
|
1813
|
+
* words ("I agree to the terms"); the renderer defaults to "I agree".
|
|
1814
|
+
* Checked stores `true`; unchecked is unanswered, so `required` means it must be checked.
|
|
1815
|
+
*/
|
|
1816
|
+
consent: (key, o) => field("consent", key, o, props({ text: o.text || void 0 })),
|
|
1045
1817
|
/** Several short answers, one per line: goals, action items, three things that went well. */
|
|
1046
1818
|
list: (key, o) => field(
|
|
1047
1819
|
"list",
|
|
@@ -1049,6 +1821,12 @@ var f = {
|
|
|
1049
1821
|
o,
|
|
1050
1822
|
props({ min: o.min, max: o.max, itemMaxLength: o.itemMaxLength, placeholder: o.itemPlaceholder })
|
|
1051
1823
|
),
|
|
1824
|
+
/**
|
|
1825
|
+
* A value worked out from other answers and shown read-only, such as a
|
|
1826
|
+
* total or a score: `formula` names answers by key (`hours * rate`,
|
|
1827
|
+
* `sum(plan.cost)`). Stored with the answers; never required.
|
|
1828
|
+
*/
|
|
1829
|
+
calculated: (key, o) => field("calculated", key, o, props({ formula: o.formula, format: o.format, decimals: o.decimals })),
|
|
1052
1830
|
/**
|
|
1053
1831
|
* A small table each answer fills row by row, one answer per column: an
|
|
1054
1832
|
* action plan (What / Who / By when), pros and cons.
|
|
@@ -1108,9 +1886,9 @@ function effects(fromKey, condition) {
|
|
|
1108
1886
|
})
|
|
1109
1887
|
};
|
|
1110
1888
|
}
|
|
1111
|
-
var
|
|
1889
|
+
var compare2 = (key, op, value) => (r) => value === void 0 ? { op, left: { field: r(key) } } : { op, left: { field: r(key) }, right: { value } };
|
|
1112
1890
|
function when(key) {
|
|
1113
|
-
const on = (op, value) => effects(key,
|
|
1891
|
+
const on = (op, value) => effects(key, compare2(key, op, value));
|
|
1114
1892
|
return {
|
|
1115
1893
|
is: (value) => on("eq", value),
|
|
1116
1894
|
isNot: (value) => on("neq", value),
|
|
@@ -1238,7 +2016,7 @@ function runCustom(validator, value) {
|
|
|
1238
2016
|
}
|
|
1239
2017
|
function validateField(field2, value, options = {}) {
|
|
1240
2018
|
if (isEmptyValue(value, field2)) {
|
|
1241
|
-
return field2.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
2019
|
+
return field2.required && field2.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1242
2020
|
}
|
|
1243
2021
|
const manifest = getFieldType(field2.type);
|
|
1244
2022
|
if (!manifest) return `Unknown field type "${field2.type}".`;
|
|
@@ -1269,9 +2047,21 @@ function build(definition, options, replaying = false) {
|
|
|
1269
2047
|
if (!declared.has(name)) initialHidden[name] = value;
|
|
1270
2048
|
}
|
|
1271
2049
|
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
1272
|
-
const
|
|
2050
|
+
const calc = calculator(definition);
|
|
2051
|
+
const settle = (values, hidden) => {
|
|
2052
|
+
let { path } = computePath(definition, scopeOf(values, hidden));
|
|
2053
|
+
if (!calc.active) return { values, path };
|
|
2054
|
+
for (let round2 = 0; round2 < 4; round2++) {
|
|
2055
|
+
const next = calc.withCalculated(values, path);
|
|
2056
|
+
if (next === values) break;
|
|
2057
|
+
values = next;
|
|
2058
|
+
path = computePath(definition, scopeOf(values, hidden)).path;
|
|
2059
|
+
}
|
|
2060
|
+
return { values, path };
|
|
2061
|
+
};
|
|
2062
|
+
const first = settle({}, initialHidden);
|
|
1273
2063
|
let state = {
|
|
1274
|
-
values:
|
|
2064
|
+
values: first.values,
|
|
1275
2065
|
hidden: initialHidden,
|
|
1276
2066
|
path: first.path,
|
|
1277
2067
|
current: first.path[0] ?? null,
|
|
@@ -1306,12 +2096,25 @@ function build(definition, options, replaying = false) {
|
|
|
1306
2096
|
}
|
|
1307
2097
|
return errors;
|
|
1308
2098
|
};
|
|
2099
|
+
let unsettled = false;
|
|
2100
|
+
const settleNow = () => {
|
|
2101
|
+
if (!unsettled) return;
|
|
2102
|
+
unsettled = false;
|
|
2103
|
+
const { values, path } = settle(state.values, state.hidden);
|
|
2104
|
+
state = { ...state, values, path };
|
|
2105
|
+
};
|
|
1309
2106
|
const answer = (id, value, event) => {
|
|
1310
|
-
if (state.status === "completed" || own(state.values, id) === value) return;
|
|
1311
|
-
const values = { ...state.values, [id]: value };
|
|
1312
|
-
const { path } = computePath(definition, scopeOf(values, state.hidden));
|
|
2107
|
+
if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
|
|
1313
2108
|
const errors = { ...state.errors };
|
|
1314
2109
|
delete errors[id];
|
|
2110
|
+
if (batch) {
|
|
2111
|
+
state.values = { ...state.values, [id]: value };
|
|
2112
|
+
state.errors = errors;
|
|
2113
|
+
log(event);
|
|
2114
|
+
unsettled = true;
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
1315
2118
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
1316
2119
|
};
|
|
1317
2120
|
const engine = {
|
|
@@ -1383,10 +2186,14 @@ function build(definition, options, replaying = false) {
|
|
|
1383
2186
|
}
|
|
1384
2187
|
};
|
|
1385
2188
|
if (options.initialValues) {
|
|
2189
|
+
const wasBatch = batch;
|
|
2190
|
+
batch = true;
|
|
1386
2191
|
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
1387
2192
|
if (!fields.has(id) || value === void 0) continue;
|
|
1388
2193
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
1389
2194
|
}
|
|
2195
|
+
settleNow();
|
|
2196
|
+
batch = wasBatch;
|
|
1390
2197
|
}
|
|
1391
2198
|
const apply = (event) => {
|
|
1392
2199
|
if (event.type === "answer") {
|
|
@@ -1394,11 +2201,19 @@ function build(definition, options, replaying = false) {
|
|
|
1394
2201
|
const { at: _at, ...logged } = event;
|
|
1395
2202
|
answer(event.field, event.value, logged);
|
|
1396
2203
|
}
|
|
1397
|
-
} else if (event.type === "next")
|
|
1398
|
-
|
|
1399
|
-
|
|
2204
|
+
} else if (event.type === "next") {
|
|
2205
|
+
settleNow();
|
|
2206
|
+
engine.next();
|
|
2207
|
+
} else if (event.type === "back") {
|
|
2208
|
+
settleNow();
|
|
2209
|
+
engine.back();
|
|
2210
|
+
} else if (event.type === "submit") {
|
|
2211
|
+
settleNow();
|
|
2212
|
+
engine.submit();
|
|
2213
|
+
}
|
|
1400
2214
|
};
|
|
1401
2215
|
const finish = () => {
|
|
2216
|
+
settleNow();
|
|
1402
2217
|
batch = false;
|
|
1403
2218
|
state = { ...state, events: [...state.events] };
|
|
1404
2219
|
};
|
|
@@ -1547,42 +2362,64 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
|
|
|
1547
2362
|
return `${left} ${op} ${right}`;
|
|
1548
2363
|
}
|
|
1549
2364
|
|
|
2365
|
+
exports.CALCULATED_TYPE = CALCULATED_TYPE;
|
|
2366
|
+
exports.COMPUTED_FIELD_TYPES = COMPUTED_FIELD_TYPES;
|
|
2367
|
+
exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
|
|
1550
2368
|
exports.HIGHLIGHT_NOTE_MESSAGE = HIGHLIGHT_NOTE_MESSAGE;
|
|
2369
|
+
exports.MAX_CALCULATED_DECIMALS = MAX_CALCULATED_DECIMALS;
|
|
2370
|
+
exports.MAX_FORMULA_ITEMS = MAX_FORMULA_ITEMS;
|
|
2371
|
+
exports.MAX_FORMULA_LENGTH = MAX_FORMULA_LENGTH;
|
|
2372
|
+
exports.MAX_FORM_FORMULA_LENGTH = MAX_FORM_FORMULA_LENGTH;
|
|
1551
2373
|
exports.MAX_HIDDEN_STRING_LENGTH = MAX_HIDDEN_STRING_LENGTH;
|
|
1552
2374
|
exports.MAX_HIGHLIGHT_NOTE_LENGTH = MAX_HIGHLIGHT_NOTE_LENGTH;
|
|
1553
2375
|
exports.MAX_HIGHLIGHT_QUOTE_LENGTH = MAX_HIGHLIGHT_QUOTE_LENGTH;
|
|
2376
|
+
exports.MAX_SETTLE_ITEMS = MAX_SETTLE_ITEMS;
|
|
1554
2377
|
exports.MAX_TABLE_CELL_LENGTH = MAX_TABLE_CELL_LENGTH;
|
|
1555
2378
|
exports.MAX_TABLE_COLUMNS = MAX_TABLE_COLUMNS;
|
|
1556
2379
|
exports.MAX_TIMESTAMP_SECONDS = MAX_TIMESTAMP_SECONDS;
|
|
1557
2380
|
exports.REQUIRED_MESSAGE = REQUIRED_MESSAGE;
|
|
1558
2381
|
exports.STRUCTURED_FIELD_TYPES = STRUCTURED_FIELD_TYPES;
|
|
2382
|
+
exports.calculatedProps = calculatedProps;
|
|
2383
|
+
exports.calculatedValue = calculatedValue;
|
|
1559
2384
|
exports.canonicalJson = canonicalJson;
|
|
1560
2385
|
exports.choiceOptions = choiceOptions;
|
|
1561
2386
|
exports.coerceHidden = coerceHidden;
|
|
1562
2387
|
exports.coerceHiddenValue = coerceHiddenValue;
|
|
1563
2388
|
exports.computePath = computePath;
|
|
1564
2389
|
exports.createForm = createForm;
|
|
2390
|
+
exports.dateLimits = dateLimits;
|
|
1565
2391
|
exports.defineForm = defineForm;
|
|
1566
2392
|
exports.describeCondition = describeCondition;
|
|
1567
2393
|
exports.evaluate = evaluate;
|
|
2394
|
+
exports.evaluateFormula = evaluateFormula;
|
|
1568
2395
|
exports.explain = explain;
|
|
1569
2396
|
exports.f = f;
|
|
1570
2397
|
exports.fingerprint = fingerprint;
|
|
2398
|
+
exports.formatCalculated = formatCalculated;
|
|
1571
2399
|
exports.formatTimestamp = formatTimestamp;
|
|
2400
|
+
exports.formulaInput = formulaInput;
|
|
2401
|
+
exports.formulaNumber = formulaNumber;
|
|
2402
|
+
exports.formulaRefs = formulaRefs;
|
|
1572
2403
|
exports.getFieldType = getFieldType;
|
|
1573
2404
|
exports.isEmptyValue = isEmptyValue;
|
|
1574
2405
|
exports.isFormEvent = isFormEvent;
|
|
2406
|
+
exports.isIsoDate = isIsoDate;
|
|
1575
2407
|
exports.isJson = isJson;
|
|
1576
2408
|
exports.isSensitive = isSensitive;
|
|
1577
2409
|
exports.listFieldTypes = listFieldTypes;
|
|
1578
2410
|
exports.listLimits = listLimits;
|
|
1579
2411
|
exports.normalizeValue = normalizeValue;
|
|
2412
|
+
exports.numberLimits = numberLimits;
|
|
1580
2413
|
exports.parseForm = parseForm;
|
|
2414
|
+
exports.parseFormula = parseFormula;
|
|
1581
2415
|
exports.parseTimestamp = parseTimestamp;
|
|
2416
|
+
exports.ratingDisplay = ratingDisplay;
|
|
1582
2417
|
exports.recall = recall;
|
|
1583
2418
|
exports.registerFieldType = registerFieldType;
|
|
2419
|
+
exports.renameFormulaKey = renameFormulaKey;
|
|
1584
2420
|
exports.replay = replay;
|
|
1585
2421
|
exports.ruleVisible = ruleVisible;
|
|
2422
|
+
exports.scaleLabels = scaleLabels;
|
|
1586
2423
|
exports.steps = steps;
|
|
1587
2424
|
exports.summarizeValue = summarizeValue;
|
|
1588
2425
|
exports.tableColumns = tableColumns;
|