@marktiderman/genesis-forms 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -1
- package/dist/destinations.cjs +159 -6
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js +159 -6
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-CPLtKVew.d.cts → engine-BkNuzlna.d.cts} +2 -1
- package/dist/{engine-DOrPgFDw.d.ts → engine-Dz9J5ej2.d.ts} +2 -1
- package/dist/index.cjs +755 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +195 -4
- package/dist/index.d.ts +195 -4
- package/dist/index.js +736 -23
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +669 -18
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +669 -18
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +671 -18
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +671 -18
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -30,6 +30,441 @@ 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
|
+
const v = Number(source.slice(at, i));
|
|
68
|
+
if (!Number.isFinite(v)) throw new FormulaError("This number is too large.", at);
|
|
69
|
+
tokens.push({ t: "num", v, at });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (isIdStart(c)) {
|
|
73
|
+
while (isIdPart(source[i] ?? "")) i++;
|
|
74
|
+
tokens.push({ t: "id", v: source.slice(at, i), at });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (c === '"' || c === "'" || c === "\u201C") {
|
|
78
|
+
const close = c === "\u201C" ? "\u201D" : c;
|
|
79
|
+
const end = source.indexOf(close, i + 1);
|
|
80
|
+
if (end === -1) throw new FormulaError("This text has no closing quote.", at);
|
|
81
|
+
tokens.push({ t: "str", v: source.slice(i + 1, end), at });
|
|
82
|
+
i = end + 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const two = source.slice(i, i + 2);
|
|
86
|
+
if (TWO_CHAR.has(two)) {
|
|
87
|
+
tokens.push({ t: "op", v: two, at });
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ONE_CHAR.has(c)) {
|
|
92
|
+
tokens.push({ t: "op", v: OP_ALIASES[c] ?? c, at });
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
throw new FormulaError(`"${c}" can't be used in a formula.`, at);
|
|
97
|
+
}
|
|
98
|
+
tokens.push({ t: "end", at: source.length });
|
|
99
|
+
return tokens;
|
|
100
|
+
}
|
|
101
|
+
function parseFormula(source) {
|
|
102
|
+
if (typeof source !== "string" || source.trim() === "") return { ok: false, message: "Write a formula.", at: 0 };
|
|
103
|
+
if (source.length > MAX_FORMULA_LENGTH) {
|
|
104
|
+
return { ok: false, message: `Keep the formula under ${MAX_FORMULA_LENGTH} characters.`, at: MAX_FORMULA_LENGTH };
|
|
105
|
+
}
|
|
106
|
+
let tokens;
|
|
107
|
+
try {
|
|
108
|
+
tokens = tokenize(source);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const err = e;
|
|
111
|
+
return { ok: false, message: err.message, at: err.at };
|
|
112
|
+
}
|
|
113
|
+
let pos = 0;
|
|
114
|
+
const refs = [];
|
|
115
|
+
const peek = () => tokens[pos];
|
|
116
|
+
const isOp = (v) => {
|
|
117
|
+
const tok = peek();
|
|
118
|
+
return tok.t === "op" && tok.v === v;
|
|
119
|
+
};
|
|
120
|
+
const expect = (v, message) => {
|
|
121
|
+
if (!isOp(v)) throw new FormulaError(message, peek().at);
|
|
122
|
+
pos++;
|
|
123
|
+
};
|
|
124
|
+
const describe = (tok) => tok.t === "end" ? "the formula ends there" : `found "${String(tok.v)}"`;
|
|
125
|
+
const guard = (depth) => {
|
|
126
|
+
if (depth > MAX_DEPTH) throw new FormulaError("The formula is nested too deeply.", peek().at);
|
|
127
|
+
};
|
|
128
|
+
const ternary = (depth) => {
|
|
129
|
+
guard(depth);
|
|
130
|
+
const test = comparison(depth);
|
|
131
|
+
if (!isOp("?")) return test;
|
|
132
|
+
pos++;
|
|
133
|
+
const then = ternary(depth + 1);
|
|
134
|
+
expect(":", "A `?` needs a `:` and the other value: `test ? a : b`.");
|
|
135
|
+
const otherwise = ternary(depth + 1);
|
|
136
|
+
return { kind: "if", test, then, else: otherwise };
|
|
137
|
+
};
|
|
138
|
+
const comparison = (depth) => {
|
|
139
|
+
const left = additive(depth);
|
|
140
|
+
const tok = peek();
|
|
141
|
+
if (tok.t !== "op" || !COMPARISONS.has(tok.v)) return left;
|
|
142
|
+
pos++;
|
|
143
|
+
const right = additive(depth);
|
|
144
|
+
const next = peek();
|
|
145
|
+
if (next.t === "op" && COMPARISONS.has(next.v)) {
|
|
146
|
+
throw new FormulaError("Compare two values at a time; combine comparisons with `? :`.", next.at);
|
|
147
|
+
}
|
|
148
|
+
return { kind: "binary", op: tok.v, left, right };
|
|
149
|
+
};
|
|
150
|
+
const additive = (depth) => {
|
|
151
|
+
let left = multiplicative(depth);
|
|
152
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "+" || tok.v === "-"); tok = peek()) {
|
|
153
|
+
pos++;
|
|
154
|
+
left = { kind: "binary", op: tok.v, left, right: multiplicative(depth) };
|
|
155
|
+
}
|
|
156
|
+
return left;
|
|
157
|
+
};
|
|
158
|
+
const multiplicative = (depth) => {
|
|
159
|
+
let left = unary(depth);
|
|
160
|
+
for (let tok = peek(); tok.t === "op" && (tok.v === "*" || tok.v === "/"); tok = peek()) {
|
|
161
|
+
pos++;
|
|
162
|
+
left = { kind: "binary", op: tok.v, left, right: unary(depth) };
|
|
163
|
+
}
|
|
164
|
+
return left;
|
|
165
|
+
};
|
|
166
|
+
const unary = (depth) => {
|
|
167
|
+
guard(depth);
|
|
168
|
+
if (isOp("-")) {
|
|
169
|
+
pos++;
|
|
170
|
+
return { kind: "unary", op: "-", arg: unary(depth + 1) };
|
|
171
|
+
}
|
|
172
|
+
if (isOp("+")) {
|
|
173
|
+
pos++;
|
|
174
|
+
return unary(depth + 1);
|
|
175
|
+
}
|
|
176
|
+
return primary(depth);
|
|
177
|
+
};
|
|
178
|
+
const primary = (depth) => {
|
|
179
|
+
const tok = peek();
|
|
180
|
+
if (tok.t === "num") {
|
|
181
|
+
pos++;
|
|
182
|
+
return { kind: "number", value: tok.v };
|
|
183
|
+
}
|
|
184
|
+
if (tok.t === "str") {
|
|
185
|
+
pos++;
|
|
186
|
+
return { kind: "text", value: tok.v };
|
|
187
|
+
}
|
|
188
|
+
if (tok.t === "op" && tok.v === "(") {
|
|
189
|
+
pos++;
|
|
190
|
+
const inner = ternary(depth + 1);
|
|
191
|
+
expect(")", "A `(` has no matching `)`.");
|
|
192
|
+
return inner;
|
|
193
|
+
}
|
|
194
|
+
if (tok.t === "id") {
|
|
195
|
+
pos++;
|
|
196
|
+
if (isOp("(")) {
|
|
197
|
+
if (!FORMULA_FUNCTIONS.includes(tok.v)) {
|
|
198
|
+
throw new FormulaError(`There is no function "${tok.v}"; use ${FORMULA_FUNCTIONS.join(", ")}.`, tok.at);
|
|
199
|
+
}
|
|
200
|
+
pos++;
|
|
201
|
+
const args = [];
|
|
202
|
+
if (!isOp(")")) {
|
|
203
|
+
args.push(ternary(depth + 1));
|
|
204
|
+
while (isOp(",")) {
|
|
205
|
+
pos++;
|
|
206
|
+
args.push(ternary(depth + 1));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
expect(")", `"${tok.v}(" has no matching ")".`);
|
|
210
|
+
if (!args.length) throw new FormulaError(`${tok.v}() needs something to work on.`, tok.at);
|
|
211
|
+
return { kind: "call", fn: tok.v, args };
|
|
212
|
+
}
|
|
213
|
+
if (isOp(".")) {
|
|
214
|
+
pos++;
|
|
215
|
+
const column = peek();
|
|
216
|
+
if (column.t !== "id") throw new FormulaError(`Name a column after "${tok.v}.".`, column.at);
|
|
217
|
+
pos++;
|
|
218
|
+
refs.push({ key: tok.v, column: column.v });
|
|
219
|
+
return { kind: "ref", key: tok.v, column: column.v, at: tok.at };
|
|
220
|
+
}
|
|
221
|
+
refs.push({ key: tok.v });
|
|
222
|
+
return { kind: "ref", key: tok.v, at: tok.at };
|
|
223
|
+
}
|
|
224
|
+
throw new FormulaError(`Expected a number, an answer or "(", but ${describe(tok)}.`, tok.at);
|
|
225
|
+
};
|
|
226
|
+
try {
|
|
227
|
+
const ast = ternary(0);
|
|
228
|
+
const rest = peek();
|
|
229
|
+
if (rest.t !== "end") throw new FormulaError(`Expected an operator such as + or *, but ${describe(rest)}.`, rest.at);
|
|
230
|
+
return { ok: true, ast, refs };
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e instanceof FormulaError) return { ok: false, message: e.message, at: e.at };
|
|
233
|
+
return { ok: false, message: "The formula is nested too deeply.", at: 0 };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function formulaRefs(source) {
|
|
237
|
+
const parsed = parseFormula(source);
|
|
238
|
+
if (!parsed.ok) return [];
|
|
239
|
+
const seen = /* @__PURE__ */ new Set();
|
|
240
|
+
return parsed.refs.filter((r) => {
|
|
241
|
+
const id = `${r.key}\0${r.column ?? ""}`;
|
|
242
|
+
if (seen.has(id)) return false;
|
|
243
|
+
seen.add(id);
|
|
244
|
+
return true;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function renameFormulaKey(source, from, to) {
|
|
248
|
+
let tokens;
|
|
249
|
+
try {
|
|
250
|
+
tokens = tokenize(source);
|
|
251
|
+
} catch {
|
|
252
|
+
return source;
|
|
253
|
+
}
|
|
254
|
+
let out = "";
|
|
255
|
+
let last = 0;
|
|
256
|
+
tokens.forEach((tok, i) => {
|
|
257
|
+
if (tok.t !== "id" || tok.v !== from) return;
|
|
258
|
+
const prev = tokens[i - 1];
|
|
259
|
+
const next = tokens[i + 1];
|
|
260
|
+
if (prev?.t === "op" && prev.v === ".") return;
|
|
261
|
+
if (next?.t === "op" && next.v === "(") return;
|
|
262
|
+
out += source.slice(last, tok.at) + to;
|
|
263
|
+
last = tok.at + from.length;
|
|
264
|
+
});
|
|
265
|
+
return out + source.slice(last);
|
|
266
|
+
}
|
|
267
|
+
var NUMERIC = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
268
|
+
function formulaNumber(v) {
|
|
269
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
270
|
+
if (typeof v === "boolean") return v ? 1 : 0;
|
|
271
|
+
if (typeof v === "string") {
|
|
272
|
+
const t = v.trim().replace(/,/g, "");
|
|
273
|
+
return t.length <= 64 && NUMERIC.test(t) ? finite(Number(t)) : null;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
var finite = (n) => Number.isFinite(n) ? n : null;
|
|
278
|
+
var scalar = (v) => Array.isArray(v) ? null : v;
|
|
279
|
+
function truthy(v) {
|
|
280
|
+
if (v === null) return false;
|
|
281
|
+
if (typeof v === "boolean") return v;
|
|
282
|
+
if (typeof v === "number") return v !== 0;
|
|
283
|
+
if (typeof v === "string") return v.trim() !== "";
|
|
284
|
+
return v.length > 0;
|
|
285
|
+
}
|
|
286
|
+
function numbersIn(values, out = []) {
|
|
287
|
+
for (const v of values) {
|
|
288
|
+
if (Array.isArray(v)) numbersIn(v, out);
|
|
289
|
+
else {
|
|
290
|
+
const n = formulaNumber(v);
|
|
291
|
+
if (n !== null) out.push(n);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
function compare(op, a, b) {
|
|
297
|
+
const blank2 = (v) => v === null || Array.isArray(v) || typeof v === "string" && v.trim() === "";
|
|
298
|
+
if (blank2(a) || blank2(b)) return op === "!=";
|
|
299
|
+
const x = formulaNumber(a);
|
|
300
|
+
const y = formulaNumber(b);
|
|
301
|
+
if (x !== null && y !== null) {
|
|
302
|
+
if (op === "==") return x === y;
|
|
303
|
+
if (op === "!=") return x !== y;
|
|
304
|
+
if (op === "<") return x < y;
|
|
305
|
+
if (op === "<=") return x <= y;
|
|
306
|
+
if (op === ">") return x > y;
|
|
307
|
+
return x >= y;
|
|
308
|
+
}
|
|
309
|
+
if (op === "==") return String(a) === String(b);
|
|
310
|
+
if (op === "!=") return String(a) !== String(b);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
function evaluateFormula(ast, read) {
|
|
314
|
+
const run = (node) => {
|
|
315
|
+
switch (node.kind) {
|
|
316
|
+
case "number":
|
|
317
|
+
case "text":
|
|
318
|
+
return node.value;
|
|
319
|
+
case "ref":
|
|
320
|
+
return read(node.column !== void 0 ? { key: node.key, column: node.column } : { key: node.key });
|
|
321
|
+
case "unary": {
|
|
322
|
+
const n = formulaNumber(scalar(run(node.arg)));
|
|
323
|
+
return n === null ? null : -n;
|
|
324
|
+
}
|
|
325
|
+
case "if":
|
|
326
|
+
return truthy(run(node.test)) ? run(node.then) : run(node.else);
|
|
327
|
+
case "call": {
|
|
328
|
+
const nums = numbersIn(node.args.map(run));
|
|
329
|
+
const total = nums.reduce((a, b) => a + b, 0);
|
|
330
|
+
if (node.fn === "sum") return finite(total);
|
|
331
|
+
if (!nums.length) return null;
|
|
332
|
+
if (node.fn === "avg") return finite(total / nums.length);
|
|
333
|
+
return nums.reduce((a, b) => node.fn === "min" ? Math.min(a, b) : Math.max(a, b));
|
|
334
|
+
}
|
|
335
|
+
case "binary": {
|
|
336
|
+
const a = run(node.left);
|
|
337
|
+
const b = run(node.right);
|
|
338
|
+
if (node.op === "+" || node.op === "-" || node.op === "*" || node.op === "/") {
|
|
339
|
+
const x = formulaNumber(scalar(a));
|
|
340
|
+
const y = formulaNumber(scalar(b));
|
|
341
|
+
if (x === null || y === null) return null;
|
|
342
|
+
if (node.op === "+") return finite(x + y);
|
|
343
|
+
if (node.op === "-") return finite(x - y);
|
|
344
|
+
if (node.op === "*") return finite(x * y);
|
|
345
|
+
return y === 0 ? null : finite(x / y);
|
|
346
|
+
}
|
|
347
|
+
return compare(node.op, a, b);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
return run(ast);
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/calculated.ts
|
|
359
|
+
var CALCULATED_TYPE = "calculated";
|
|
360
|
+
var COMPUTED_FIELD_TYPES = [CALCULATED_TYPE];
|
|
361
|
+
var MAX_CALCULATED_DECIMALS = 10;
|
|
362
|
+
function calculatedProps(field2) {
|
|
363
|
+
const p = field2.props ?? {};
|
|
364
|
+
const out = { formula: typeof p.formula === "string" ? p.formula : "" };
|
|
365
|
+
if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
|
|
366
|
+
if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
|
|
367
|
+
out.decimals = p.decimals;
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
var isCalculated = (field2) => field2?.type === CALCULATED_TYPE;
|
|
372
|
+
var round = (n, decimals) => {
|
|
373
|
+
if (decimals === void 0) return n;
|
|
374
|
+
const r = Number(n.toFixed(decimals));
|
|
375
|
+
return Object.is(r, -0) ? 0 : r;
|
|
376
|
+
};
|
|
377
|
+
function calculatedValue(result, field2) {
|
|
378
|
+
const { format, decimals } = calculatedProps(field2);
|
|
379
|
+
if (result === null || Array.isArray(result)) return void 0;
|
|
380
|
+
if (format === "number" || format === "percent") {
|
|
381
|
+
const n = formulaNumber(result);
|
|
382
|
+
return n === null ? void 0 : round(n, decimals);
|
|
383
|
+
}
|
|
384
|
+
if (format === "text") {
|
|
385
|
+
if (typeof result === "number") return decimals === void 0 ? String(result) : result.toFixed(decimals);
|
|
386
|
+
return typeof result === "boolean" ? result ? "Yes" : "No" : result;
|
|
387
|
+
}
|
|
388
|
+
return typeof result === "number" ? round(result, decimals) : result;
|
|
389
|
+
}
|
|
390
|
+
function formatCalculated(value, field2) {
|
|
391
|
+
if (value === void 0 || value === null) return "";
|
|
392
|
+
const { format, decimals } = calculatedProps(field2);
|
|
393
|
+
const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
|
|
394
|
+
if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
|
|
395
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
396
|
+
return typeof value === "string" ? value : "";
|
|
397
|
+
}
|
|
398
|
+
var MAX_FORMULA_ITEMS = 1e4;
|
|
399
|
+
function formulaInput(value, column) {
|
|
400
|
+
if (value === void 0 || value === null) return null;
|
|
401
|
+
if (Array.isArray(value) && value.length > MAX_FORMULA_ITEMS) return null;
|
|
402
|
+
if (column !== void 0) {
|
|
403
|
+
if (!Array.isArray(value)) return null;
|
|
404
|
+
return value.flatMap((row) => {
|
|
405
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return [];
|
|
406
|
+
const cell = row[column];
|
|
407
|
+
return typeof cell === "string" || typeof cell === "number" ? [cell] : [];
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (Array.isArray(value)) {
|
|
411
|
+
return value.flatMap((v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? [v] : []);
|
|
412
|
+
}
|
|
413
|
+
return typeof value === "object" ? null : value;
|
|
414
|
+
}
|
|
415
|
+
var UNREAD = /* @__PURE__ */ Symbol("unread");
|
|
416
|
+
function calculator(definition) {
|
|
417
|
+
const compiled = [];
|
|
418
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
419
|
+
for (const block of definition.blocks) {
|
|
420
|
+
if (block.kind !== "field") continue;
|
|
421
|
+
idByKey.set(block.key, block.id);
|
|
422
|
+
}
|
|
423
|
+
for (const block of definition.blocks) {
|
|
424
|
+
if (block.kind !== "field" || !isCalculated(block)) continue;
|
|
425
|
+
const parsed = parseFormula(calculatedProps(block).formula);
|
|
426
|
+
const reads = parsed.ok ? parsed.refs.map((r) => idByKey.get(r.key)) : [];
|
|
427
|
+
compiled.push({ field: block, ast: parsed.ok ? parsed.ast : null, reads, last: null });
|
|
428
|
+
}
|
|
429
|
+
const withCalculated = (values, path) => {
|
|
430
|
+
if (!compiled.length) return values;
|
|
431
|
+
const visible = new Set(path);
|
|
432
|
+
let next = values;
|
|
433
|
+
const input = (id) => id === void 0 || !visible.has(id) || !Object.prototype.hasOwnProperty.call(next, id) ? UNREAD : next[id];
|
|
434
|
+
for (const entry of compiled) {
|
|
435
|
+
const { field: field2, ast } = entry;
|
|
436
|
+
const inputs = entry.reads.map(input);
|
|
437
|
+
let value;
|
|
438
|
+
if (entry.last && entry.last.inputs.length === inputs.length && entry.last.inputs.every((v, i) => v === inputs[i])) {
|
|
439
|
+
value = entry.last.value;
|
|
440
|
+
} else {
|
|
441
|
+
const read = (ref) => {
|
|
442
|
+
const raw = input(idByKey.get(ref.key));
|
|
443
|
+
return raw === UNREAD ? null : formulaInput(raw, ref.column);
|
|
444
|
+
};
|
|
445
|
+
value = ast ? calculatedValue(evaluateFormula(ast, read), field2) : void 0;
|
|
446
|
+
entry.last = { inputs, value };
|
|
447
|
+
}
|
|
448
|
+
const current = Object.prototype.hasOwnProperty.call(next, field2.id) ? next[field2.id] : void 0;
|
|
449
|
+
if (current !== value) next = { ...next, [field2.id]: value };
|
|
450
|
+
}
|
|
451
|
+
return next;
|
|
452
|
+
};
|
|
453
|
+
return { active: compiled.length > 0, withCalculated };
|
|
454
|
+
}
|
|
455
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
456
|
+
var calculatedFieldType = {
|
|
457
|
+
type: CALCULATED_TYPE,
|
|
458
|
+
label: "Calculated",
|
|
459
|
+
category: "advanced",
|
|
460
|
+
value: { json: true },
|
|
461
|
+
operators: numberOps,
|
|
462
|
+
a11y: { semantics: "control" },
|
|
463
|
+
// The engine works the value out; any number, text or true / false it made is valid.
|
|
464
|
+
validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
|
|
465
|
+
summarize: (value, field2) => formatCalculated(value, field2)
|
|
466
|
+
};
|
|
467
|
+
|
|
33
468
|
// src/events.ts
|
|
34
469
|
function isPlainObject(value) {
|
|
35
470
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -324,7 +759,7 @@ function intRange(field2, min, maxDefault) {
|
|
|
324
759
|
const max = typeof field2.props?.max === "number" ? field2.props.max : maxDefault;
|
|
325
760
|
return { min, max };
|
|
326
761
|
}
|
|
327
|
-
var
|
|
762
|
+
var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
328
763
|
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
329
764
|
function maxLength(field2, fallback) {
|
|
330
765
|
return typeof field2.props?.maxLength === "number" ? field2.props.maxLength : fallback;
|
|
@@ -402,23 +837,30 @@ var builtins = [
|
|
|
402
837
|
label: "Rating",
|
|
403
838
|
category: "scale",
|
|
404
839
|
value: { json: true },
|
|
405
|
-
operators:
|
|
840
|
+
operators: numberOps2,
|
|
406
841
|
a11y: { semantics: "group" },
|
|
407
842
|
validate: (value, field2) => {
|
|
408
843
|
const { min, max } = intRange(field2, 1, 5);
|
|
409
844
|
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
410
845
|
},
|
|
411
|
-
summarize: (value, field2) =>
|
|
846
|
+
summarize: (value, field2) => {
|
|
847
|
+
const { max } = intRange(field2, 1, 5);
|
|
848
|
+
const end = scaleEndLabel(field2, value, 1, max);
|
|
849
|
+
return end ? `${value}/${max} (${end})` : `${value}/${max}`;
|
|
850
|
+
}
|
|
412
851
|
},
|
|
413
852
|
{
|
|
414
853
|
type: "nps",
|
|
415
854
|
label: "Net Promoter Score",
|
|
416
855
|
category: "scale",
|
|
417
856
|
value: { json: true },
|
|
418
|
-
operators:
|
|
857
|
+
operators: numberOps2,
|
|
419
858
|
a11y: { semantics: "group" },
|
|
420
859
|
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
421
|
-
summarize: (value) =>
|
|
860
|
+
summarize: (value, field2) => {
|
|
861
|
+
const end = scaleEndLabel(field2, value, 0, 10);
|
|
862
|
+
return end ? `${value}/10 (${end})` : `${value}/10`;
|
|
863
|
+
}
|
|
422
864
|
},
|
|
423
865
|
{
|
|
424
866
|
type: "single-choice",
|
|
@@ -452,9 +894,125 @@ var builtins = [
|
|
|
452
894
|
a11y: { semantics: "group" },
|
|
453
895
|
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
454
896
|
summarize: (value) => value ? "Yes" : "No"
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
type: "dropdown",
|
|
900
|
+
label: "Dropdown",
|
|
901
|
+
category: "choice",
|
|
902
|
+
value: { json: true },
|
|
903
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
904
|
+
a11y: { semantics: "control" },
|
|
905
|
+
validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
906
|
+
summarize: (value, field2) => optionLabel(field2, String(value))
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
type: "number",
|
|
910
|
+
label: "Number",
|
|
911
|
+
category: "text",
|
|
912
|
+
value: { json: true },
|
|
913
|
+
operators: numberOps2,
|
|
914
|
+
a11y: { semantics: "control", inputMode: "decimal" },
|
|
915
|
+
validate: (value, field2) => {
|
|
916
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
|
|
917
|
+
const { min, max, step } = numberLimits(field2);
|
|
918
|
+
if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
|
|
919
|
+
return `Enter a number from ${min} to ${max}.`;
|
|
920
|
+
}
|
|
921
|
+
if (min !== void 0 && value < min) return `Enter ${min} or more.`;
|
|
922
|
+
if (max !== void 0 && value > max) return `Enter ${max} or less.`;
|
|
923
|
+
if (step !== void 0 && !onStep(value, min ?? 0, step)) {
|
|
924
|
+
return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
|
|
925
|
+
}
|
|
926
|
+
return null;
|
|
927
|
+
},
|
|
928
|
+
summarize: (value) => String(value),
|
|
929
|
+
samples: (field2) => {
|
|
930
|
+
const { min, max } = numberLimits(field2);
|
|
931
|
+
return [min, max, 0, 1].filter((v) => v !== void 0);
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
type: "date",
|
|
936
|
+
label: "Date",
|
|
937
|
+
category: "date",
|
|
938
|
+
value: { json: true },
|
|
939
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
940
|
+
a11y: { semantics: "control" },
|
|
941
|
+
validate: (value, field2) => {
|
|
942
|
+
if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
|
|
943
|
+
const date = value.trim();
|
|
944
|
+
const { min, max } = dateLimits(field2);
|
|
945
|
+
if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
|
|
946
|
+
if (min && date < min) return `Choose ${min} or later.`;
|
|
947
|
+
if (max && date > max) return `Choose ${max} or earlier.`;
|
|
948
|
+
return null;
|
|
949
|
+
},
|
|
950
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
951
|
+
samples: (field2) => {
|
|
952
|
+
const { min, max } = dateLimits(field2);
|
|
953
|
+
return [min ?? max ?? "2000-01-01"];
|
|
954
|
+
}
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
type: "consent",
|
|
958
|
+
label: "Consent checkbox",
|
|
959
|
+
category: "choice",
|
|
960
|
+
value: { json: true },
|
|
961
|
+
operators: ["answered", "empty"],
|
|
962
|
+
a11y: { semantics: "group" },
|
|
963
|
+
// Unchecked is unanswered, so a required consent must be checked.
|
|
964
|
+
isEmpty: (value) => value === void 0 || value === null || value === false,
|
|
965
|
+
validate: (value) => value === true ? null : "Check the box to agree.",
|
|
966
|
+
summarize: () => "Agreed",
|
|
967
|
+
samples: () => [true]
|
|
455
968
|
}
|
|
456
969
|
];
|
|
457
|
-
|
|
970
|
+
function scaleLabels(field2) {
|
|
971
|
+
const out = {};
|
|
972
|
+
const low = field2.props?.lowLabel;
|
|
973
|
+
const high = field2.props?.highLabel;
|
|
974
|
+
if (typeof low === "string" && low.trim()) out.low = low.trim();
|
|
975
|
+
if (typeof high === "string" && high.trim()) out.high = high.trim();
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
function ratingDisplay(field2) {
|
|
979
|
+
return field2.props?.display === "stars" ? "stars" : "numbers";
|
|
980
|
+
}
|
|
981
|
+
function scaleEndLabel(field2, value, min, max) {
|
|
982
|
+
const { low, high } = scaleLabels(field2);
|
|
983
|
+
return value === min ? low : value === max ? high : void 0;
|
|
984
|
+
}
|
|
985
|
+
var finite2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
986
|
+
function numberLimits(field2) {
|
|
987
|
+
const out = {};
|
|
988
|
+
const min = finite2(field2.props?.min);
|
|
989
|
+
const max = finite2(field2.props?.max);
|
|
990
|
+
const step = finite2(field2.props?.step);
|
|
991
|
+
if (min !== void 0) out.min = min;
|
|
992
|
+
if (max !== void 0) out.max = max;
|
|
993
|
+
if (step !== void 0 && step > 0) out.step = step;
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
function onStep(value, base, step) {
|
|
997
|
+
const n = (value - base) / step;
|
|
998
|
+
return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
|
|
999
|
+
}
|
|
1000
|
+
function isIsoDate(value) {
|
|
1001
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
1002
|
+
const [y, m, d] = value.split("-").map(Number);
|
|
1003
|
+
if (y < 1 || m < 1 || m > 12 || d < 1) return false;
|
|
1004
|
+
const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
1005
|
+
return d <= days[m - 1];
|
|
1006
|
+
}
|
|
1007
|
+
function dateLimits(field2) {
|
|
1008
|
+
const out = {};
|
|
1009
|
+
const min = field2.props?.min;
|
|
1010
|
+
const max = field2.props?.max;
|
|
1011
|
+
if (typeof min === "string" && isIsoDate(min)) out.min = min;
|
|
1012
|
+
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
1013
|
+
return out;
|
|
1014
|
+
}
|
|
1015
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
458
1016
|
|
|
459
1017
|
// src/logic.ts
|
|
460
1018
|
function own(record, key) {
|
|
@@ -626,8 +1184,15 @@ var LITERAL_TYPE = {
|
|
|
626
1184
|
"single-choice": "string",
|
|
627
1185
|
"multi-choice": "string",
|
|
628
1186
|
"yes-no": "boolean",
|
|
629
|
-
list: "string"
|
|
1187
|
+
list: "string",
|
|
1188
|
+
dropdown: "string",
|
|
1189
|
+
number: "number",
|
|
1190
|
+
date: "string",
|
|
1191
|
+
consent: "boolean",
|
|
1192
|
+
// A calculated field holds a number unless its format says text; see `literalType`.
|
|
1193
|
+
calculated: "number"
|
|
630
1194
|
};
|
|
1195
|
+
var CHOICE_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
|
|
631
1196
|
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
632
1197
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
633
1198
|
"eq",
|
|
@@ -707,7 +1272,44 @@ function parseForm(input) {
|
|
|
707
1272
|
} else if (type === "text-highlights") {
|
|
708
1273
|
optionalText(props2.text, `${path}.text`);
|
|
709
1274
|
optionalInt(props2.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1275
|
+
} else if (type === CALCULATED_TYPE) {
|
|
1276
|
+
if (props2.format !== void 0 && props2.format !== "number" && props2.format !== "percent" && props2.format !== "text") {
|
|
1277
|
+
issue(`${path}.format`, "Must be number, percent or text.");
|
|
1278
|
+
}
|
|
1279
|
+
if (props2.decimals !== void 0 && !(isInt(props2.decimals) && props2.decimals >= 0 && props2.decimals <= MAX_CALCULATED_DECIMALS)) {
|
|
1280
|
+
issue(`${path}.decimals`, `Must be a whole number from 0 to ${MAX_CALCULATED_DECIMALS}.`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
1285
|
+
const fieldProps = (type, props2, path) => {
|
|
1286
|
+
if (type === "rating" || type === "nps") {
|
|
1287
|
+
optionalText(props2.lowLabel, `${path}.lowLabel`);
|
|
1288
|
+
optionalText(props2.highLabel, `${path}.highLabel`);
|
|
1289
|
+
}
|
|
1290
|
+
if (type === "rating" && props2.display !== void 0 && props2.display !== "numbers" && props2.display !== "stars") {
|
|
1291
|
+
issue(`${path}.display`, "Must be numbers or stars.");
|
|
1292
|
+
}
|
|
1293
|
+
if (type === "number") {
|
|
1294
|
+
for (const k of ["min", "max", "step"]) {
|
|
1295
|
+
if (props2[k] !== void 0 && !isFiniteNumber(props2[k])) issue(`${path}.${k}`, "Must be a number.");
|
|
1296
|
+
}
|
|
1297
|
+
if (isFiniteNumber(props2.step) && props2.step <= 0) issue(`${path}.step`, "Must be above 0.");
|
|
1298
|
+
if (isFiniteNumber(props2.min) && isFiniteNumber(props2.max) && props2.min > props2.max) {
|
|
1299
|
+
issue(`${path}.min`, "Can't be more than the largest number.");
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (type === "date") {
|
|
1303
|
+
for (const k of ["min", "max"]) {
|
|
1304
|
+
if (props2[k] !== void 0 && !(typeof props2[k] === "string" && isIsoDate(props2[k]))) {
|
|
1305
|
+
issue(`${path}.${k}`, "Must be a date like 2026-10-22.");
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
if (typeof props2.min === "string" && typeof props2.max === "string" && isIsoDate(props2.min) && isIsoDate(props2.max) && props2.min > props2.max) {
|
|
1309
|
+
issue(`${path}.min`, "Can't be after the latest date.");
|
|
1310
|
+
}
|
|
710
1311
|
}
|
|
1312
|
+
if (type === "consent") optionalText(props2.text, `${path}.text`);
|
|
711
1313
|
};
|
|
712
1314
|
if (!isObject2(input)) {
|
|
713
1315
|
issue("", "A form definition must be an object.");
|
|
@@ -742,7 +1344,9 @@ function parseForm(input) {
|
|
|
742
1344
|
const keys = /* @__PURE__ */ new Map();
|
|
743
1345
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
744
1346
|
const order = /* @__PURE__ */ new Map();
|
|
1347
|
+
const tableColumnKeys = /* @__PURE__ */ new Map();
|
|
745
1348
|
const choices = /* @__PURE__ */ new Map();
|
|
1349
|
+
const calculatedLiteral = /* @__PURE__ */ new Map();
|
|
746
1350
|
input.blocks.forEach((block, i) => {
|
|
747
1351
|
const path = `blocks[${i}]`;
|
|
748
1352
|
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
@@ -792,12 +1396,24 @@ function parseForm(input) {
|
|
|
792
1396
|
}
|
|
793
1397
|
if (typeof block.type === "string" && (block.props === void 0 || isObject2(block.props))) {
|
|
794
1398
|
structuredProps(block.type, block.props ?? {}, `${path}.props`);
|
|
1399
|
+
fieldProps(block.type, block.props ?? {}, `${path}.props`);
|
|
795
1400
|
}
|
|
796
1401
|
if (validKey) {
|
|
797
1402
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
798
1403
|
keys.set(block.key, block.id);
|
|
799
1404
|
}
|
|
800
1405
|
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
1406
|
+
if (block.type === CALCULATED_TYPE) {
|
|
1407
|
+
const format = isObject2(block.props) ? block.props.format : void 0;
|
|
1408
|
+
if (format === "text") calculatedLiteral.set(block.id, "string");
|
|
1409
|
+
else if (format !== "number" && format !== "percent") calculatedLiteral.set(block.id, void 0);
|
|
1410
|
+
}
|
|
1411
|
+
if (block.type === "table" && isObject2(block.props) && Array.isArray(block.props.columns)) {
|
|
1412
|
+
tableColumnKeys.set(
|
|
1413
|
+
block.id,
|
|
1414
|
+
new Set(block.props.columns.flatMap((c) => isObject2(c) && typeof c.key === "string" ? [c.key] : []))
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
801
1417
|
} else if (block.kind === "content") {
|
|
802
1418
|
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
803
1419
|
text(block.text, `${path}.text`);
|
|
@@ -807,6 +1423,38 @@ function parseForm(input) {
|
|
|
807
1423
|
issue(`${path}.kind`, "Unknown block kind.");
|
|
808
1424
|
}
|
|
809
1425
|
});
|
|
1426
|
+
input.blocks.forEach((block, i) => {
|
|
1427
|
+
if (!isObject2(block) || block.kind !== "field" || block.type !== CALCULATED_TYPE) return;
|
|
1428
|
+
const path = `blocks[${i}].props.formula`;
|
|
1429
|
+
if (block.required === true) {
|
|
1430
|
+
warnings.push({ path: `blocks[${i}].required`, message: "A calculated field is worked out, not answered, so it is never required." });
|
|
1431
|
+
}
|
|
1432
|
+
const formula = isObject2(block.props) ? block.props.formula : void 0;
|
|
1433
|
+
if (typeof formula !== "string" || !formula.trim()) return issue(path, "A calculated field needs a formula.");
|
|
1434
|
+
const parsed = parseFormula(formula);
|
|
1435
|
+
if (!parsed.ok) return issue(path, `${parsed.message} (at character ${parsed.at + 1})`);
|
|
1436
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1437
|
+
for (const ref of parsed.refs) {
|
|
1438
|
+
const name2 = ref.column === void 0 ? ref.key : `${ref.key}.${ref.column}`;
|
|
1439
|
+
if (reported.has(name2)) continue;
|
|
1440
|
+
reported.add(name2);
|
|
1441
|
+
const target = keys.get(ref.key);
|
|
1442
|
+
const targetType = target === void 0 ? void 0 : fieldTypes.get(target);
|
|
1443
|
+
const at = target === void 0 ? void 0 : order.get(target);
|
|
1444
|
+
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.`);
|
|
1445
|
+
else if (target === block.id) issue(path, "The formula uses this field's own value.");
|
|
1446
|
+
else if (targetType === CALCULATED_TYPE && (at ?? 0) > i) {
|
|
1447
|
+
issue(path, `The formula uses "${ref.key}", a calculated field that comes after this one; move it earlier.`);
|
|
1448
|
+
} else if (targetType === "table") {
|
|
1449
|
+
const columns = tableColumnKeys.get(target);
|
|
1450
|
+
if (ref.column === void 0) issue(path, `"${ref.key}" is a table: name one of its columns, like ${ref.key}.${[...columns ?? []][0] ?? "column"}.`);
|
|
1451
|
+
else if (columns && !columns.has(ref.column)) issue(path, `The table "${ref.key}" has no column "${ref.column}".`);
|
|
1452
|
+
} else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
|
|
1453
|
+
else if (targetType === "media-notes" || targetType === "text-highlights") {
|
|
1454
|
+
warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
810
1458
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
811
1459
|
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
812
1460
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
@@ -890,8 +1538,8 @@ function parseForm(input) {
|
|
|
890
1538
|
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
891
1539
|
const fieldType = fieldTypes.get(left.field);
|
|
892
1540
|
what = `"${keyOf(left.field)}"`;
|
|
893
|
-
type = LITERAL_TYPE[fieldType];
|
|
894
|
-
if (fieldType
|
|
1541
|
+
type = calculatedLiteral.has(left.field) ? calculatedLiteral.get(left.field) : LITERAL_TYPE[fieldType];
|
|
1542
|
+
if (CHOICE_TYPES.has(fieldType)) options = choices.get(left.field);
|
|
895
1543
|
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
896
1544
|
what = `hidden input "${left.hidden}"`;
|
|
897
1545
|
type = hiddenTypes.get(left.hidden);
|
|
@@ -1037,11 +1685,33 @@ var f = {
|
|
|
1037
1685
|
longText: (key, o) => field("long-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
|
|
1038
1686
|
email: (key, o) => field("email", key, o),
|
|
1039
1687
|
phone: (key, o) => field("phone", key, o),
|
|
1040
|
-
|
|
1041
|
-
|
|
1688
|
+
/**
|
|
1689
|
+
* A 1-to-`max` scale. `lowLabel` and `highLabel` are optional words under
|
|
1690
|
+
* its ends ("Poor", "Great"); `display: "stars"` draws stars instead of numbers.
|
|
1691
|
+
*/
|
|
1692
|
+
rating: (key, o) => field(
|
|
1693
|
+
"rating",
|
|
1694
|
+
key,
|
|
1695
|
+
o,
|
|
1696
|
+
props({ max: o.max ?? 5, lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0, display: o.display })
|
|
1697
|
+
),
|
|
1698
|
+
/** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
|
|
1699
|
+
nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
|
|
1042
1700
|
singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
|
|
1043
1701
|
multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
|
|
1044
1702
|
yesNo: (key, o) => field("yes-no", key, o),
|
|
1703
|
+
/** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
|
|
1704
|
+
dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
|
|
1705
|
+
/** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
|
|
1706
|
+
number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
|
|
1707
|
+
/** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
|
|
1708
|
+
date: (key, o) => field("date", key, o, props({ min: o.min, max: o.max })),
|
|
1709
|
+
/**
|
|
1710
|
+
* A single checkbox to agree to something. `text` is the checkbox's own
|
|
1711
|
+
* words ("I agree to the terms"); the renderer defaults to "I agree".
|
|
1712
|
+
* Checked stores `true`; unchecked is unanswered, so `required` means it must be checked.
|
|
1713
|
+
*/
|
|
1714
|
+
consent: (key, o) => field("consent", key, o, props({ text: o.text || void 0 })),
|
|
1045
1715
|
/** Several short answers, one per line: goals, action items, three things that went well. */
|
|
1046
1716
|
list: (key, o) => field(
|
|
1047
1717
|
"list",
|
|
@@ -1049,6 +1719,12 @@ var f = {
|
|
|
1049
1719
|
o,
|
|
1050
1720
|
props({ min: o.min, max: o.max, itemMaxLength: o.itemMaxLength, placeholder: o.itemPlaceholder })
|
|
1051
1721
|
),
|
|
1722
|
+
/**
|
|
1723
|
+
* A value worked out from other answers and shown read-only, such as a
|
|
1724
|
+
* total or a score: `formula` names answers by key (`hours * rate`,
|
|
1725
|
+
* `sum(plan.cost)`). Stored with the answers; never required.
|
|
1726
|
+
*/
|
|
1727
|
+
calculated: (key, o) => field("calculated", key, o, props({ formula: o.formula, format: o.format, decimals: o.decimals })),
|
|
1052
1728
|
/**
|
|
1053
1729
|
* A small table each answer fills row by row, one answer per column: an
|
|
1054
1730
|
* action plan (What / Who / By when), pros and cons.
|
|
@@ -1108,9 +1784,9 @@ function effects(fromKey, condition) {
|
|
|
1108
1784
|
})
|
|
1109
1785
|
};
|
|
1110
1786
|
}
|
|
1111
|
-
var
|
|
1787
|
+
var compare2 = (key, op, value) => (r) => value === void 0 ? { op, left: { field: r(key) } } : { op, left: { field: r(key) }, right: { value } };
|
|
1112
1788
|
function when(key) {
|
|
1113
|
-
const on = (op, value) => effects(key,
|
|
1789
|
+
const on = (op, value) => effects(key, compare2(key, op, value));
|
|
1114
1790
|
return {
|
|
1115
1791
|
is: (value) => on("eq", value),
|
|
1116
1792
|
isNot: (value) => on("neq", value),
|
|
@@ -1238,7 +1914,7 @@ function runCustom(validator, value) {
|
|
|
1238
1914
|
}
|
|
1239
1915
|
function validateField(field2, value, options = {}) {
|
|
1240
1916
|
if (isEmptyValue(value, field2)) {
|
|
1241
|
-
return field2.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1917
|
+
return field2.required && field2.type !== CALCULATED_TYPE && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
1242
1918
|
}
|
|
1243
1919
|
const manifest = getFieldType(field2.type);
|
|
1244
1920
|
if (!manifest) return `Unknown field type "${field2.type}".`;
|
|
@@ -1269,9 +1945,21 @@ function build(definition, options, replaying = false) {
|
|
|
1269
1945
|
if (!declared.has(name)) initialHidden[name] = value;
|
|
1270
1946
|
}
|
|
1271
1947
|
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
1272
|
-
const
|
|
1948
|
+
const calc = calculator(definition);
|
|
1949
|
+
const settle = (values, hidden) => {
|
|
1950
|
+
let { path } = computePath(definition, scopeOf(values, hidden));
|
|
1951
|
+
if (!calc.active) return { values, path };
|
|
1952
|
+
for (let round2 = 0; round2 < 4; round2++) {
|
|
1953
|
+
const next = calc.withCalculated(values, path);
|
|
1954
|
+
if (next === values) break;
|
|
1955
|
+
values = next;
|
|
1956
|
+
path = computePath(definition, scopeOf(values, hidden)).path;
|
|
1957
|
+
}
|
|
1958
|
+
return { values, path };
|
|
1959
|
+
};
|
|
1960
|
+
const first = settle({}, initialHidden);
|
|
1273
1961
|
let state = {
|
|
1274
|
-
values:
|
|
1962
|
+
values: first.values,
|
|
1275
1963
|
hidden: initialHidden,
|
|
1276
1964
|
path: first.path,
|
|
1277
1965
|
current: first.path[0] ?? null,
|
|
@@ -1306,12 +1994,25 @@ function build(definition, options, replaying = false) {
|
|
|
1306
1994
|
}
|
|
1307
1995
|
return errors;
|
|
1308
1996
|
};
|
|
1997
|
+
let unsettled = false;
|
|
1998
|
+
const settleNow = () => {
|
|
1999
|
+
if (!unsettled) return;
|
|
2000
|
+
unsettled = false;
|
|
2001
|
+
const { values, path } = settle(state.values, state.hidden);
|
|
2002
|
+
state = { ...state, values, path };
|
|
2003
|
+
};
|
|
1309
2004
|
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));
|
|
2005
|
+
if (state.status === "completed" || own(state.values, id) === value || isCalculated(fields.get(id))) return;
|
|
1313
2006
|
const errors = { ...state.errors };
|
|
1314
2007
|
delete errors[id];
|
|
2008
|
+
if (batch) {
|
|
2009
|
+
state.values = { ...state.values, [id]: value };
|
|
2010
|
+
state.errors = errors;
|
|
2011
|
+
log(event);
|
|
2012
|
+
unsettled = true;
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
|
|
1315
2016
|
emit({ ...state, values, path, errors, events: log(event) });
|
|
1316
2017
|
};
|
|
1317
2018
|
const engine = {
|
|
@@ -1383,10 +2084,14 @@ function build(definition, options, replaying = false) {
|
|
|
1383
2084
|
}
|
|
1384
2085
|
};
|
|
1385
2086
|
if (options.initialValues) {
|
|
2087
|
+
const wasBatch = batch;
|
|
2088
|
+
batch = true;
|
|
1386
2089
|
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
1387
2090
|
if (!fields.has(id) || value === void 0) continue;
|
|
1388
2091
|
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
1389
2092
|
}
|
|
2093
|
+
settleNow();
|
|
2094
|
+
batch = wasBatch;
|
|
1390
2095
|
}
|
|
1391
2096
|
const apply = (event) => {
|
|
1392
2097
|
if (event.type === "answer") {
|
|
@@ -1394,11 +2099,19 @@ function build(definition, options, replaying = false) {
|
|
|
1394
2099
|
const { at: _at, ...logged } = event;
|
|
1395
2100
|
answer(event.field, event.value, logged);
|
|
1396
2101
|
}
|
|
1397
|
-
} else if (event.type === "next")
|
|
1398
|
-
|
|
1399
|
-
|
|
2102
|
+
} else if (event.type === "next") {
|
|
2103
|
+
settleNow();
|
|
2104
|
+
engine.next();
|
|
2105
|
+
} else if (event.type === "back") {
|
|
2106
|
+
settleNow();
|
|
2107
|
+
engine.back();
|
|
2108
|
+
} else if (event.type === "submit") {
|
|
2109
|
+
settleNow();
|
|
2110
|
+
engine.submit();
|
|
2111
|
+
}
|
|
1400
2112
|
};
|
|
1401
2113
|
const finish = () => {
|
|
2114
|
+
settleNow();
|
|
1402
2115
|
batch = false;
|
|
1403
2116
|
state = { ...state, events: [...state.events] };
|
|
1404
2117
|
};
|
|
@@ -1547,7 +2260,13 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
|
|
|
1547
2260
|
return `${left} ${op} ${right}`;
|
|
1548
2261
|
}
|
|
1549
2262
|
|
|
2263
|
+
exports.CALCULATED_TYPE = CALCULATED_TYPE;
|
|
2264
|
+
exports.COMPUTED_FIELD_TYPES = COMPUTED_FIELD_TYPES;
|
|
2265
|
+
exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
|
|
1550
2266
|
exports.HIGHLIGHT_NOTE_MESSAGE = HIGHLIGHT_NOTE_MESSAGE;
|
|
2267
|
+
exports.MAX_CALCULATED_DECIMALS = MAX_CALCULATED_DECIMALS;
|
|
2268
|
+
exports.MAX_FORMULA_ITEMS = MAX_FORMULA_ITEMS;
|
|
2269
|
+
exports.MAX_FORMULA_LENGTH = MAX_FORMULA_LENGTH;
|
|
1551
2270
|
exports.MAX_HIDDEN_STRING_LENGTH = MAX_HIDDEN_STRING_LENGTH;
|
|
1552
2271
|
exports.MAX_HIGHLIGHT_NOTE_LENGTH = MAX_HIGHLIGHT_NOTE_LENGTH;
|
|
1553
2272
|
exports.MAX_HIGHLIGHT_QUOTE_LENGTH = MAX_HIGHLIGHT_QUOTE_LENGTH;
|
|
@@ -1556,33 +2275,47 @@ exports.MAX_TABLE_COLUMNS = MAX_TABLE_COLUMNS;
|
|
|
1556
2275
|
exports.MAX_TIMESTAMP_SECONDS = MAX_TIMESTAMP_SECONDS;
|
|
1557
2276
|
exports.REQUIRED_MESSAGE = REQUIRED_MESSAGE;
|
|
1558
2277
|
exports.STRUCTURED_FIELD_TYPES = STRUCTURED_FIELD_TYPES;
|
|
2278
|
+
exports.calculatedProps = calculatedProps;
|
|
2279
|
+
exports.calculatedValue = calculatedValue;
|
|
1559
2280
|
exports.canonicalJson = canonicalJson;
|
|
1560
2281
|
exports.choiceOptions = choiceOptions;
|
|
1561
2282
|
exports.coerceHidden = coerceHidden;
|
|
1562
2283
|
exports.coerceHiddenValue = coerceHiddenValue;
|
|
1563
2284
|
exports.computePath = computePath;
|
|
1564
2285
|
exports.createForm = createForm;
|
|
2286
|
+
exports.dateLimits = dateLimits;
|
|
1565
2287
|
exports.defineForm = defineForm;
|
|
1566
2288
|
exports.describeCondition = describeCondition;
|
|
1567
2289
|
exports.evaluate = evaluate;
|
|
2290
|
+
exports.evaluateFormula = evaluateFormula;
|
|
1568
2291
|
exports.explain = explain;
|
|
1569
2292
|
exports.f = f;
|
|
1570
2293
|
exports.fingerprint = fingerprint;
|
|
2294
|
+
exports.formatCalculated = formatCalculated;
|
|
1571
2295
|
exports.formatTimestamp = formatTimestamp;
|
|
2296
|
+
exports.formulaInput = formulaInput;
|
|
2297
|
+
exports.formulaNumber = formulaNumber;
|
|
2298
|
+
exports.formulaRefs = formulaRefs;
|
|
1572
2299
|
exports.getFieldType = getFieldType;
|
|
1573
2300
|
exports.isEmptyValue = isEmptyValue;
|
|
1574
2301
|
exports.isFormEvent = isFormEvent;
|
|
2302
|
+
exports.isIsoDate = isIsoDate;
|
|
1575
2303
|
exports.isJson = isJson;
|
|
1576
2304
|
exports.isSensitive = isSensitive;
|
|
1577
2305
|
exports.listFieldTypes = listFieldTypes;
|
|
1578
2306
|
exports.listLimits = listLimits;
|
|
1579
2307
|
exports.normalizeValue = normalizeValue;
|
|
2308
|
+
exports.numberLimits = numberLimits;
|
|
1580
2309
|
exports.parseForm = parseForm;
|
|
2310
|
+
exports.parseFormula = parseFormula;
|
|
1581
2311
|
exports.parseTimestamp = parseTimestamp;
|
|
2312
|
+
exports.ratingDisplay = ratingDisplay;
|
|
1582
2313
|
exports.recall = recall;
|
|
1583
2314
|
exports.registerFieldType = registerFieldType;
|
|
2315
|
+
exports.renameFormulaKey = renameFormulaKey;
|
|
1584
2316
|
exports.replay = replay;
|
|
1585
2317
|
exports.ruleVisible = ruleVisible;
|
|
2318
|
+
exports.scaleLabels = scaleLabels;
|
|
1586
2319
|
exports.steps = steps;
|
|
1587
2320
|
exports.summarizeValue = summarizeValue;
|
|
1588
2321
|
exports.tableColumns = tableColumns;
|