@marktiderman/genesis-forms 0.1.1 → 0.3.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 +121 -17
- package/conformance/{after-class-back-after-a-jump.scenario.json → event-feedback-back-after-a-jump.scenario.json} +21 -21
- package/conformance/{after-class-contact-yes-completed.scenario.json → event-feedback-contact-yes-completed.scenario.json} +21 -21
- package/conformance/{after-class-contact-yes-requires-name-and-email.scenario.json → event-feedback-contact-yes-requires-name-and-email.scenario.json} +19 -19
- package/conformance/{after-class-happy-path.scenario.json → event-feedback-happy-path.scenario.json} +24 -24
- package/conformance/{after-class-invalid-email-rejected.scenario.json → event-feedback-invalid-email-rejected.scenario.json} +19 -19
- package/conformance/{after-class-low-rating-skips-went-well.scenario.json → event-feedback-low-rating-skips-went-well.scenario.json} +21 -21
- package/conformance/{after-class-server-drops-unknown-and-hidden-answers.scenario.json → event-feedback-server-drops-unknown-and-hidden-answers.scenario.json} +22 -22
- package/conformance/{after-class-server-ignores-a-client-sent-coach.scenario.json → event-feedback-server-ignores-a-client-sent-owner.scenario.json} +24 -24
- package/conformance/{monthly-pulse-default-ending.scenario.json → monthly-check-in-default-ending.scenario.json} +6 -6
- package/conformance/{monthly-pulse-promoter-ending-via-jump.scenario.json → monthly-check-in-promoter-ending-via-jump.scenario.json} +6 -6
- package/dist/destinations.cjs +1211 -0
- package/dist/destinations.cjs.map +1 -0
- package/dist/destinations.d.cts +465 -0
- package/dist/destinations.d.ts +465 -0
- package/dist/destinations.js +1169 -0
- package/dist/destinations.js.map +1 -0
- package/dist/{engine-LsPRTzt3.d.cts → engine-CPLtKVew.d.cts} +7 -6
- package/dist/{engine-Cp5t2_Am.d.ts → engine-DOrPgFDw.d.ts} +7 -6
- package/dist/index.cjs +410 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -18
- package/dist/index.d.ts +122 -18
- package/dist/index.js +399 -25
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +350 -29
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +13 -12
- package/dist/server.d.ts +13 -12
- package/dist/server.js +350 -29
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +354 -29
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -3
- package/dist/testing.d.ts +4 -3
- package/dist/testing.js +354 -29
- package/dist/testing.js.map +1 -1
- package/dist/{validate-j8Z188mF.d.cts → types-BBKBQ8sp.d.cts} +9 -29
- package/dist/{validate-j8Z188mF.d.ts → types-BBKBQ8sp.d.ts} +9 -29
- package/dist/validate-BXXqi0fq.d.cts +30 -0
- package/dist/validate-BjNwF1UE.d.ts +30 -0
- package/package.json +14 -2
package/dist/testing.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-
|
|
2
|
-
export { d as compactEvents } from './engine-
|
|
3
|
-
import { J as Json,
|
|
1
|
+
import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-CPLtKVew.cjs';
|
|
2
|
+
export { d as compactEvents } from './engine-CPLtKVew.cjs';
|
|
3
|
+
import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.cjs';
|
|
4
|
+
import './validate-BXXqi0fq.cjs';
|
|
4
5
|
|
|
5
6
|
interface CoverageOptions {
|
|
6
7
|
/** Stop after this many explored states and report `truncated`. Default 5000. */
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-
|
|
2
|
-
export { d as compactEvents } from './engine-
|
|
3
|
-
import { J as Json,
|
|
1
|
+
import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-DOrPgFDw.js';
|
|
2
|
+
export { d as compactEvents } from './engine-DOrPgFDw.js';
|
|
3
|
+
import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.js';
|
|
4
|
+
import './validate-BjNwF1UE.js';
|
|
4
5
|
|
|
5
6
|
interface CoverageOptions {
|
|
6
7
|
/** Stop after this many explored states and report `truncated`. Default 5000. */
|
package/dist/testing.js
CHANGED
|
@@ -1,3 +1,185 @@
|
|
|
1
|
+
// src/structured.ts
|
|
2
|
+
var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
3
|
+
function intProp(field, name, fallback, floor = 1) {
|
|
4
|
+
const v = field.props?.[name];
|
|
5
|
+
return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : fallback;
|
|
6
|
+
}
|
|
7
|
+
function optionalIntProp(field, name, floor = 0) {
|
|
8
|
+
const v = field.props?.[name];
|
|
9
|
+
return typeof v === "number" && Number.isInteger(v) && v >= floor ? v : void 0;
|
|
10
|
+
}
|
|
11
|
+
var blank = (v) => typeof v !== "string" || v.trim() === "";
|
|
12
|
+
var blankText = (v) => typeof v === "string" && v.trim() === "";
|
|
13
|
+
var emptyList = (value, isBlank) => value === void 0 || value === null || Array.isArray(value) && value.every(isBlank);
|
|
14
|
+
var plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
15
|
+
function listLimits(field) {
|
|
16
|
+
return { min: optionalIntProp(field, "min") ?? 0, max: intProp(field, "max", 10), itemMaxLength: intProp(field, "itemMaxLength", 500) };
|
|
17
|
+
}
|
|
18
|
+
var listItems = (value) => Array.isArray(value) ? value.filter((v) => typeof v === "string" && v.trim() !== "") : [];
|
|
19
|
+
var MAX_TABLE_COLUMNS = 6;
|
|
20
|
+
var MAX_TABLE_CELL_LENGTH = 2e3;
|
|
21
|
+
function tableColumns(field) {
|
|
22
|
+
const raw = field.props?.columns;
|
|
23
|
+
if (!Array.isArray(raw)) return [];
|
|
24
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const c of raw) {
|
|
27
|
+
if (!isObject(c) || typeof c.key !== "string" || !c.key || seen.has(c.key)) continue;
|
|
28
|
+
seen.add(c.key);
|
|
29
|
+
const column = { key: c.key, label: typeof c.label === "string" ? c.label : c.key };
|
|
30
|
+
if (c.long === true) column.long = true;
|
|
31
|
+
out.push(column);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function tableLimits(field) {
|
|
36
|
+
return { minRows: optionalIntProp(field, "minRows") ?? 0, maxRows: intProp(field, "maxRows", 3) };
|
|
37
|
+
}
|
|
38
|
+
var filledRows = (value) => Array.isArray(value) ? value.filter((r) => isObject(r) && Object.values(r).some((c) => !blank(c))) : [];
|
|
39
|
+
var MAX_TIMESTAMP_SECONDS = 86400;
|
|
40
|
+
function formatTimestamp(seconds) {
|
|
41
|
+
const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0;
|
|
42
|
+
const h = Math.floor(total / 3600);
|
|
43
|
+
const m = Math.floor(total % 3600 / 60);
|
|
44
|
+
const s = String(total % 60).padStart(2, "0");
|
|
45
|
+
return h ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
|
|
46
|
+
}
|
|
47
|
+
var isTimestamp = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= MAX_TIMESTAMP_SECONDS;
|
|
48
|
+
var notesWithText = (value) => Array.isArray(value) ? value.filter((n) => isObject(n) && !blank(n.text)) : [];
|
|
49
|
+
var MAX_HIGHLIGHT_NOTE_LENGTH = 1e3;
|
|
50
|
+
var MAX_HIGHLIGHT_QUOTE_LENGTH = 2e3;
|
|
51
|
+
var HIGHLIGHT_NOTE_MESSAGE = "Add a note to each highlight.";
|
|
52
|
+
var sourceText = (field) => typeof field.props?.text === "string" && field.props.text ? field.props.text : void 0;
|
|
53
|
+
var structuredFieldTypes = [
|
|
54
|
+
{
|
|
55
|
+
type: "list",
|
|
56
|
+
label: "List",
|
|
57
|
+
category: "text",
|
|
58
|
+
value: { json: true },
|
|
59
|
+
// logic.ts `contains` on an array is exact item equality, same as multi-choice, so it is offered.
|
|
60
|
+
operators: ["contains", "answered", "empty"],
|
|
61
|
+
a11y: { semantics: "group" },
|
|
62
|
+
isEmpty: (value) => emptyList(value, blankText),
|
|
63
|
+
validate: (value, field) => {
|
|
64
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return "Enter a list of answers.";
|
|
65
|
+
const { min, max, itemMaxLength } = listLimits(field);
|
|
66
|
+
if (value.some((v) => v.trim().length > itemMaxLength)) return `Keep each item under ${itemMaxLength} characters.`;
|
|
67
|
+
const count = listItems(value).length;
|
|
68
|
+
if (count < min) return `Add at least ${plural(min, "item")}.`;
|
|
69
|
+
if (count > max) return `Add at most ${plural(max, "item")}.`;
|
|
70
|
+
return null;
|
|
71
|
+
},
|
|
72
|
+
normalize: (value) => Array.isArray(value) ? listItems(value).map((v) => v.trim()) : value,
|
|
73
|
+
summarize: (value) => listItems(value).map((v) => v.trim()).join("; "),
|
|
74
|
+
samples: (field) => {
|
|
75
|
+
const { min, max } = listLimits(field);
|
|
76
|
+
return [Array.from({ length: Math.min(Math.max(1, min), max) }, (_, i) => `Item ${i + 1}`)];
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "table",
|
|
81
|
+
label: "Table",
|
|
82
|
+
category: "advanced",
|
|
83
|
+
value: { json: true },
|
|
84
|
+
operators: ["answered", "empty"],
|
|
85
|
+
a11y: { semantics: "group" },
|
|
86
|
+
isEmpty: (value) => emptyList(value, (r) => isObject(r) && Object.values(r).every(blankText)),
|
|
87
|
+
validate: (value, field) => {
|
|
88
|
+
const columns = tableColumns(field);
|
|
89
|
+
if (!columns.length) return "This question has no columns.";
|
|
90
|
+
if (!Array.isArray(value) || !value.every(isObject)) return "Fill in the rows.";
|
|
91
|
+
const keys = new Set(columns.map((c) => c.key));
|
|
92
|
+
for (const row of value) {
|
|
93
|
+
for (const [key, cell] of Object.entries(row)) {
|
|
94
|
+
if (!keys.has(key)) return "An answer names a column this question doesn't have.";
|
|
95
|
+
if (typeof cell !== "string") return "Fill in the rows with text.";
|
|
96
|
+
if (cell.trim().length > MAX_TABLE_CELL_LENGTH) return `Keep each answer under ${MAX_TABLE_CELL_LENGTH} characters.`;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const { minRows, maxRows } = tableLimits(field);
|
|
100
|
+
const count = filledRows(value).length;
|
|
101
|
+
if (count < minRows) return `Fill in at least ${plural(minRows, "row")}.`;
|
|
102
|
+
if (count > maxRows) return `Fill in at most ${plural(maxRows, "row")}.`;
|
|
103
|
+
return null;
|
|
104
|
+
},
|
|
105
|
+
normalize: (value) => Array.isArray(value) ? filledRows(value).map((row) => {
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [key, cell] of Object.entries(row)) if (typeof cell === "string") out[key] = cell.trim();
|
|
108
|
+
return out;
|
|
109
|
+
}) : value,
|
|
110
|
+
summarize: (value, field) => {
|
|
111
|
+
const columns = tableColumns(field);
|
|
112
|
+
return filledRows(value).map((row) => columns.filter((c) => !blank(row[c.key])).map((c) => `${c.label}: ${row[c.key].trim()}`).join(" \xB7 ")).join(" / ");
|
|
113
|
+
},
|
|
114
|
+
samples: (field) => {
|
|
115
|
+
const columns = tableColumns(field);
|
|
116
|
+
if (!columns.length) return [];
|
|
117
|
+
const { minRows, maxRows } = tableLimits(field);
|
|
118
|
+
const row = Object.fromEntries(columns.map((c) => [c.key, "sample"]));
|
|
119
|
+
return [Array.from({ length: Math.min(Math.max(1, minRows), maxRows) }, () => ({ ...row }))];
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "media-notes",
|
|
124
|
+
label: "Notes at moments",
|
|
125
|
+
category: "media",
|
|
126
|
+
value: { json: true },
|
|
127
|
+
operators: ["answered", "empty"],
|
|
128
|
+
a11y: { semantics: "group" },
|
|
129
|
+
isEmpty: (value) => emptyList(value, (n) => isObject(n) && blankText(n.text)),
|
|
130
|
+
validate: (value, field) => {
|
|
131
|
+
if (!Array.isArray(value)) return "Add notes.";
|
|
132
|
+
const textMaxLength = intProp(field, "textMaxLength", 1e3);
|
|
133
|
+
for (const note of value) {
|
|
134
|
+
if (!isObject(note) || typeof note.text !== "string") return "Add notes.";
|
|
135
|
+
if (!isTimestamp(note.at)) return "Each note needs a time.";
|
|
136
|
+
if (note.text.trim().length > textMaxLength) return `Keep each note under ${textMaxLength} characters.`;
|
|
137
|
+
}
|
|
138
|
+
const maxNotes = intProp(field, "maxNotes", 20);
|
|
139
|
+
return notesWithText(value).length > maxNotes ? `Add at most ${plural(maxNotes, "note")}.` : null;
|
|
140
|
+
},
|
|
141
|
+
normalize: (value) => Array.isArray(value) ? notesWithText(value).map((n) => ({ at: n.at, text: n.text.trim() })).sort((a, b) => a.at - b.at) : value,
|
|
142
|
+
summarize: (value) => notesWithText(value).map((n) => ({ at: typeof n.at === "number" ? n.at : 0, text: n.text.trim() })).sort((a, b) => a.at - b.at).map((n) => `${formatTimestamp(n.at)} ${n.text}`).join("; "),
|
|
143
|
+
samples: () => [[{ at: 0, text: "sample" }]]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: "text-highlights",
|
|
147
|
+
label: "Highlights",
|
|
148
|
+
category: "media",
|
|
149
|
+
value: { json: true },
|
|
150
|
+
operators: ["answered", "empty"],
|
|
151
|
+
a11y: { semantics: "group" },
|
|
152
|
+
isEmpty: (value) => emptyList(value, () => false),
|
|
153
|
+
validate: (value, field) => {
|
|
154
|
+
if (!Array.isArray(value)) return "Highlight a passage.";
|
|
155
|
+
const maxHighlights = intProp(field, "maxHighlights", 20);
|
|
156
|
+
if (value.length > maxHighlights) return `Add at most ${plural(maxHighlights, "highlight")}.`;
|
|
157
|
+
const text = sourceText(field);
|
|
158
|
+
for (const h of value) {
|
|
159
|
+
if (!isObject(h) || !Number.isInteger(h.start) || !Number.isInteger(h.end) || typeof h.quote !== "string" || typeof h.note !== "string") {
|
|
160
|
+
return "Highlight a passage of the text.";
|
|
161
|
+
}
|
|
162
|
+
const start = h.start;
|
|
163
|
+
const end = h.end;
|
|
164
|
+
if (start < 0 || end <= start || h.quote.length !== end - start) return "Highlight a passage of the text.";
|
|
165
|
+
if (end - start > MAX_HIGHLIGHT_QUOTE_LENGTH) return `Highlight under ${MAX_HIGHLIGHT_QUOTE_LENGTH} characters at a time.`;
|
|
166
|
+
if (text !== void 0 && (end > text.length || text.slice(start, end) !== h.quote)) return "A highlight doesn't match the text.";
|
|
167
|
+
if (h.note.trim() === "") return HIGHLIGHT_NOTE_MESSAGE;
|
|
168
|
+
if (h.note.trim().length > MAX_HIGHLIGHT_NOTE_LENGTH) return `Keep each note under ${MAX_HIGHLIGHT_NOTE_LENGTH} characters.`;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
},
|
|
172
|
+
normalize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => ({ start: h.start, end: h.end, quote: h.quote, note: String(h.note).trim() })).sort((a, b) => a.start - b.start || a.end - b.end) : value,
|
|
173
|
+
summarize: (value) => Array.isArray(value) ? value.filter(isObject).map((h) => `"${String(h.quote)}" \u2014 ${String(h.note).trim()}`).join("; ") : "",
|
|
174
|
+
samples: (field) => {
|
|
175
|
+
const text = sourceText(field);
|
|
176
|
+
if (text === void 0) return [[{ start: 0, end: 6, quote: "sample", note: "sample" }]];
|
|
177
|
+
const end = Math.min(text.length, 20);
|
|
178
|
+
return [[{ start: 0, end, quote: text.slice(0, end), note: "sample" }]];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
];
|
|
182
|
+
|
|
1
183
|
// src/fields.ts
|
|
2
184
|
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
3
185
|
var globalScope = globalThis;
|
|
@@ -179,7 +361,7 @@ var builtins = [
|
|
|
179
361
|
summarize: (value) => value ? "Yes" : "No"
|
|
180
362
|
}
|
|
181
363
|
];
|
|
182
|
-
for (const manifest of builtins) registerFieldType(manifest);
|
|
364
|
+
for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
|
|
183
365
|
|
|
184
366
|
// src/events.ts
|
|
185
367
|
function isPlainObject(value) {
|
|
@@ -228,6 +410,7 @@ function compactEvents(events) {
|
|
|
228
410
|
|
|
229
411
|
// src/hidden.ts
|
|
230
412
|
var MAX_HIDDEN_STRING_LENGTH = 1e3;
|
|
413
|
+
var DECIMAL = /^-?\d+(\.\d+)?$/;
|
|
231
414
|
function coerceHiddenValue(value, type) {
|
|
232
415
|
if (typeof value === "string" && value.length > MAX_HIDDEN_STRING_LENGTH) return void 0;
|
|
233
416
|
if (type === "string") {
|
|
@@ -237,7 +420,7 @@ function coerceHiddenValue(value, type) {
|
|
|
237
420
|
}
|
|
238
421
|
if (type === "number") {
|
|
239
422
|
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
240
|
-
if (typeof value !== "string" ||
|
|
423
|
+
if (typeof value !== "string" || !DECIMAL.test(value)) return void 0;
|
|
241
424
|
const n = Number(value);
|
|
242
425
|
return Number.isFinite(n) ? n : void 0;
|
|
243
426
|
}
|
|
@@ -562,7 +745,7 @@ function build(definition, options, replaying = false) {
|
|
|
562
745
|
for (const id of state.path) {
|
|
563
746
|
const field = fields.get(id);
|
|
564
747
|
const value = own(state.values, id);
|
|
565
|
-
if (field && value
|
|
748
|
+
if (field && !isEmptyValue(value, field)) out[id] = normalizeValue(value, field);
|
|
566
749
|
}
|
|
567
750
|
return out;
|
|
568
751
|
}
|
|
@@ -667,6 +850,10 @@ function domainOf(field, extra, maxOptions) {
|
|
|
667
850
|
extra = extra.map((v) => typeof v === "string" ? [v] : v);
|
|
668
851
|
break;
|
|
669
852
|
}
|
|
853
|
+
case "list":
|
|
854
|
+
base = getFieldType(field.type)?.samples?.(field) ?? [];
|
|
855
|
+
extra = extra.map((v) => typeof v === "string" ? [v] : v);
|
|
856
|
+
break;
|
|
670
857
|
case "email":
|
|
671
858
|
base = ["ada@example.com"];
|
|
672
859
|
break;
|
|
@@ -770,7 +957,31 @@ function coverage(form, opts = {}) {
|
|
|
770
957
|
}
|
|
771
958
|
|
|
772
959
|
// src/parse.ts
|
|
773
|
-
|
|
960
|
+
function shown(v) {
|
|
961
|
+
let text;
|
|
962
|
+
try {
|
|
963
|
+
text = JSON.stringify(v);
|
|
964
|
+
} catch {
|
|
965
|
+
text = void 0;
|
|
966
|
+
}
|
|
967
|
+
text ??= typeof v === "bigint" ? `${v}n` : typeof v;
|
|
968
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
969
|
+
}
|
|
970
|
+
var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
971
|
+
var isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
972
|
+
var LITERAL_TYPE = {
|
|
973
|
+
"short-text": "string",
|
|
974
|
+
"long-text": "string",
|
|
975
|
+
email: "string",
|
|
976
|
+
phone: "string",
|
|
977
|
+
rating: "number",
|
|
978
|
+
nps: "number",
|
|
979
|
+
"single-choice": "string",
|
|
980
|
+
"multi-choice": "string",
|
|
981
|
+
"yes-no": "boolean",
|
|
982
|
+
list: "string"
|
|
983
|
+
};
|
|
984
|
+
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
774
985
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
775
986
|
"eq",
|
|
776
987
|
"neq",
|
|
@@ -810,7 +1021,48 @@ function parseForm(input) {
|
|
|
810
1021
|
}
|
|
811
1022
|
return true;
|
|
812
1023
|
};
|
|
813
|
-
|
|
1024
|
+
const optionalInt = (v, path, floor) => {
|
|
1025
|
+
if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
|
|
1026
|
+
};
|
|
1027
|
+
const ordered = (low, high, path, what) => {
|
|
1028
|
+
if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
|
|
1029
|
+
};
|
|
1030
|
+
const structuredProps = (type, props, path) => {
|
|
1031
|
+
if (type === "list") {
|
|
1032
|
+
optionalInt(props.min, `${path}.min`, 0);
|
|
1033
|
+
optionalInt(props.max, `${path}.max`, 1);
|
|
1034
|
+
optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
|
|
1035
|
+
optionalText(props.placeholder, `${path}.placeholder`);
|
|
1036
|
+
ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
|
|
1037
|
+
} else if (type === "table") {
|
|
1038
|
+
const columns = props.columns;
|
|
1039
|
+
if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
|
|
1040
|
+
issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
|
|
1041
|
+
} else {
|
|
1042
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1043
|
+
columns.forEach((c, j) => {
|
|
1044
|
+
const at = `${path}.columns[${j}]`;
|
|
1045
|
+
if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
|
|
1046
|
+
if (name(c.key, `${at}.key`, "A column key")) {
|
|
1047
|
+
if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
|
|
1048
|
+
seen.add(c.key);
|
|
1049
|
+
}
|
|
1050
|
+
text(c.label, `${at}.label`);
|
|
1051
|
+
optionalBoolean(c.long, `${at}.long`);
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
optionalInt(props.minRows, `${path}.minRows`, 0);
|
|
1055
|
+
optionalInt(props.maxRows, `${path}.maxRows`, 1);
|
|
1056
|
+
ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
|
|
1057
|
+
} else if (type === "media-notes") {
|
|
1058
|
+
optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
|
|
1059
|
+
optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
|
|
1060
|
+
} else if (type === "text-highlights") {
|
|
1061
|
+
optionalText(props.text, `${path}.text`);
|
|
1062
|
+
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
if (!isObject2(input)) {
|
|
814
1066
|
issue("", "A form definition must be an object.");
|
|
815
1067
|
return fail();
|
|
816
1068
|
}
|
|
@@ -819,19 +1071,19 @@ function parseForm(input) {
|
|
|
819
1071
|
text(input.title, "title");
|
|
820
1072
|
optionalText(input.description, "description");
|
|
821
1073
|
if (input.layout !== void 0) {
|
|
822
|
-
if (!
|
|
1074
|
+
if (!isObject2(input.layout)) issue("layout", "Must be an object.");
|
|
823
1075
|
else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
|
|
824
1076
|
issue("layout.mode", "Must be focus or classic.");
|
|
825
1077
|
}
|
|
826
1078
|
}
|
|
827
1079
|
if (input.settings !== void 0) {
|
|
828
|
-
if (!
|
|
1080
|
+
if (!isObject2(input.settings)) issue("settings", "Must be an object.");
|
|
829
1081
|
else {
|
|
830
1082
|
optionalBoolean(input.settings.anonymous, "settings.anonymous");
|
|
831
1083
|
optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
|
|
832
1084
|
}
|
|
833
1085
|
}
|
|
834
|
-
if (input.meta !== void 0 && (!
|
|
1086
|
+
if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
|
|
835
1087
|
issue("meta", "Must be a JSON object.");
|
|
836
1088
|
}
|
|
837
1089
|
if (!Array.isArray(input.blocks)) {
|
|
@@ -843,9 +1095,10 @@ function parseForm(input) {
|
|
|
843
1095
|
const keys = /* @__PURE__ */ new Map();
|
|
844
1096
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
845
1097
|
const order = /* @__PURE__ */ new Map();
|
|
1098
|
+
const choices = /* @__PURE__ */ new Map();
|
|
846
1099
|
input.blocks.forEach((block, i) => {
|
|
847
1100
|
const path = `blocks[${i}]`;
|
|
848
|
-
if (!
|
|
1101
|
+
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
849
1102
|
issue(path, "Every block needs a string id.");
|
|
850
1103
|
return;
|
|
851
1104
|
}
|
|
@@ -866,18 +1119,30 @@ function parseForm(input) {
|
|
|
866
1119
|
issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
|
|
867
1120
|
}
|
|
868
1121
|
if (block.props !== void 0) {
|
|
869
|
-
if (!
|
|
1122
|
+
if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
870
1123
|
else if (block.props.options !== void 0) {
|
|
871
1124
|
const options = block.props.options;
|
|
872
1125
|
if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
|
|
873
1126
|
else {
|
|
874
1127
|
options.forEach((o, j) => {
|
|
875
1128
|
const at = `${path}.props.options[${j}]`;
|
|
876
|
-
if (!
|
|
1129
|
+
if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
877
1130
|
else optionalText(o.label, `${at}.label`);
|
|
878
1131
|
});
|
|
1132
|
+
choices.set(
|
|
1133
|
+
block.id,
|
|
1134
|
+
new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
|
|
1135
|
+
);
|
|
879
1136
|
}
|
|
880
1137
|
}
|
|
1138
|
+
const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
|
|
1139
|
+
if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
|
|
1140
|
+
issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
|
|
1141
|
+
}
|
|
1142
|
+
if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
|
|
1143
|
+
issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
|
|
1144
|
+
}
|
|
1145
|
+
if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
|
|
881
1146
|
}
|
|
882
1147
|
if (validKey) {
|
|
883
1148
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
@@ -894,9 +1159,10 @@ function parseForm(input) {
|
|
|
894
1159
|
}
|
|
895
1160
|
});
|
|
896
1161
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
1162
|
+
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
897
1163
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
898
1164
|
(Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
|
|
899
|
-
if (!
|
|
1165
|
+
if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
|
|
900
1166
|
issue(`hidden[${i}]`, "Each hidden input needs a name.");
|
|
901
1167
|
return;
|
|
902
1168
|
}
|
|
@@ -909,13 +1175,15 @@ function parseForm(input) {
|
|
|
909
1175
|
issue(`hidden[${i}].type`, "Must be string, number or boolean.");
|
|
910
1176
|
}
|
|
911
1177
|
optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
|
|
1178
|
+
optionalBoolean(h.identifying, `hidden[${i}].identifying`);
|
|
912
1179
|
hiddenNames.add(h.name);
|
|
1180
|
+
if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
|
|
913
1181
|
});
|
|
914
1182
|
const endingIds = /* @__PURE__ */ new Set();
|
|
915
1183
|
if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
|
|
916
1184
|
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
917
1185
|
const path = `endings[${i}]`;
|
|
918
|
-
if (!
|
|
1186
|
+
if (!isObject2(e)) {
|
|
919
1187
|
issue(path, "Each ending must be an object with an id and a title.");
|
|
920
1188
|
return;
|
|
921
1189
|
}
|
|
@@ -927,7 +1195,7 @@ function parseForm(input) {
|
|
|
927
1195
|
});
|
|
928
1196
|
const checkOperand = (o, path, reads) => {
|
|
929
1197
|
if (o === void 0) return;
|
|
930
|
-
if (!
|
|
1198
|
+
if (!isObject2(o)) return issue(path, "An operand must be an object.");
|
|
931
1199
|
if ("field" in o) {
|
|
932
1200
|
if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
|
|
933
1201
|
reads.push(o.field);
|
|
@@ -940,7 +1208,7 @@ function parseForm(input) {
|
|
|
940
1208
|
}
|
|
941
1209
|
};
|
|
942
1210
|
const checkCondition = (c, path, reads, depth = 0) => {
|
|
943
|
-
if (!
|
|
1211
|
+
if (!isObject2(c)) return issue(path, "A condition must be an object.");
|
|
944
1212
|
if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
|
|
945
1213
|
if ("all" in c || "any" in c) {
|
|
946
1214
|
const group = "all" in c ? "all" : "any";
|
|
@@ -949,7 +1217,7 @@ function parseForm(input) {
|
|
|
949
1217
|
return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
|
|
950
1218
|
}
|
|
951
1219
|
if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
|
|
952
|
-
if (typeof c.op !== "string" || !
|
|
1220
|
+
if (typeof c.op !== "string" || !isObject2(c.left)) {
|
|
953
1221
|
return issue(path, "A comparison needs an `op` and a `left` operand.");
|
|
954
1222
|
}
|
|
955
1223
|
if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
|
|
@@ -958,8 +1226,45 @@ function parseForm(input) {
|
|
|
958
1226
|
if (typeof c.left.field === "string") {
|
|
959
1227
|
const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
|
|
960
1228
|
if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
|
|
961
|
-
issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
1229
|
+
return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
checkLiterals(c.op, c.left, c.right, path);
|
|
1233
|
+
};
|
|
1234
|
+
const checkLiterals = (op, left, right, path) => {
|
|
1235
|
+
if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
|
|
1236
|
+
const literal = isObject2(right) && "value" in right;
|
|
1237
|
+
if (right !== void 0 && !literal) return;
|
|
1238
|
+
let what = "the left side";
|
|
1239
|
+
let type;
|
|
1240
|
+
let options;
|
|
1241
|
+
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1242
|
+
const fieldType = fieldTypes.get(left.field);
|
|
1243
|
+
what = `"${keyOf(left.field)}"`;
|
|
1244
|
+
type = LITERAL_TYPE[fieldType];
|
|
1245
|
+
if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
|
|
1246
|
+
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1247
|
+
what = `hidden input "${left.hidden}"`;
|
|
1248
|
+
type = hiddenTypes.get(left.hidden);
|
|
1249
|
+
}
|
|
1250
|
+
const outcome = op === "neq" ? "always true" : "never true";
|
|
1251
|
+
let values;
|
|
1252
|
+
if (op === "in") {
|
|
1253
|
+
if (!literal || !Array.isArray(right.value)) {
|
|
1254
|
+
return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
|
|
962
1255
|
}
|
|
1256
|
+
values = right.value;
|
|
1257
|
+
} else if (!literal) return;
|
|
1258
|
+
else values = [right.value];
|
|
1259
|
+
if (ORDERING.has(op)) {
|
|
1260
|
+
if (type !== void 0 && type !== "number") {
|
|
1261
|
+
return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
|
|
1262
|
+
}
|
|
1263
|
+
type = "number";
|
|
1264
|
+
}
|
|
1265
|
+
for (const v of values) {
|
|
1266
|
+
const message = type !== void 0 && typeof v !== type ? `${what} holds a ${type}, but ${shown(v)} is not one; this comparison is ${outcome}.` : options && typeof v === "string" && !options.has(v) ? `${shown(v)} is not an option of ${what}; this comparison is ${outcome}.` : null;
|
|
1267
|
+
if (message) warnings.push({ path: `${path}.right`, message });
|
|
963
1268
|
}
|
|
964
1269
|
};
|
|
965
1270
|
const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
|
|
@@ -967,7 +1272,7 @@ function parseForm(input) {
|
|
|
967
1272
|
if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
|
|
968
1273
|
(Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
|
|
969
1274
|
const path = `logic[${i}]`;
|
|
970
|
-
if (!
|
|
1275
|
+
if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
|
|
971
1276
|
issue(path, "Each rule needs an id and a `when` condition.");
|
|
972
1277
|
return;
|
|
973
1278
|
}
|
|
@@ -986,7 +1291,7 @@ function parseForm(input) {
|
|
|
986
1291
|
at = rule.from;
|
|
987
1292
|
inclusive = true;
|
|
988
1293
|
}
|
|
989
|
-
if (
|
|
1294
|
+
if (isObject2(rule.to)) {
|
|
990
1295
|
if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
|
|
991
1296
|
issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
|
|
992
1297
|
}
|
|
@@ -1018,7 +1323,7 @@ function parseForm(input) {
|
|
|
1018
1323
|
}
|
|
1019
1324
|
});
|
|
1020
1325
|
input.blocks.forEach((block, i) => {
|
|
1021
|
-
if (!
|
|
1326
|
+
if (!isObject2(block)) return;
|
|
1022
1327
|
const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
|
|
1023
1328
|
const texts = raw.filter((t) => typeof t === "string");
|
|
1024
1329
|
for (const t of texts) {
|
|
@@ -1036,6 +1341,18 @@ function parseForm(input) {
|
|
|
1036
1341
|
}
|
|
1037
1342
|
}
|
|
1038
1343
|
});
|
|
1344
|
+
const extra = [["description", input.description]];
|
|
1345
|
+
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
1346
|
+
if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
|
|
1347
|
+
});
|
|
1348
|
+
for (const [path, t] of extra) {
|
|
1349
|
+
if (typeof t !== "string") continue;
|
|
1350
|
+
for (const ref of recallRefs(t)) {
|
|
1351
|
+
if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
|
|
1352
|
+
warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1039
1356
|
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
1040
1357
|
}
|
|
1041
1358
|
|
|
@@ -1072,18 +1389,16 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
1072
1389
|
if (invalid) return { ok: false, errors: { "": invalid } };
|
|
1073
1390
|
const input = wellFormed(submission);
|
|
1074
1391
|
if (!input) return MALFORMED;
|
|
1075
|
-
const ignored =
|
|
1392
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
1076
1393
|
const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
|
|
1077
|
-
for (const name of client.ignored) ignored.
|
|
1394
|
+
for (const name of client.ignored) ignored.add(`hidden.${name}`);
|
|
1078
1395
|
const hidden = client.values;
|
|
1079
1396
|
const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
|
|
1080
1397
|
const fields = fieldMap(definition);
|
|
1081
|
-
const dropped = /* @__PURE__ */ new Set();
|
|
1082
1398
|
const accept = (id) => {
|
|
1083
|
-
if (!fields.has(id)) return void ignored.
|
|
1399
|
+
if (!fields.has(id)) return void ignored.add(id);
|
|
1084
1400
|
if (!withheld(definition, fields.get(id))) return true;
|
|
1085
|
-
|
|
1086
|
-
dropped.add(id);
|
|
1401
|
+
ignored.add(id);
|
|
1087
1402
|
return false;
|
|
1088
1403
|
};
|
|
1089
1404
|
const engineHidden = { ...hidden, ...context };
|
|
@@ -1136,15 +1451,25 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
1136
1451
|
if (message === null) answers[id] = normalizeValue(value, field);
|
|
1137
1452
|
}
|
|
1138
1453
|
}
|
|
1454
|
+
const store = (values, report) => {
|
|
1455
|
+
if (definition.settings?.anonymous !== true) return values;
|
|
1456
|
+
const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
|
|
1457
|
+
const kept = {};
|
|
1458
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1459
|
+
if (!identifying.has(name)) kept[name] = value;
|
|
1460
|
+
else if (report) ignored.add(`hidden.${name}`);
|
|
1461
|
+
}
|
|
1462
|
+
return kept;
|
|
1463
|
+
};
|
|
1139
1464
|
return {
|
|
1140
1465
|
ok: true,
|
|
1141
1466
|
submission: {
|
|
1142
1467
|
status: complete ? "completed" : "draft",
|
|
1143
1468
|
answers,
|
|
1144
|
-
hidden,
|
|
1145
|
-
context,
|
|
1469
|
+
hidden: store(hidden, true),
|
|
1470
|
+
context: store(context, false),
|
|
1146
1471
|
ending: complete ? state.ending : null,
|
|
1147
|
-
ignored
|
|
1472
|
+
ignored: [...ignored]
|
|
1148
1473
|
}
|
|
1149
1474
|
};
|
|
1150
1475
|
}
|