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