@marktiderman/genesis-forms 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +146 -2
- package/conformance/after-class-back-after-a-jump.scenario.json +217 -0
- package/conformance/after-class-contact-yes-completed.scenario.json +230 -0
- package/conformance/after-class-contact-yes-requires-name-and-email.scenario.json +218 -0
- package/conformance/after-class-happy-path.scenario.json +237 -0
- package/conformance/after-class-invalid-email-rejected.scenario.json +227 -0
- package/conformance/after-class-low-rating-skips-went-well.scenario.json +223 -0
- package/conformance/after-class-server-drops-unknown-and-hidden-answers.scenario.json +236 -0
- package/conformance/after-class-server-ignores-a-client-sent-coach.scenario.json +219 -0
- package/conformance/contact-details-anonymous-form-skips-identifying-fields.scenario.json +125 -0
- package/conformance/contact-details-named-form-asks-for-identifying-fields.scenario.json +129 -0
- package/conformance/monthly-pulse-default-ending.scenario.json +148 -0
- package/conformance/monthly-pulse-promoter-ending-via-jump.scenario.json +128 -0
- package/dist/engine-Cp5t2_Am.d.ts +75 -0
- package/dist/engine-LsPRTzt3.d.cts +75 -0
- package/dist/index.cjs +1205 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +1174 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +988 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +52 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +984 -0
- package/dist/server.js.map +1 -0
- package/dist/testing.cjs +1299 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +129 -0
- package/dist/testing.d.ts +129 -0
- package/dist/testing.js +1293 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-j8Z188mF.d.cts +175 -0
- package/dist/validate-j8Z188mF.d.ts +175 -0
- package/package.json +79 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
// src/hash.ts
|
|
2
|
+
function canonicalJson(value) {
|
|
3
|
+
return JSON.stringify(sortKeys(value));
|
|
4
|
+
}
|
|
5
|
+
function sortKeys(value) {
|
|
6
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
7
|
+
if (value && typeof value === "object") {
|
|
8
|
+
const out = {};
|
|
9
|
+
for (const key of Object.keys(value).sort()) {
|
|
10
|
+
const v = value[key];
|
|
11
|
+
if (v !== void 0) out[key] = sortKeys(v);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function fingerprint(value) {
|
|
18
|
+
const str = canonicalJson(value);
|
|
19
|
+
let h1 = 3735928559;
|
|
20
|
+
let h2 = 1103547991;
|
|
21
|
+
for (let i = 0; i < str.length; i++) {
|
|
22
|
+
const ch = str.charCodeAt(i);
|
|
23
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
24
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
25
|
+
}
|
|
26
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
27
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
28
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/events.ts
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
34
|
+
const proto = Object.getPrototypeOf(value);
|
|
35
|
+
return proto === Object.prototype || proto === null;
|
|
36
|
+
}
|
|
37
|
+
function isJson(value) {
|
|
38
|
+
const stack = [value];
|
|
39
|
+
while (stack.length) {
|
|
40
|
+
const v = stack.pop();
|
|
41
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") continue;
|
|
42
|
+
if (typeof v === "number") {
|
|
43
|
+
if (!Number.isFinite(v)) return false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(v)) {
|
|
47
|
+
for (let i = 0; i < v.length; i++) stack.push(v[i]);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!isPlainObject(v)) return false;
|
|
51
|
+
for (const key of Object.keys(v)) stack.push(v[key]);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
var EVENT_TYPES = /* @__PURE__ */ new Set(["answer", "next", "back", "submit"]);
|
|
56
|
+
function isFormEvent(value) {
|
|
57
|
+
if (!isPlainObject(value) || typeof value.type !== "string" || !EVENT_TYPES.has(value.type)) return false;
|
|
58
|
+
if (value.at !== void 0 && !(typeof value.at === "number" && Number.isFinite(value.at))) return false;
|
|
59
|
+
if (value.type !== "answer") return true;
|
|
60
|
+
return typeof value.field === "string" && (value.value === void 0 || isJson(value.value)) && (value.source === void 0 || value.source === "prefill");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/fields.ts
|
|
64
|
+
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
65
|
+
var globalScope = globalThis;
|
|
66
|
+
var registry = globalScope[REGISTRY] ??= /* @__PURE__ */ new Map();
|
|
67
|
+
function registerFieldType(manifest) {
|
|
68
|
+
if (!manifest.type) throw new Error("registerFieldType: a field type needs a `type`.");
|
|
69
|
+
const value = manifest.value;
|
|
70
|
+
if (!value.json && (typeof value.serialize !== "function" || typeof value.deserialize !== "function")) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`registerFieldType: "${manifest.type}" stores a non-JSON value, so it must provide both serialize and deserialize.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
registry.set(manifest.type, manifest);
|
|
76
|
+
}
|
|
77
|
+
function getFieldType(type) {
|
|
78
|
+
return registry.get(type);
|
|
79
|
+
}
|
|
80
|
+
function listFieldTypes() {
|
|
81
|
+
return [...registry.values()];
|
|
82
|
+
}
|
|
83
|
+
function isEmptyValue(value, field2) {
|
|
84
|
+
const manifest = field2 ? registry.get(field2.type) : void 0;
|
|
85
|
+
if (manifest?.isEmpty) return manifest.isEmpty(value);
|
|
86
|
+
if (value === void 0 || value === null) return true;
|
|
87
|
+
if (typeof value === "string") return value.trim() === "";
|
|
88
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
function normalizeValue(value, field2) {
|
|
92
|
+
const manifest = registry.get(field2.type);
|
|
93
|
+
return manifest?.normalize ? manifest.normalize(value, field2) : value;
|
|
94
|
+
}
|
|
95
|
+
function isSensitive(block) {
|
|
96
|
+
return block.kind === "field" && (block.sensitive === true || block.props?.sensitive === true);
|
|
97
|
+
}
|
|
98
|
+
function summarizeValue(value, field2) {
|
|
99
|
+
if (isEmptyValue(value, field2)) return "";
|
|
100
|
+
const manifest = registry.get(field2.type);
|
|
101
|
+
if (manifest?.summarize) return manifest.summarize(value, field2);
|
|
102
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
103
|
+
return String(value);
|
|
104
|
+
}
|
|
105
|
+
function choiceOptions(field2) {
|
|
106
|
+
const raw = field2.props?.options;
|
|
107
|
+
if (!Array.isArray(raw)) return [];
|
|
108
|
+
return raw.flatMap(
|
|
109
|
+
(o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
function optionLabel(field2, value) {
|
|
113
|
+
const option = choiceOptions(field2).find((o) => o.value === value);
|
|
114
|
+
return option?.label ?? value;
|
|
115
|
+
}
|
|
116
|
+
function intRange(field2, min, maxDefault) {
|
|
117
|
+
const max = typeof field2.props?.max === "number" ? field2.props.max : maxDefault;
|
|
118
|
+
return { min, max };
|
|
119
|
+
}
|
|
120
|
+
var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
|
|
121
|
+
var textOps = ["eq", "neq", "contains", "answered", "empty"];
|
|
122
|
+
function maxLength(field2, fallback) {
|
|
123
|
+
return typeof field2.props?.maxLength === "number" ? field2.props.maxLength : fallback;
|
|
124
|
+
}
|
|
125
|
+
function textValidate(fallback) {
|
|
126
|
+
return (value, field2) => {
|
|
127
|
+
if (typeof value !== "string") return "Enter text.";
|
|
128
|
+
const limit = maxLength(field2, fallback);
|
|
129
|
+
return value.length > limit ? `Keep it under ${limit} characters.` : null;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
var MAX_EMAIL_LENGTH = 254;
|
|
133
|
+
function isEmailAddress(value) {
|
|
134
|
+
if (value.length > MAX_EMAIL_LENGTH || /\s/.test(value)) return false;
|
|
135
|
+
const at = value.indexOf("@");
|
|
136
|
+
if (at < 1 || value.indexOf("@", at + 1) !== -1) return false;
|
|
137
|
+
const domain = value.slice(at + 1);
|
|
138
|
+
return domain.slice(1, -1).includes(".");
|
|
139
|
+
}
|
|
140
|
+
var MAX_PHONE_LENGTH = 32;
|
|
141
|
+
function isPhoneNumber(value) {
|
|
142
|
+
if (value.length > MAX_PHONE_LENGTH) return false;
|
|
143
|
+
let digits = 0;
|
|
144
|
+
for (let i = 0; i < value.length; i++) {
|
|
145
|
+
const c = value[i];
|
|
146
|
+
if (c >= "0" && c <= "9") digits++;
|
|
147
|
+
else if (c === "+") {
|
|
148
|
+
if (i !== 0) return false;
|
|
149
|
+
} else if (c !== " " && c !== "-" && c !== "(" && c !== ")" && c !== ".") return false;
|
|
150
|
+
}
|
|
151
|
+
return digits >= 7 && digits <= 15;
|
|
152
|
+
}
|
|
153
|
+
var builtins = [
|
|
154
|
+
{
|
|
155
|
+
type: "short-text",
|
|
156
|
+
label: "Short text",
|
|
157
|
+
category: "text",
|
|
158
|
+
value: { json: true },
|
|
159
|
+
operators: textOps,
|
|
160
|
+
a11y: { semantics: "control" },
|
|
161
|
+
validate: textValidate(500)
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
type: "long-text",
|
|
165
|
+
label: "Long text",
|
|
166
|
+
category: "text",
|
|
167
|
+
value: { json: true },
|
|
168
|
+
operators: textOps,
|
|
169
|
+
a11y: { semantics: "control" },
|
|
170
|
+
validate: textValidate(5e3)
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
type: "email",
|
|
174
|
+
label: "Email",
|
|
175
|
+
category: "text",
|
|
176
|
+
value: { json: true },
|
|
177
|
+
operators: textOps,
|
|
178
|
+
a11y: { semantics: "control", autocomplete: "email", inputMode: "email" },
|
|
179
|
+
validate: (value) => typeof value === "string" && isEmailAddress(value.trim()) ? null : "Enter an email address like name@example.com.",
|
|
180
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
type: "phone",
|
|
184
|
+
label: "Phone",
|
|
185
|
+
category: "text",
|
|
186
|
+
value: { json: true },
|
|
187
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
188
|
+
a11y: { semantics: "control", autocomplete: "tel", inputMode: "tel" },
|
|
189
|
+
validate: (value) => typeof value === "string" && isPhoneNumber(value.trim()) ? null : "Enter a phone number with 7 to 15 digits.",
|
|
190
|
+
normalize: (value) => typeof value === "string" ? value.trim() : value,
|
|
191
|
+
summarize: (value) => String(value)
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
type: "rating",
|
|
195
|
+
label: "Rating",
|
|
196
|
+
category: "scale",
|
|
197
|
+
value: { json: true },
|
|
198
|
+
operators: numberOps,
|
|
199
|
+
a11y: { semantics: "group" },
|
|
200
|
+
validate: (value, field2) => {
|
|
201
|
+
const { min, max } = intRange(field2, 1, 5);
|
|
202
|
+
return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
|
|
203
|
+
},
|
|
204
|
+
summarize: (value, field2) => `${value}/${intRange(field2, 1, 5).max}`
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
type: "nps",
|
|
208
|
+
label: "Net Promoter Score",
|
|
209
|
+
category: "scale",
|
|
210
|
+
value: { json: true },
|
|
211
|
+
operators: numberOps,
|
|
212
|
+
a11y: { semantics: "group" },
|
|
213
|
+
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
|
|
214
|
+
summarize: (value) => `${value}/10`
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
type: "single-choice",
|
|
218
|
+
label: "Single choice",
|
|
219
|
+
category: "choice",
|
|
220
|
+
value: { json: true },
|
|
221
|
+
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
222
|
+
a11y: { semantics: "group" },
|
|
223
|
+
validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
224
|
+
summarize: (value, field2) => optionLabel(field2, String(value))
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
type: "multi-choice",
|
|
228
|
+
label: "Multiple choice",
|
|
229
|
+
category: "choice",
|
|
230
|
+
value: { json: true },
|
|
231
|
+
operators: ["contains", "answered", "empty"],
|
|
232
|
+
a11y: { semantics: "group" },
|
|
233
|
+
validate: (value, field2) => {
|
|
234
|
+
const allowed = new Set(choiceOptions(field2).map((o) => o.value));
|
|
235
|
+
return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
|
|
236
|
+
},
|
|
237
|
+
summarize: (value, field2) => Array.isArray(value) ? value.map((v) => optionLabel(field2, String(v))).join(", ") : String(value)
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
type: "yes-no",
|
|
241
|
+
label: "Yes / no",
|
|
242
|
+
category: "choice",
|
|
243
|
+
value: { json: true },
|
|
244
|
+
operators: ["eq", "neq", "answered", "empty"],
|
|
245
|
+
a11y: { semantics: "group" },
|
|
246
|
+
validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
|
|
247
|
+
summarize: (value) => value ? "Yes" : "No"
|
|
248
|
+
}
|
|
249
|
+
];
|
|
250
|
+
for (const manifest of builtins) registerFieldType(manifest);
|
|
251
|
+
|
|
252
|
+
// src/logic.ts
|
|
253
|
+
function own(record, key) {
|
|
254
|
+
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : void 0;
|
|
255
|
+
}
|
|
256
|
+
function resolve(operand, scope) {
|
|
257
|
+
if ("field" in operand) return own(scope.values, operand.field);
|
|
258
|
+
if ("hidden" in operand) return own(scope.hidden, operand.hidden);
|
|
259
|
+
return operand.value;
|
|
260
|
+
}
|
|
261
|
+
function deepEqual(a, b) {
|
|
262
|
+
if (a === b) return true;
|
|
263
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
264
|
+
return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
function evaluate(condition, scope) {
|
|
269
|
+
if ("all" in condition) return condition.all.every((c) => evaluate(c, scope));
|
|
270
|
+
if ("any" in condition) return condition.any.some((c) => evaluate(c, scope));
|
|
271
|
+
if ("not" in condition) return !evaluate(condition.not, scope);
|
|
272
|
+
const left = resolve(condition.left, scope);
|
|
273
|
+
const right = condition.right ? resolve(condition.right, scope) : void 0;
|
|
274
|
+
const field2 = "field" in condition.left ? scope.fields.get(condition.left.field) : void 0;
|
|
275
|
+
const unanswered = isEmptyValue(left, field2);
|
|
276
|
+
switch (condition.op) {
|
|
277
|
+
case "answered":
|
|
278
|
+
return !unanswered;
|
|
279
|
+
case "empty":
|
|
280
|
+
return unanswered;
|
|
281
|
+
case "eq":
|
|
282
|
+
return !unanswered && deepEqual(left, right);
|
|
283
|
+
case "neq":
|
|
284
|
+
return unanswered || !deepEqual(left, right);
|
|
285
|
+
case "lt":
|
|
286
|
+
case "lte":
|
|
287
|
+
case "gt":
|
|
288
|
+
case "gte": {
|
|
289
|
+
if (typeof left !== "number" || typeof right !== "number") return false;
|
|
290
|
+
if (condition.op === "lt") return left < right;
|
|
291
|
+
if (condition.op === "lte") return left <= right;
|
|
292
|
+
if (condition.op === "gt") return left > right;
|
|
293
|
+
return left >= right;
|
|
294
|
+
}
|
|
295
|
+
case "in":
|
|
296
|
+
return !unanswered && Array.isArray(right) && right.some((r) => deepEqual(r, left));
|
|
297
|
+
case "contains":
|
|
298
|
+
if (unanswered) return false;
|
|
299
|
+
if (Array.isArray(left)) return left.some((l) => deepEqual(l, right));
|
|
300
|
+
return typeof left === "string" && typeof right === "string" && left.toLowerCase().includes(right.toLowerCase());
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function steps(def) {
|
|
304
|
+
return def.blocks.filter((b) => b.kind !== "page-break");
|
|
305
|
+
}
|
|
306
|
+
function visibilityRules(def, blockId) {
|
|
307
|
+
return (def.logic ?? []).filter(
|
|
308
|
+
(r) => (r.effect === "show" || r.effect === "hide") && r.target === blockId
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
function withheld(def, field2) {
|
|
312
|
+
return def.settings?.anonymous === true && field2?.identifying === true;
|
|
313
|
+
}
|
|
314
|
+
function rulesAllow(def, blockId, scope) {
|
|
315
|
+
if (withheld(def, scope.fields.get(blockId))) return false;
|
|
316
|
+
const rules = visibilityRules(def, blockId);
|
|
317
|
+
if (rules.some((r) => r.effect === "hide" && evaluate(r.when, scope))) return false;
|
|
318
|
+
const shows = rules.filter((r) => r.effect === "show");
|
|
319
|
+
return shows.length === 0 || shows.some((r) => evaluate(r.when, scope));
|
|
320
|
+
}
|
|
321
|
+
function walkPath(def, scope) {
|
|
322
|
+
const list = steps(def);
|
|
323
|
+
const index = new Map(list.map((b, i2) => [b.id, i2]));
|
|
324
|
+
const position = new Map(def.blocks.map((b, i2) => [b.id, i2]));
|
|
325
|
+
const jumps = (def.logic ?? []).filter(
|
|
326
|
+
(r) => r.effect === "jump"
|
|
327
|
+
);
|
|
328
|
+
const defaultEnding = def.endings?.find((e) => e.id === "default")?.id ?? def.endings?.[0]?.id ?? null;
|
|
329
|
+
const placed = /* @__PURE__ */ Object.create(null);
|
|
330
|
+
const local = { values: placed, hidden: scope.hidden, fields: scope.fields };
|
|
331
|
+
const path = [];
|
|
332
|
+
const nextVisible = (from) => {
|
|
333
|
+
for (let i2 = from; i2 < list.length; i2++) if (rulesAllow(def, list[i2].id, local)) return i2;
|
|
334
|
+
return list.length;
|
|
335
|
+
};
|
|
336
|
+
let ending = defaultEnding;
|
|
337
|
+
let i = nextVisible(0);
|
|
338
|
+
while (i < list.length) {
|
|
339
|
+
const block = list[i];
|
|
340
|
+
path.push(block.id);
|
|
341
|
+
if (block.kind === "field") placed[block.id] = own(scope.values, block.id);
|
|
342
|
+
const jump = jumps.find((r) => r.from === block.id && evaluate(r.when, local));
|
|
343
|
+
if (jump) {
|
|
344
|
+
if (typeof jump.to === "object") {
|
|
345
|
+
ending = jump.to.ending;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
const target = index.get(jump.to);
|
|
349
|
+
if (target !== void 0 && target > i) {
|
|
350
|
+
i = nextVisible(target);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
i = nextVisible(i + 1);
|
|
355
|
+
}
|
|
356
|
+
const scopeAt = (blockId, inclusive = false) => {
|
|
357
|
+
const at = position.get(blockId) ?? -1;
|
|
358
|
+
const values = /* @__PURE__ */ Object.create(null);
|
|
359
|
+
for (const id of path) {
|
|
360
|
+
const p = position.get(id);
|
|
361
|
+
if (p > at || p === at && !inclusive) break;
|
|
362
|
+
if (id in placed) values[id] = placed[id];
|
|
363
|
+
}
|
|
364
|
+
return { values, hidden: scope.hidden, fields: scope.fields };
|
|
365
|
+
};
|
|
366
|
+
return { path, ending, scopeAt };
|
|
367
|
+
}
|
|
368
|
+
function computePath(def, scope) {
|
|
369
|
+
const { path, ending } = walkPath(def, scope);
|
|
370
|
+
return { path, ending };
|
|
371
|
+
}
|
|
372
|
+
function ruleVisible(def, blockId, scope) {
|
|
373
|
+
return rulesAllow(def, blockId, walkPath(def, scope).scopeAt(blockId));
|
|
374
|
+
}
|
|
375
|
+
function fieldMap(def) {
|
|
376
|
+
const map = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const block of def.blocks) if (block.kind === "field") map.set(block.id, block);
|
|
378
|
+
return map;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/recall.ts
|
|
382
|
+
var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
|
|
383
|
+
function recall(text, scope) {
|
|
384
|
+
return text.replace(TOKEN, (_match, kind, name, fallback) => {
|
|
385
|
+
if (kind === "field") {
|
|
386
|
+
const field2 = scope.fieldsByKey.get(name);
|
|
387
|
+
const out2 = field2 ? summarizeValue(own(scope.values, field2.id), field2) : "";
|
|
388
|
+
return out2 || (fallback ?? "").trim();
|
|
389
|
+
}
|
|
390
|
+
const value = own(scope.hidden, name);
|
|
391
|
+
const out = value === void 0 || value === null ? "" : String(value);
|
|
392
|
+
return out || (fallback ?? "").trim();
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function recallRefs(text) {
|
|
396
|
+
return [...text.matchAll(TOKEN)].map((m) => ({ kind: m[1], name: m[2] }));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/parse.ts
|
|
400
|
+
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
401
|
+
var OPERATORS = /* @__PURE__ */ new Set([
|
|
402
|
+
"eq",
|
|
403
|
+
"neq",
|
|
404
|
+
"lt",
|
|
405
|
+
"lte",
|
|
406
|
+
"gt",
|
|
407
|
+
"gte",
|
|
408
|
+
"in",
|
|
409
|
+
"contains",
|
|
410
|
+
"answered",
|
|
411
|
+
"empty"
|
|
412
|
+
]);
|
|
413
|
+
var RESERVED = new Set(Object.getOwnPropertyNames(Object.prototype));
|
|
414
|
+
var MAX_CONDITION_DEPTH = 32;
|
|
415
|
+
function parseForm(input) {
|
|
416
|
+
const issues = [];
|
|
417
|
+
const warnings = [];
|
|
418
|
+
const fail = () => ({ ok: false, issues, warnings });
|
|
419
|
+
const issue = (path, message) => void issues.push({ path, message });
|
|
420
|
+
const text = (v, path, nonEmpty = false) => {
|
|
421
|
+
if (typeof v !== "string" || nonEmpty && !v) issue(path, nonEmpty ? "Required." : "Required text.");
|
|
422
|
+
};
|
|
423
|
+
const optionalText = (v, path) => {
|
|
424
|
+
if (v !== void 0 && typeof v !== "string") issue(path, "Must be text.");
|
|
425
|
+
};
|
|
426
|
+
const optionalBoolean = (v, path) => {
|
|
427
|
+
if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
|
|
428
|
+
};
|
|
429
|
+
const name = (v, path, what) => {
|
|
430
|
+
if (typeof v !== "string" || !v) {
|
|
431
|
+
issue(path, `${what} must be a non-empty string.`);
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
if (RESERVED.has(v)) {
|
|
435
|
+
issue(path, `"${v}" is reserved and cannot be used as ${what.toLowerCase()}.`);
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
return true;
|
|
439
|
+
};
|
|
440
|
+
if (!isObject(input)) {
|
|
441
|
+
issue("", "A form definition must be an object.");
|
|
442
|
+
return fail();
|
|
443
|
+
}
|
|
444
|
+
if (input.schemaVersion !== 1) issue("schemaVersion", "Unsupported schemaVersion; expected 1.");
|
|
445
|
+
text(input.id, "id", true);
|
|
446
|
+
text(input.title, "title");
|
|
447
|
+
optionalText(input.description, "description");
|
|
448
|
+
if (input.layout !== void 0) {
|
|
449
|
+
if (!isObject(input.layout)) issue("layout", "Must be an object.");
|
|
450
|
+
else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
|
|
451
|
+
issue("layout.mode", "Must be focus or classic.");
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (input.settings !== void 0) {
|
|
455
|
+
if (!isObject(input.settings)) issue("settings", "Must be an object.");
|
|
456
|
+
else {
|
|
457
|
+
optionalBoolean(input.settings.anonymous, "settings.anonymous");
|
|
458
|
+
optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (input.meta !== void 0 && (!isObject(input.meta) || !isJson(input.meta))) {
|
|
462
|
+
issue("meta", "Must be a JSON object.");
|
|
463
|
+
}
|
|
464
|
+
if (!Array.isArray(input.blocks)) {
|
|
465
|
+
issue("blocks", "Must be an array.");
|
|
466
|
+
return fail();
|
|
467
|
+
}
|
|
468
|
+
const ids = /* @__PURE__ */ new Set();
|
|
469
|
+
const pageBreaks = /* @__PURE__ */ new Set();
|
|
470
|
+
const keys = /* @__PURE__ */ new Map();
|
|
471
|
+
const fieldTypes = /* @__PURE__ */ new Map();
|
|
472
|
+
const order = /* @__PURE__ */ new Map();
|
|
473
|
+
input.blocks.forEach((block, i) => {
|
|
474
|
+
const path = `blocks[${i}]`;
|
|
475
|
+
if (!isObject(block) || typeof block.id !== "string" || !block.id) {
|
|
476
|
+
issue(path, "Every block needs a string id.");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!name(block.id, `${path}.id`, "A block id")) return;
|
|
480
|
+
if (ids.has(block.id)) issue(`${path}.id`, `Duplicate id "${block.id}".`);
|
|
481
|
+
ids.add(block.id);
|
|
482
|
+
if (block.kind === "page-break") pageBreaks.add(block.id);
|
|
483
|
+
order.set(block.id, i);
|
|
484
|
+
if (block.kind === "field") {
|
|
485
|
+
text(block.label, `${path}.label`);
|
|
486
|
+
optionalText(block.description, `${path}.description`);
|
|
487
|
+
optionalText(block.placeholder, `${path}.placeholder`);
|
|
488
|
+
optionalBoolean(block.required, `${path}.required`);
|
|
489
|
+
optionalBoolean(block.sensitive, `${path}.sensitive`);
|
|
490
|
+
optionalBoolean(block.identifying, `${path}.identifying`);
|
|
491
|
+
const validKey = name(block.key, `${path}.key`, "A field key");
|
|
492
|
+
if (typeof block.type !== "string" || !getFieldType(block.type)) {
|
|
493
|
+
issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
|
|
494
|
+
}
|
|
495
|
+
if (block.props !== void 0) {
|
|
496
|
+
if (!isObject(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
497
|
+
else if (block.props.options !== void 0) {
|
|
498
|
+
const options = block.props.options;
|
|
499
|
+
if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
|
|
500
|
+
else {
|
|
501
|
+
options.forEach((o, j) => {
|
|
502
|
+
const at = `${path}.props.options[${j}]`;
|
|
503
|
+
if (!isObject(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
504
|
+
else optionalText(o.label, `${at}.label`);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (validKey) {
|
|
510
|
+
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
511
|
+
keys.set(block.key, block.id);
|
|
512
|
+
}
|
|
513
|
+
fieldTypes.set(block.id, typeof block.type === "string" ? block.type : "");
|
|
514
|
+
} else if (block.kind === "content") {
|
|
515
|
+
if (block.type !== "heading" && block.type !== "text") issue(`${path}.type`, "Must be heading or text.");
|
|
516
|
+
text(block.text, `${path}.text`);
|
|
517
|
+
} else if (block.kind === "page-break") {
|
|
518
|
+
optionalText(block.title, `${path}.title`);
|
|
519
|
+
} else {
|
|
520
|
+
issue(`${path}.kind`, "Unknown block kind.");
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
const hiddenNames = /* @__PURE__ */ new Set();
|
|
524
|
+
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
525
|
+
(Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
|
|
526
|
+
if (!isObject(h) || typeof h.name !== "string" || !h.name) {
|
|
527
|
+
issue(`hidden[${i}]`, "Each hidden input needs a name.");
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!name(h.name, `hidden[${i}].name`, "A hidden input name")) return;
|
|
531
|
+
if (hiddenNames.has(h.name)) issue(`hidden[${i}].name`, `Duplicate hidden input "${h.name}".`);
|
|
532
|
+
if (typeof h.source !== "string" || !["url", "host", "server"].includes(h.source)) {
|
|
533
|
+
issue(`hidden[${i}].source`, "Must be url, host or server.");
|
|
534
|
+
}
|
|
535
|
+
if (typeof h.type !== "string" || !["string", "number", "boolean"].includes(h.type)) {
|
|
536
|
+
issue(`hidden[${i}].type`, "Must be string, number or boolean.");
|
|
537
|
+
}
|
|
538
|
+
optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
|
|
539
|
+
hiddenNames.add(h.name);
|
|
540
|
+
});
|
|
541
|
+
const endingIds = /* @__PURE__ */ new Set();
|
|
542
|
+
if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
|
|
543
|
+
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
544
|
+
const path = `endings[${i}]`;
|
|
545
|
+
if (!isObject(e)) {
|
|
546
|
+
issue(path, "Each ending must be an object with an id and a title.");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
text(e.title, `${path}.title`);
|
|
550
|
+
optionalText(e.description, `${path}.description`);
|
|
551
|
+
if (!name(e.id, `${path}.id`, "An ending id")) return;
|
|
552
|
+
if (endingIds.has(e.id)) issue(`${path}.id`, `Duplicate ending id "${e.id}".`);
|
|
553
|
+
endingIds.add(e.id);
|
|
554
|
+
});
|
|
555
|
+
const checkOperand = (o, path, reads) => {
|
|
556
|
+
if (o === void 0) return;
|
|
557
|
+
if (!isObject(o)) return issue(path, "An operand must be an object.");
|
|
558
|
+
if ("field" in o) {
|
|
559
|
+
if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
|
|
560
|
+
reads.push(o.field);
|
|
561
|
+
} else if ("hidden" in o) {
|
|
562
|
+
if (typeof o.hidden !== "string" || !hiddenNames.has(o.hidden)) {
|
|
563
|
+
issue(path, `No hidden input "${String(o.hidden)}".`);
|
|
564
|
+
}
|
|
565
|
+
} else if (!("value" in o) || !isJson(o.value)) {
|
|
566
|
+
issue(path, "An operand needs a `field`, a `hidden` input or a JSON `value`.");
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
const checkCondition = (c, path, reads, depth = 0) => {
|
|
570
|
+
if (!isObject(c)) return issue(path, "A condition must be an object.");
|
|
571
|
+
if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
|
|
572
|
+
if ("all" in c || "any" in c) {
|
|
573
|
+
const group = "all" in c ? "all" : "any";
|
|
574
|
+
const list = c[group];
|
|
575
|
+
if (!Array.isArray(list)) return issue(path, `\`${group}\` must be an array.`);
|
|
576
|
+
return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
|
|
577
|
+
}
|
|
578
|
+
if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
|
|
579
|
+
if (typeof c.op !== "string" || !isObject(c.left)) {
|
|
580
|
+
return issue(path, "A comparison needs an `op` and a `left` operand.");
|
|
581
|
+
}
|
|
582
|
+
if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
|
|
583
|
+
checkOperand(c.left, `${path}.left`, reads);
|
|
584
|
+
checkOperand(c.right, `${path}.right`, reads);
|
|
585
|
+
if (typeof c.left.field === "string") {
|
|
586
|
+
const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
|
|
587
|
+
if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
|
|
588
|
+
issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
|
|
593
|
+
const ruleIds = /* @__PURE__ */ new Set();
|
|
594
|
+
if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
|
|
595
|
+
(Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
|
|
596
|
+
const path = `logic[${i}]`;
|
|
597
|
+
if (!isObject(rule) || typeof rule.id !== "string" || !isObject(rule.when)) {
|
|
598
|
+
issue(path, "Each rule needs an id and a `when` condition.");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (ruleIds.has(rule.id)) issue(`${path}.id`, `Duplicate rule id "${rule.id}".`);
|
|
602
|
+
ruleIds.add(rule.id);
|
|
603
|
+
const reads = [];
|
|
604
|
+
checkCondition(rule.when, `${path}.when`, reads);
|
|
605
|
+
let at;
|
|
606
|
+
let inclusive = false;
|
|
607
|
+
if (rule.effect === "jump") {
|
|
608
|
+
if (typeof rule.from !== "string" || !ids.has(rule.from)) {
|
|
609
|
+
issue(`${path}.from`, `No block "${String(rule.from)}".`);
|
|
610
|
+
} else if (pageBreaks.has(rule.from)) {
|
|
611
|
+
issue(`${path}.from`, "A jump can't start at a page break; use the block before it.");
|
|
612
|
+
} else {
|
|
613
|
+
at = rule.from;
|
|
614
|
+
inclusive = true;
|
|
615
|
+
}
|
|
616
|
+
if (isObject(rule.to)) {
|
|
617
|
+
if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
|
|
618
|
+
issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
|
|
619
|
+
}
|
|
620
|
+
} else if (typeof rule.to !== "string" || !ids.has(rule.to)) {
|
|
621
|
+
issue(`${path}.to`, `No block "${String(rule.to)}".`);
|
|
622
|
+
} else if (pageBreaks.has(rule.to)) {
|
|
623
|
+
issue(`${path}.to`, "A jump can't land on a page break; jump to the first block after it.");
|
|
624
|
+
} else if (at !== void 0 && (order.get(rule.to) ?? 0) <= (order.get(at) ?? 0)) {
|
|
625
|
+
warnings.push({ path: `${path}.to`, message: "Jumps only go forward; this one is ignored." });
|
|
626
|
+
}
|
|
627
|
+
} else if (rule.effect === "show" || rule.effect === "hide") {
|
|
628
|
+
if (typeof rule.target !== "string" || !ids.has(rule.target)) {
|
|
629
|
+
issue(`${path}.target`, `No block "${String(rule.target)}".`);
|
|
630
|
+
} else at = rule.target;
|
|
631
|
+
} else {
|
|
632
|
+
issue(`${path}.effect`, "Must be show, hide or jump.");
|
|
633
|
+
}
|
|
634
|
+
if (at !== void 0) {
|
|
635
|
+
const limit = order.get(at) ?? 0;
|
|
636
|
+
for (const field2 of new Set(reads)) {
|
|
637
|
+
const p = order.get(field2) ?? 0;
|
|
638
|
+
if (p > limit || p === limit && !inclusive) {
|
|
639
|
+
warnings.push({
|
|
640
|
+
path: `${path}.when`,
|
|
641
|
+
message: `Reads "${keyOf(field2)}", which does not come before "${keyOf(at)}"; it always reads as unanswered.`
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
input.blocks.forEach((block, i) => {
|
|
648
|
+
if (!isObject(block)) return;
|
|
649
|
+
const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
|
|
650
|
+
const texts = raw.filter((t) => typeof t === "string");
|
|
651
|
+
for (const t of texts) {
|
|
652
|
+
for (const ref of recallRefs(t)) {
|
|
653
|
+
if (ref.kind === "hidden" && !hiddenNames.has(ref.name)) {
|
|
654
|
+
issue(`blocks[${i}]`, `Recalls unknown hidden input "${ref.name}".`);
|
|
655
|
+
}
|
|
656
|
+
if (ref.kind === "field") {
|
|
657
|
+
const target = keys.get(ref.name);
|
|
658
|
+
if (!target) issue(`blocks[${i}]`, `Recalls unknown field "${ref.name}".`);
|
|
659
|
+
else if ((order.get(target) ?? 0) >= i) {
|
|
660
|
+
warnings.push({ path: `blocks[${i}]`, message: `Recalls "${ref.name}", which comes later.` });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/define.ts
|
|
670
|
+
function field(type, key, options, props) {
|
|
671
|
+
const block = {
|
|
672
|
+
kind: "field",
|
|
673
|
+
id: options.id ?? key,
|
|
674
|
+
key,
|
|
675
|
+
type,
|
|
676
|
+
label: options.label
|
|
677
|
+
};
|
|
678
|
+
if (options.description) block.description = options.description;
|
|
679
|
+
if (options.placeholder) block.placeholder = options.placeholder;
|
|
680
|
+
if (options.required) block.required = true;
|
|
681
|
+
if (options.sensitive) block.sensitive = true;
|
|
682
|
+
if (options.identifying) block.identifying = true;
|
|
683
|
+
if (props && Object.keys(props).length) block.props = props;
|
|
684
|
+
return block;
|
|
685
|
+
}
|
|
686
|
+
var choiceProps = (options) => ({
|
|
687
|
+
options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
|
|
688
|
+
});
|
|
689
|
+
var hiddenSpec = (type, o) => ({
|
|
690
|
+
type,
|
|
691
|
+
source: o.source ?? "url",
|
|
692
|
+
...o.sensitive ? { sensitive: true } : {}
|
|
693
|
+
});
|
|
694
|
+
var f = {
|
|
695
|
+
shortText: (key, o) => field("short-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
|
|
696
|
+
longText: (key, o) => field("long-text", key, o, o.maxLength ? { maxLength: o.maxLength } : void 0),
|
|
697
|
+
email: (key, o) => field("email", key, o),
|
|
698
|
+
phone: (key, o) => field("phone", key, o),
|
|
699
|
+
rating: (key, o) => field("rating", key, o, { max: o.max ?? 5 }),
|
|
700
|
+
nps: (key, o) => field("nps", key, o),
|
|
701
|
+
singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
|
|
702
|
+
multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
|
|
703
|
+
yesNo: (key, o) => field("yes-no", key, o),
|
|
704
|
+
/** Any registered field type, including plugins. */
|
|
705
|
+
custom: (type, key, o) => field(type, key, o, o.props),
|
|
706
|
+
heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
|
|
707
|
+
text: (id, text) => ({ kind: "content", id, type: "text", text }),
|
|
708
|
+
pageBreak: (id, title) => title ? { kind: "page-break", id, title } : { kind: "page-break", id },
|
|
709
|
+
hidden: {
|
|
710
|
+
string: (o = {}) => hiddenSpec("string", o),
|
|
711
|
+
number: (o = {}) => hiddenSpec("number", o),
|
|
712
|
+
boolean: (o = {}) => hiddenSpec("boolean", o)
|
|
713
|
+
}
|
|
714
|
+
};
|
|
715
|
+
function effects(fromKey, condition) {
|
|
716
|
+
const spec = (make) => ({
|
|
717
|
+
__rule: true,
|
|
718
|
+
build: make
|
|
719
|
+
});
|
|
720
|
+
return {
|
|
721
|
+
show: (target) => spec((r, id) => ({ id, when: condition(r), effect: "show", target: r(target, "target") })),
|
|
722
|
+
hide: (target) => spec((r, id) => ({ id, when: condition(r), effect: "hide", target: r(target, "target") })),
|
|
723
|
+
jumpTo: (target) => spec((r, id) => {
|
|
724
|
+
if (!fromKey) throw new Error("jumpTo needs a starting field: use when(key)\u2026");
|
|
725
|
+
return {
|
|
726
|
+
id,
|
|
727
|
+
when: condition(r),
|
|
728
|
+
effect: "jump",
|
|
729
|
+
from: r(fromKey),
|
|
730
|
+
to: typeof target === "string" ? r(target, "target") : target
|
|
731
|
+
};
|
|
732
|
+
})
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
var compare = (key, op, value) => (r) => value === void 0 ? { op, left: { field: r(key) } } : { op, left: { field: r(key) }, right: { value } };
|
|
736
|
+
function when(key) {
|
|
737
|
+
const on = (op, value) => effects(key, compare(key, op, value));
|
|
738
|
+
return {
|
|
739
|
+
is: (value) => on("eq", value),
|
|
740
|
+
isNot: (value) => on("neq", value),
|
|
741
|
+
lessThan: (n) => on("lt", n),
|
|
742
|
+
atMost: (n) => on("lte", n),
|
|
743
|
+
greaterThan: (n) => on("gt", n),
|
|
744
|
+
atLeast: (n) => on("gte", n),
|
|
745
|
+
isOneOf: (values) => on("in", values),
|
|
746
|
+
includes: (value) => on("contains", value),
|
|
747
|
+
isAnswered: () => on("answered"),
|
|
748
|
+
isEmpty: () => on("empty")
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
when.condition = (condition, from) => effects(from ?? null, (r) => resolveCondition(condition, r));
|
|
752
|
+
function resolveCondition(c, r) {
|
|
753
|
+
if ("all" in c) return { all: c.all.map((x) => resolveCondition(x, r)) };
|
|
754
|
+
if ("any" in c) return { any: c.any.map((x) => resolveCondition(x, r)) };
|
|
755
|
+
if ("not" in c) return { not: resolveCondition(c.not, r) };
|
|
756
|
+
const fix = (o) => "field" in o ? { field: r(o.field) } : o;
|
|
757
|
+
return c.right ? { op: c.op, left: fix(c.left), right: fix(c.right) } : { op: c.op, left: fix(c.left) };
|
|
758
|
+
}
|
|
759
|
+
function defineForm(input) {
|
|
760
|
+
const keyToId = /* @__PURE__ */ new Map();
|
|
761
|
+
const contentIds = /* @__PURE__ */ new Set();
|
|
762
|
+
for (const block of input.blocks) {
|
|
763
|
+
if (block.kind === "field") keyToId.set(block.key, block.id);
|
|
764
|
+
else contentIds.add(block.id);
|
|
765
|
+
}
|
|
766
|
+
const resolve2 = (key, as = "field") => {
|
|
767
|
+
const id = keyToId.get(key) ?? (as === "target" && contentIds.has(key) ? key : void 0);
|
|
768
|
+
if (!id) {
|
|
769
|
+
const what = as === "target" ? "field key or content block id" : "field with key";
|
|
770
|
+
throw new Error(`defineForm "${input.id}": no ${what} "${key}".`);
|
|
771
|
+
}
|
|
772
|
+
return id;
|
|
773
|
+
};
|
|
774
|
+
const definition = {
|
|
775
|
+
schemaVersion: 1,
|
|
776
|
+
id: input.id,
|
|
777
|
+
title: input.title,
|
|
778
|
+
blocks: input.blocks.map((b) => ({ ...b }))
|
|
779
|
+
};
|
|
780
|
+
if (input.description) definition.description = input.description;
|
|
781
|
+
if (input.layout) definition.layout = input.layout;
|
|
782
|
+
if (input.hidden && Object.keys(input.hidden).length) {
|
|
783
|
+
definition.hidden = Object.entries(input.hidden).map(([name, spec]) => ({
|
|
784
|
+
name,
|
|
785
|
+
type: spec.type,
|
|
786
|
+
source: spec.source,
|
|
787
|
+
...spec.sensitive ? { sensitive: true } : {}
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
if (input.logic?.length) definition.logic = input.logic.map((rule, i) => rule.build(resolve2, `r${i + 1}`));
|
|
791
|
+
if (input.endings?.length) definition.endings = input.endings;
|
|
792
|
+
if (input.settings) definition.settings = input.settings;
|
|
793
|
+
if (input.meta) definition.meta = input.meta;
|
|
794
|
+
const frozen = JSON.parse(JSON.stringify(definition));
|
|
795
|
+
const parsed = parseForm(frozen);
|
|
796
|
+
if (!parsed.ok) {
|
|
797
|
+
const list = parsed.issues.map((i) => ` - ${i.path ? `${i.path}: ` : ""}${i.message}`).join("\n");
|
|
798
|
+
throw new Error(`defineForm "${input.id}" is not a valid form:
|
|
799
|
+
${list}`);
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
definition: frozen,
|
|
803
|
+
toJSON: () => JSON.parse(JSON.stringify(frozen)),
|
|
804
|
+
hash: () => fingerprint(frozen),
|
|
805
|
+
$answers: void 0,
|
|
806
|
+
$hidden: void 0
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// src/hidden.ts
|
|
811
|
+
var MAX_HIDDEN_STRING_LENGTH = 1e3;
|
|
812
|
+
function coerceHiddenValue(value, type) {
|
|
813
|
+
if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
|
|
814
|
+
if (type === "string") {
|
|
815
|
+
if (typeof value === "string") return value;
|
|
816
|
+
if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
817
|
+
return void 0;
|
|
818
|
+
}
|
|
819
|
+
if (type === "number") {
|
|
820
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
821
|
+
if (typeof value !== "string" || value.trim() === "") return void 0;
|
|
822
|
+
const n = Number(value);
|
|
823
|
+
return Number.isFinite(n) ? n : void 0;
|
|
824
|
+
}
|
|
825
|
+
if (typeof value === "boolean") return value;
|
|
826
|
+
if (value === "true") return true;
|
|
827
|
+
if (value === "false") return false;
|
|
828
|
+
return void 0;
|
|
829
|
+
}
|
|
830
|
+
function coerceHidden(definition, raw, options = {}) {
|
|
831
|
+
const declared = new Map((definition.hidden ?? []).map((h) => [h.name, h]));
|
|
832
|
+
const values = {};
|
|
833
|
+
const ignored = [];
|
|
834
|
+
if (!isPlainObject(raw)) return { values, ignored };
|
|
835
|
+
for (const name of Object.keys(raw)) {
|
|
836
|
+
const value = raw[name];
|
|
837
|
+
if (value === void 0) continue;
|
|
838
|
+
const spec = declared.get(name);
|
|
839
|
+
if (!spec || options.sources && !options.sources.includes(spec.source)) {
|
|
840
|
+
ignored.push(name);
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const coerced = coerceHiddenValue(value, spec.type);
|
|
844
|
+
if (coerced === void 0) ignored.push(name);
|
|
845
|
+
else Object.defineProperty(values, name, { value: coerced, enumerable: true, writable: true, configurable: true });
|
|
846
|
+
}
|
|
847
|
+
return { values, ignored };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// src/validate.ts
|
|
851
|
+
var REQUIRED_MESSAGE = "This question needs an answer.";
|
|
852
|
+
function runCustom(validator, value) {
|
|
853
|
+
if (typeof validator === "function") return validator(value);
|
|
854
|
+
const result = validator["~standard"].validate(value);
|
|
855
|
+
if (result instanceof Promise) {
|
|
856
|
+
throw new Error("Async validators are not supported in the engine yet; run them on the server.");
|
|
857
|
+
}
|
|
858
|
+
const issues = result.issues;
|
|
859
|
+
return issues?.length ? issues[0].message : null;
|
|
860
|
+
}
|
|
861
|
+
function validateField(field2, value, options = {}) {
|
|
862
|
+
if (isEmptyValue(value, field2)) {
|
|
863
|
+
return field2.required && options.requireAnswers !== false ? REQUIRED_MESSAGE : null;
|
|
864
|
+
}
|
|
865
|
+
const manifest = getFieldType(field2.type);
|
|
866
|
+
if (!manifest) return `Unknown field type "${field2.type}".`;
|
|
867
|
+
const builtIn = manifest.validate?.(value, field2) ?? null;
|
|
868
|
+
if (builtIn) return builtIn;
|
|
869
|
+
return options.custom ? runCustom(options.custom, value) : null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/engine.ts
|
|
873
|
+
function createForm(definition, options = {}) {
|
|
874
|
+
return build(definition, options).engine;
|
|
875
|
+
}
|
|
876
|
+
function build(definition, options, replaying = false) {
|
|
877
|
+
const fields = fieldMap(definition);
|
|
878
|
+
const byKey = new Map([...fields.values()].map((f2) => [f2.key, f2]));
|
|
879
|
+
const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
|
|
880
|
+
const clock = definition.settings?.anonymous === true ? () => void 0 : options.clock ?? (() => Date.now());
|
|
881
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
882
|
+
let batch = replaying;
|
|
883
|
+
const scopeOf = (values, hidden) => ({
|
|
884
|
+
values,
|
|
885
|
+
hidden,
|
|
886
|
+
fields
|
|
887
|
+
});
|
|
888
|
+
const declared = new Set((definition.hidden ?? []).map((h) => h.name));
|
|
889
|
+
const initialHidden = {};
|
|
890
|
+
for (const [name, value] of Object.entries(options.hidden ?? {})) {
|
|
891
|
+
if (!declared.has(name)) initialHidden[name] = value;
|
|
892
|
+
}
|
|
893
|
+
Object.assign(initialHidden, coerceHidden(definition, options.hidden).values);
|
|
894
|
+
const first = computePath(definition, scopeOf({}, initialHidden));
|
|
895
|
+
let state = {
|
|
896
|
+
values: {},
|
|
897
|
+
hidden: initialHidden,
|
|
898
|
+
path: first.path,
|
|
899
|
+
current: first.path[0] ?? null,
|
|
900
|
+
history: [],
|
|
901
|
+
errors: {},
|
|
902
|
+
status: "in_progress",
|
|
903
|
+
ending: null,
|
|
904
|
+
events: []
|
|
905
|
+
};
|
|
906
|
+
const emit = (next) => {
|
|
907
|
+
state = next;
|
|
908
|
+
if (batch) return;
|
|
909
|
+
for (const l of listeners) l();
|
|
910
|
+
};
|
|
911
|
+
const log = (event) => {
|
|
912
|
+
if (!batch) return [...state.events, event];
|
|
913
|
+
state.events.push(event);
|
|
914
|
+
return state.events;
|
|
915
|
+
};
|
|
916
|
+
const stamp = (event) => {
|
|
917
|
+
const at = clock();
|
|
918
|
+
return at === void 0 ? event : { ...event, at };
|
|
919
|
+
};
|
|
920
|
+
const isVisibleIn = (s, id) => s.path.includes(id);
|
|
921
|
+
const errorsFor = (s, ids) => {
|
|
922
|
+
const errors = {};
|
|
923
|
+
for (const id of ids) {
|
|
924
|
+
const field2 = fields.get(id);
|
|
925
|
+
if (!field2 || !isVisibleIn(s, id)) continue;
|
|
926
|
+
const message = validateField(field2, own(s.values, id), { custom: options.validators?.[id] });
|
|
927
|
+
if (message) errors[id] = message;
|
|
928
|
+
}
|
|
929
|
+
return errors;
|
|
930
|
+
};
|
|
931
|
+
const answer = (id, value, event) => {
|
|
932
|
+
if (state.status === "completed" || own(state.values, id) === value) return;
|
|
933
|
+
const values = { ...state.values, [id]: value };
|
|
934
|
+
const { path } = computePath(definition, scopeOf(values, state.hidden));
|
|
935
|
+
const errors = { ...state.errors };
|
|
936
|
+
delete errors[id];
|
|
937
|
+
emit({ ...state, values, path, errors, events: log(event) });
|
|
938
|
+
};
|
|
939
|
+
const engine = {
|
|
940
|
+
definition,
|
|
941
|
+
getState: () => state,
|
|
942
|
+
subscribe(listener) {
|
|
943
|
+
listeners.add(listener);
|
|
944
|
+
return () => listeners.delete(listener);
|
|
945
|
+
},
|
|
946
|
+
idOf(key) {
|
|
947
|
+
const field2 = byKey.get(key);
|
|
948
|
+
if (!field2) throw new Error(`No field with key "${key}".`);
|
|
949
|
+
return field2.id;
|
|
950
|
+
},
|
|
951
|
+
field: (id) => fields.get(id),
|
|
952
|
+
block: (id) => blocks.get(id),
|
|
953
|
+
isVisible: (id) => isVisibleIn(state, id),
|
|
954
|
+
setValue(id, value) {
|
|
955
|
+
if (!fields.has(id)) throw new Error(`No field "${id}".`);
|
|
956
|
+
answer(id, value, stamp({ type: "answer", field: id, value }));
|
|
957
|
+
},
|
|
958
|
+
next() {
|
|
959
|
+
if (!state.current || state.status === "completed") return false;
|
|
960
|
+
const errors = errorsFor(state, [state.current]);
|
|
961
|
+
const events = log(stamp({ type: "next" }));
|
|
962
|
+
if (Object.keys(errors).length) {
|
|
963
|
+
emit({ ...state, errors: { ...state.errors, ...errors }, events });
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
const at = state.path.indexOf(state.current);
|
|
967
|
+
const target = state.path[at + 1];
|
|
968
|
+
if (!target) {
|
|
969
|
+
emit({ ...state, events });
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
emit({ ...state, current: target, history: [...state.history, state.current], events });
|
|
973
|
+
return true;
|
|
974
|
+
},
|
|
975
|
+
back() {
|
|
976
|
+
if (state.status === "completed") return;
|
|
977
|
+
const history = [...state.history];
|
|
978
|
+
let previous = history.pop();
|
|
979
|
+
while (previous && !state.path.includes(previous)) previous = history.pop();
|
|
980
|
+
if (!previous) return;
|
|
981
|
+
emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
|
|
982
|
+
},
|
|
983
|
+
submit() {
|
|
984
|
+
if (state.status === "completed") return false;
|
|
985
|
+
const errors = errorsFor(state, state.path);
|
|
986
|
+
const events = log(stamp({ type: "submit" }));
|
|
987
|
+
if (Object.keys(errors).length) {
|
|
988
|
+
const firstError = state.path.find((id) => own(errors, id)) ?? state.current;
|
|
989
|
+
emit({ ...state, errors, current: firstError, events });
|
|
990
|
+
return false;
|
|
991
|
+
}
|
|
992
|
+
const { ending } = computePath(definition, scopeOf(state.values, state.hidden));
|
|
993
|
+
emit({ ...state, errors: {}, status: "completed", ending, events });
|
|
994
|
+
return true;
|
|
995
|
+
},
|
|
996
|
+
text: (template) => recall(template, { fieldsByKey: byKey, values: state.values, hidden: state.hidden }),
|
|
997
|
+
answers() {
|
|
998
|
+
const out = {};
|
|
999
|
+
for (const id of state.path) {
|
|
1000
|
+
const field2 = fields.get(id);
|
|
1001
|
+
const value = own(state.values, id);
|
|
1002
|
+
if (field2 && value !== void 0) out[id] = normalizeValue(value, field2);
|
|
1003
|
+
}
|
|
1004
|
+
return out;
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
if (options.initialValues) {
|
|
1008
|
+
for (const [id, value] of Object.entries(options.initialValues)) {
|
|
1009
|
+
if (!fields.has(id) || value === void 0) continue;
|
|
1010
|
+
answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
const apply = (event) => {
|
|
1014
|
+
if (event.type === "answer") {
|
|
1015
|
+
if (fields.has(event.field)) {
|
|
1016
|
+
const { at: _at, ...logged } = event;
|
|
1017
|
+
answer(event.field, event.value, logged);
|
|
1018
|
+
}
|
|
1019
|
+
} else if (event.type === "next") engine.next();
|
|
1020
|
+
else if (event.type === "back") engine.back();
|
|
1021
|
+
else if (event.type === "submit") engine.submit();
|
|
1022
|
+
};
|
|
1023
|
+
const finish = () => {
|
|
1024
|
+
batch = false;
|
|
1025
|
+
state = { ...state, events: [...state.events] };
|
|
1026
|
+
};
|
|
1027
|
+
return { engine, apply, finish };
|
|
1028
|
+
}
|
|
1029
|
+
function replay(definition, events, options = {}) {
|
|
1030
|
+
const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true);
|
|
1031
|
+
for (const event of events) apply(event);
|
|
1032
|
+
finish();
|
|
1033
|
+
return engine;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// src/explain.ts
|
|
1037
|
+
function explain(source, blockId, options = {}) {
|
|
1038
|
+
const input = "getState" in source ? { definition: source.definition, values: source.getState().values, hidden: source.getState().hidden } : source;
|
|
1039
|
+
const def = input.definition;
|
|
1040
|
+
const fields = fieldMap(def);
|
|
1041
|
+
const scope = { values: input.values, hidden: input.hidden ?? {}, fields };
|
|
1042
|
+
const block = def.blocks.find((b) => b.id === blockId) ?? def.blocks.find((b) => b.kind === "field" && b.key === blockId);
|
|
1043
|
+
if (!block) throw new Error(`explain: no block "${blockId}".`);
|
|
1044
|
+
const id = block.id;
|
|
1045
|
+
const nameOf = (bid) => {
|
|
1046
|
+
const b = def.blocks.find((x) => x.id === bid);
|
|
1047
|
+
return b?.kind === "field" ? b.key : bid;
|
|
1048
|
+
};
|
|
1049
|
+
const key = nameOf(id);
|
|
1050
|
+
const walk = walkPath(def, scope);
|
|
1051
|
+
const at = walk.scopeAt(id);
|
|
1052
|
+
const rules = (def.logic ?? []).filter((r) => (r.effect === "show" || r.effect === "hide") && r.target === id).map((r) => {
|
|
1053
|
+
const matched = evaluate(r.when, at);
|
|
1054
|
+
const cond = describeCondition(r.when, def, fields);
|
|
1055
|
+
const sentence = r.effect === "hide" ? matched ? `Hidden because ${cond}.` : `Would be hidden if ${cond}.` : matched ? `Shown because ${cond}.` : `Hidden until ${cond}.`;
|
|
1056
|
+
return { ruleId: r.id, effect: r.effect, matched, sentence };
|
|
1057
|
+
});
|
|
1058
|
+
const { path } = walk;
|
|
1059
|
+
const onPath = path.includes(id);
|
|
1060
|
+
const visibleByRules = ruleVisible(def, id, scope);
|
|
1061
|
+
const skippedBy = onPath ? void 0 : findSkippingJump(def, walk, id, nameOf);
|
|
1062
|
+
const visible = onPath && visibleByRules;
|
|
1063
|
+
let validation;
|
|
1064
|
+
if (block.kind === "field") {
|
|
1065
|
+
const value = own(input.values, id);
|
|
1066
|
+
if (!visible) validation = { status: "not-checked" };
|
|
1067
|
+
else {
|
|
1068
|
+
const message = validateField(block, value, { custom: options.validators?.[id] });
|
|
1069
|
+
if (message) validation = { status: "invalid", message };
|
|
1070
|
+
else validation = { status: isEmptyValue(value, block) ? "unanswered" : "valid" };
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
let summary;
|
|
1074
|
+
if (block.kind === "page-break") summary = "Page breaks are never on the path; they only group blocks into pages.";
|
|
1075
|
+
else if (visible) summary = rules.find((r) => r.effect === "show" && r.matched)?.sentence ?? "Visible: no rule hides it.";
|
|
1076
|
+
else if (block.kind === "field" && withheld(def, block)) {
|
|
1077
|
+
summary = "Not asked: the form is anonymous and this field identifies the respondent.";
|
|
1078
|
+
} else if (!visibleByRules) {
|
|
1079
|
+
summary = rules.find((r) => r.effect === "hide" && r.matched)?.sentence ?? rules.find((r) => r.effect === "show")?.sentence ?? "Hidden by logic.";
|
|
1080
|
+
} else if (skippedBy) summary = skippedBy.sentence;
|
|
1081
|
+
else summary = "Not on the path.";
|
|
1082
|
+
return {
|
|
1083
|
+
blockId: id,
|
|
1084
|
+
key,
|
|
1085
|
+
kind: block.kind,
|
|
1086
|
+
onPath,
|
|
1087
|
+
ruleVisible: visibleByRules,
|
|
1088
|
+
visible,
|
|
1089
|
+
...skippedBy ? { skippedBy } : {},
|
|
1090
|
+
rules,
|
|
1091
|
+
...validation ? { validation } : {},
|
|
1092
|
+
summary
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function findSkippingJump(def, walk, id, nameOf) {
|
|
1096
|
+
const list = steps(def);
|
|
1097
|
+
const index = new Map(list.map((b, i) => [b.id, i]));
|
|
1098
|
+
const at = index.get(id);
|
|
1099
|
+
if (at === void 0) return void 0;
|
|
1100
|
+
const jumps = (def.logic ?? []).filter((r) => r.effect === "jump");
|
|
1101
|
+
for (const from of walk.path) {
|
|
1102
|
+
const i = index.get(from);
|
|
1103
|
+
if (i >= at) break;
|
|
1104
|
+
const scope = walk.scopeAt(from, true);
|
|
1105
|
+
const jump = jumps.find((r) => r.from === from && evaluate(r.when, scope));
|
|
1106
|
+
if (!jump) continue;
|
|
1107
|
+
const cond = describeCondition(jump.when, def, fieldMap(def));
|
|
1108
|
+
if (typeof jump.to === "object") {
|
|
1109
|
+
return {
|
|
1110
|
+
ruleId: jump.id,
|
|
1111
|
+
from,
|
|
1112
|
+
to: jump.to,
|
|
1113
|
+
sentence: `Skipped: ${nameOf(from)} jumps to the "${jump.to.ending}" ending because ${cond}.`
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const target = index.get(jump.to);
|
|
1117
|
+
if (target !== void 0 && target > i && at < target) {
|
|
1118
|
+
return {
|
|
1119
|
+
ruleId: jump.id,
|
|
1120
|
+
from,
|
|
1121
|
+
to: jump.to,
|
|
1122
|
+
sentence: `Skipped: ${nameOf(from)} jumps to ${nameOf(jump.to)} because ${cond}.`
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
return void 0;
|
|
1127
|
+
}
|
|
1128
|
+
var OPS = {
|
|
1129
|
+
eq: "is",
|
|
1130
|
+
neq: "is not",
|
|
1131
|
+
lt: "is less than",
|
|
1132
|
+
lte: "is at most",
|
|
1133
|
+
gt: "is more than",
|
|
1134
|
+
gte: "is at least",
|
|
1135
|
+
in: "is one of",
|
|
1136
|
+
contains: "includes",
|
|
1137
|
+
answered: "is answered",
|
|
1138
|
+
empty: "is unanswered"
|
|
1139
|
+
};
|
|
1140
|
+
function describeValue(value, field2) {
|
|
1141
|
+
if (value === void 0 || value === null) return "nothing";
|
|
1142
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
1143
|
+
if (typeof value === "number") return String(value);
|
|
1144
|
+
if (typeof value === "string") {
|
|
1145
|
+
const label = field2 ? choiceOptions(field2).find((o) => o.value === value)?.label : void 0;
|
|
1146
|
+
return `"${label ?? value}"`;
|
|
1147
|
+
}
|
|
1148
|
+
if (Array.isArray(value)) return value.map((v) => describeValue(v, field2)).join(", ");
|
|
1149
|
+
return JSON.stringify(value);
|
|
1150
|
+
}
|
|
1151
|
+
function describeOperand(operand, def, fields) {
|
|
1152
|
+
if ("field" in operand) return fields.get(operand.field)?.key ?? operand.field;
|
|
1153
|
+
if ("hidden" in operand) return `the ${operand.hidden} input`;
|
|
1154
|
+
return describeValue(operand.value);
|
|
1155
|
+
}
|
|
1156
|
+
function describeCondition(condition, def, fields = fieldMap(def)) {
|
|
1157
|
+
const group = (c) => {
|
|
1158
|
+
const text = describeCondition(c, def, fields);
|
|
1159
|
+
return "all" in c || "any" in c ? `(${text})` : text;
|
|
1160
|
+
};
|
|
1161
|
+
if ("all" in condition) return condition.all.map(group).join(" and ");
|
|
1162
|
+
if ("any" in condition) return condition.any.map(group).join(" or ");
|
|
1163
|
+
if ("not" in condition) return `it is not the case that ${group(condition.not)}`;
|
|
1164
|
+
const left = describeOperand(condition.left, def, fields);
|
|
1165
|
+
const op = OPS[condition.op] ?? condition.op;
|
|
1166
|
+
if (!condition.right) return `${left} ${op}`;
|
|
1167
|
+
const field2 = "field" in condition.left ? fields.get(condition.left.field) : void 0;
|
|
1168
|
+
const right = "value" in condition.right ? describeValue(condition.right.value, field2) : describeOperand(condition.right, def, fields);
|
|
1169
|
+
return `${left} ${op} ${right}`;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
export { MAX_HIDDEN_STRING_LENGTH, REQUIRED_MESSAGE, canonicalJson, choiceOptions, coerceHidden, coerceHiddenValue, computePath, createForm, defineForm, describeCondition, evaluate, explain, f, fingerprint, getFieldType, isEmptyValue, isFormEvent, isJson, isSensitive, listFieldTypes, normalizeValue, parseForm, recall, registerFieldType, replay, ruleVisible, steps, summarizeValue, validateField, when };
|
|
1173
|
+
//# sourceMappingURL=index.js.map
|
|
1174
|
+
//# sourceMappingURL=index.js.map
|