@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/server.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
|
}
|
|
@@ -599,7 +782,31 @@ function replay(definition, events, options = {}) {
|
|
|
599
782
|
}
|
|
600
783
|
|
|
601
784
|
// src/parse.ts
|
|
602
|
-
|
|
785
|
+
function shown(v) {
|
|
786
|
+
let text;
|
|
787
|
+
try {
|
|
788
|
+
text = JSON.stringify(v);
|
|
789
|
+
} catch {
|
|
790
|
+
text = void 0;
|
|
791
|
+
}
|
|
792
|
+
text ??= typeof v === "bigint" ? `${v}n` : typeof v;
|
|
793
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
794
|
+
}
|
|
795
|
+
var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
796
|
+
var isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
797
|
+
var LITERAL_TYPE = {
|
|
798
|
+
"short-text": "string",
|
|
799
|
+
"long-text": "string",
|
|
800
|
+
email: "string",
|
|
801
|
+
phone: "string",
|
|
802
|
+
rating: "number",
|
|
803
|
+
nps: "number",
|
|
804
|
+
"single-choice": "string",
|
|
805
|
+
"multi-choice": "string",
|
|
806
|
+
"yes-no": "boolean",
|
|
807
|
+
list: "string"
|
|
808
|
+
};
|
|
809
|
+
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
603
810
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
604
811
|
"eq",
|
|
605
812
|
"neq",
|
|
@@ -639,7 +846,48 @@ function parseForm(input) {
|
|
|
639
846
|
}
|
|
640
847
|
return true;
|
|
641
848
|
};
|
|
642
|
-
|
|
849
|
+
const optionalInt = (v, path, floor) => {
|
|
850
|
+
if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
|
|
851
|
+
};
|
|
852
|
+
const ordered = (low, high, path, what) => {
|
|
853
|
+
if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
|
|
854
|
+
};
|
|
855
|
+
const structuredProps = (type, props, path) => {
|
|
856
|
+
if (type === "list") {
|
|
857
|
+
optionalInt(props.min, `${path}.min`, 0);
|
|
858
|
+
optionalInt(props.max, `${path}.max`, 1);
|
|
859
|
+
optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
|
|
860
|
+
optionalText(props.placeholder, `${path}.placeholder`);
|
|
861
|
+
ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
|
|
862
|
+
} else if (type === "table") {
|
|
863
|
+
const columns = props.columns;
|
|
864
|
+
if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
|
|
865
|
+
issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
|
|
866
|
+
} else {
|
|
867
|
+
const seen = /* @__PURE__ */ new Set();
|
|
868
|
+
columns.forEach((c, j) => {
|
|
869
|
+
const at = `${path}.columns[${j}]`;
|
|
870
|
+
if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
|
|
871
|
+
if (name(c.key, `${at}.key`, "A column key")) {
|
|
872
|
+
if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
|
|
873
|
+
seen.add(c.key);
|
|
874
|
+
}
|
|
875
|
+
text(c.label, `${at}.label`);
|
|
876
|
+
optionalBoolean(c.long, `${at}.long`);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
optionalInt(props.minRows, `${path}.minRows`, 0);
|
|
880
|
+
optionalInt(props.maxRows, `${path}.maxRows`, 1);
|
|
881
|
+
ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
|
|
882
|
+
} else if (type === "media-notes") {
|
|
883
|
+
optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
|
|
884
|
+
optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
|
|
885
|
+
} else if (type === "text-highlights") {
|
|
886
|
+
optionalText(props.text, `${path}.text`);
|
|
887
|
+
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
if (!isObject2(input)) {
|
|
643
891
|
issue("", "A form definition must be an object.");
|
|
644
892
|
return fail();
|
|
645
893
|
}
|
|
@@ -648,19 +896,19 @@ function parseForm(input) {
|
|
|
648
896
|
text(input.title, "title");
|
|
649
897
|
optionalText(input.description, "description");
|
|
650
898
|
if (input.layout !== void 0) {
|
|
651
|
-
if (!
|
|
899
|
+
if (!isObject2(input.layout)) issue("layout", "Must be an object.");
|
|
652
900
|
else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
|
|
653
901
|
issue("layout.mode", "Must be focus or classic.");
|
|
654
902
|
}
|
|
655
903
|
}
|
|
656
904
|
if (input.settings !== void 0) {
|
|
657
|
-
if (!
|
|
905
|
+
if (!isObject2(input.settings)) issue("settings", "Must be an object.");
|
|
658
906
|
else {
|
|
659
907
|
optionalBoolean(input.settings.anonymous, "settings.anonymous");
|
|
660
908
|
optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
|
|
661
909
|
}
|
|
662
910
|
}
|
|
663
|
-
if (input.meta !== void 0 && (!
|
|
911
|
+
if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
|
|
664
912
|
issue("meta", "Must be a JSON object.");
|
|
665
913
|
}
|
|
666
914
|
if (!Array.isArray(input.blocks)) {
|
|
@@ -672,9 +920,10 @@ function parseForm(input) {
|
|
|
672
920
|
const keys = /* @__PURE__ */ new Map();
|
|
673
921
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
674
922
|
const order = /* @__PURE__ */ new Map();
|
|
923
|
+
const choices = /* @__PURE__ */ new Map();
|
|
675
924
|
input.blocks.forEach((block, i) => {
|
|
676
925
|
const path = `blocks[${i}]`;
|
|
677
|
-
if (!
|
|
926
|
+
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
678
927
|
issue(path, "Every block needs a string id.");
|
|
679
928
|
return;
|
|
680
929
|
}
|
|
@@ -695,18 +944,30 @@ function parseForm(input) {
|
|
|
695
944
|
issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
|
|
696
945
|
}
|
|
697
946
|
if (block.props !== void 0) {
|
|
698
|
-
if (!
|
|
947
|
+
if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
699
948
|
else if (block.props.options !== void 0) {
|
|
700
949
|
const options = block.props.options;
|
|
701
950
|
if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
|
|
702
951
|
else {
|
|
703
952
|
options.forEach((o, j) => {
|
|
704
953
|
const at = `${path}.props.options[${j}]`;
|
|
705
|
-
if (!
|
|
954
|
+
if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
706
955
|
else optionalText(o.label, `${at}.label`);
|
|
707
956
|
});
|
|
957
|
+
choices.set(
|
|
958
|
+
block.id,
|
|
959
|
+
new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
|
|
960
|
+
);
|
|
708
961
|
}
|
|
709
962
|
}
|
|
963
|
+
const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
|
|
964
|
+
if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
|
|
965
|
+
issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
|
|
966
|
+
}
|
|
967
|
+
if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
|
|
968
|
+
issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
|
|
969
|
+
}
|
|
970
|
+
if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
|
|
710
971
|
}
|
|
711
972
|
if (validKey) {
|
|
712
973
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
@@ -723,9 +984,10 @@ function parseForm(input) {
|
|
|
723
984
|
}
|
|
724
985
|
});
|
|
725
986
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
987
|
+
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
726
988
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
727
989
|
(Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
|
|
728
|
-
if (!
|
|
990
|
+
if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
|
|
729
991
|
issue(`hidden[${i}]`, "Each hidden input needs a name.");
|
|
730
992
|
return;
|
|
731
993
|
}
|
|
@@ -738,13 +1000,15 @@ function parseForm(input) {
|
|
|
738
1000
|
issue(`hidden[${i}].type`, "Must be string, number or boolean.");
|
|
739
1001
|
}
|
|
740
1002
|
optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
|
|
1003
|
+
optionalBoolean(h.identifying, `hidden[${i}].identifying`);
|
|
741
1004
|
hiddenNames.add(h.name);
|
|
1005
|
+
if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
|
|
742
1006
|
});
|
|
743
1007
|
const endingIds = /* @__PURE__ */ new Set();
|
|
744
1008
|
if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
|
|
745
1009
|
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
746
1010
|
const path = `endings[${i}]`;
|
|
747
|
-
if (!
|
|
1011
|
+
if (!isObject2(e)) {
|
|
748
1012
|
issue(path, "Each ending must be an object with an id and a title.");
|
|
749
1013
|
return;
|
|
750
1014
|
}
|
|
@@ -756,7 +1020,7 @@ function parseForm(input) {
|
|
|
756
1020
|
});
|
|
757
1021
|
const checkOperand = (o, path, reads) => {
|
|
758
1022
|
if (o === void 0) return;
|
|
759
|
-
if (!
|
|
1023
|
+
if (!isObject2(o)) return issue(path, "An operand must be an object.");
|
|
760
1024
|
if ("field" in o) {
|
|
761
1025
|
if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
|
|
762
1026
|
reads.push(o.field);
|
|
@@ -769,7 +1033,7 @@ function parseForm(input) {
|
|
|
769
1033
|
}
|
|
770
1034
|
};
|
|
771
1035
|
const checkCondition = (c, path, reads, depth = 0) => {
|
|
772
|
-
if (!
|
|
1036
|
+
if (!isObject2(c)) return issue(path, "A condition must be an object.");
|
|
773
1037
|
if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
|
|
774
1038
|
if ("all" in c || "any" in c) {
|
|
775
1039
|
const group = "all" in c ? "all" : "any";
|
|
@@ -778,7 +1042,7 @@ function parseForm(input) {
|
|
|
778
1042
|
return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
|
|
779
1043
|
}
|
|
780
1044
|
if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
|
|
781
|
-
if (typeof c.op !== "string" || !
|
|
1045
|
+
if (typeof c.op !== "string" || !isObject2(c.left)) {
|
|
782
1046
|
return issue(path, "A comparison needs an `op` and a `left` operand.");
|
|
783
1047
|
}
|
|
784
1048
|
if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
|
|
@@ -787,16 +1051,53 @@ function parseForm(input) {
|
|
|
787
1051
|
if (typeof c.left.field === "string") {
|
|
788
1052
|
const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
|
|
789
1053
|
if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
|
|
790
|
-
issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
1054
|
+
return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
791
1055
|
}
|
|
792
1056
|
}
|
|
1057
|
+
checkLiterals(c.op, c.left, c.right, path);
|
|
1058
|
+
};
|
|
1059
|
+
const checkLiterals = (op, left, right, path) => {
|
|
1060
|
+
if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
|
|
1061
|
+
const literal = isObject2(right) && "value" in right;
|
|
1062
|
+
if (right !== void 0 && !literal) return;
|
|
1063
|
+
let what = "the left side";
|
|
1064
|
+
let type;
|
|
1065
|
+
let options;
|
|
1066
|
+
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1067
|
+
const fieldType = fieldTypes.get(left.field);
|
|
1068
|
+
what = `"${keyOf(left.field)}"`;
|
|
1069
|
+
type = LITERAL_TYPE[fieldType];
|
|
1070
|
+
if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
|
|
1071
|
+
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1072
|
+
what = `hidden input "${left.hidden}"`;
|
|
1073
|
+
type = hiddenTypes.get(left.hidden);
|
|
1074
|
+
}
|
|
1075
|
+
const outcome = op === "neq" ? "always true" : "never true";
|
|
1076
|
+
let values;
|
|
1077
|
+
if (op === "in") {
|
|
1078
|
+
if (!literal || !Array.isArray(right.value)) {
|
|
1079
|
+
return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
|
|
1080
|
+
}
|
|
1081
|
+
values = right.value;
|
|
1082
|
+
} else if (!literal) return;
|
|
1083
|
+
else values = [right.value];
|
|
1084
|
+
if (ORDERING.has(op)) {
|
|
1085
|
+
if (type !== void 0 && type !== "number") {
|
|
1086
|
+
return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
|
|
1087
|
+
}
|
|
1088
|
+
type = "number";
|
|
1089
|
+
}
|
|
1090
|
+
for (const v of values) {
|
|
1091
|
+
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;
|
|
1092
|
+
if (message) warnings.push({ path: `${path}.right`, message });
|
|
1093
|
+
}
|
|
793
1094
|
};
|
|
794
1095
|
const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
|
|
795
1096
|
const ruleIds = /* @__PURE__ */ new Set();
|
|
796
1097
|
if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
|
|
797
1098
|
(Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
|
|
798
1099
|
const path = `logic[${i}]`;
|
|
799
|
-
if (!
|
|
1100
|
+
if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
|
|
800
1101
|
issue(path, "Each rule needs an id and a `when` condition.");
|
|
801
1102
|
return;
|
|
802
1103
|
}
|
|
@@ -815,7 +1116,7 @@ function parseForm(input) {
|
|
|
815
1116
|
at = rule.from;
|
|
816
1117
|
inclusive = true;
|
|
817
1118
|
}
|
|
818
|
-
if (
|
|
1119
|
+
if (isObject2(rule.to)) {
|
|
819
1120
|
if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
|
|
820
1121
|
issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
|
|
821
1122
|
}
|
|
@@ -847,7 +1148,7 @@ function parseForm(input) {
|
|
|
847
1148
|
}
|
|
848
1149
|
});
|
|
849
1150
|
input.blocks.forEach((block, i) => {
|
|
850
|
-
if (!
|
|
1151
|
+
if (!isObject2(block)) return;
|
|
851
1152
|
const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
|
|
852
1153
|
const texts = raw.filter((t) => typeof t === "string");
|
|
853
1154
|
for (const t of texts) {
|
|
@@ -865,6 +1166,18 @@ function parseForm(input) {
|
|
|
865
1166
|
}
|
|
866
1167
|
}
|
|
867
1168
|
});
|
|
1169
|
+
const extra = [["description", input.description]];
|
|
1170
|
+
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
1171
|
+
if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
|
|
1172
|
+
});
|
|
1173
|
+
for (const [path, t] of extra) {
|
|
1174
|
+
if (typeof t !== "string") continue;
|
|
1175
|
+
for (const ref of recallRefs(t)) {
|
|
1176
|
+
if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
|
|
1177
|
+
warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
868
1181
|
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
869
1182
|
}
|
|
870
1183
|
|
|
@@ -901,18 +1214,16 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
901
1214
|
if (invalid) return { ok: false, errors: { "": invalid } };
|
|
902
1215
|
const input = wellFormed(submission);
|
|
903
1216
|
if (!input) return MALFORMED;
|
|
904
|
-
const ignored =
|
|
1217
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
905
1218
|
const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
|
|
906
|
-
for (const name of client.ignored) ignored.
|
|
1219
|
+
for (const name of client.ignored) ignored.add(`hidden.${name}`);
|
|
907
1220
|
const hidden = client.values;
|
|
908
1221
|
const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
|
|
909
1222
|
const fields = fieldMap(definition);
|
|
910
|
-
const dropped = /* @__PURE__ */ new Set();
|
|
911
1223
|
const accept = (id) => {
|
|
912
|
-
if (!fields.has(id)) return void ignored.
|
|
1224
|
+
if (!fields.has(id)) return void ignored.add(id);
|
|
913
1225
|
if (!withheld(definition, fields.get(id))) return true;
|
|
914
|
-
|
|
915
|
-
dropped.add(id);
|
|
1226
|
+
ignored.add(id);
|
|
916
1227
|
return false;
|
|
917
1228
|
};
|
|
918
1229
|
const engineHidden = { ...hidden, ...context };
|
|
@@ -965,15 +1276,25 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
965
1276
|
if (message === null) answers[id] = normalizeValue(value, field);
|
|
966
1277
|
}
|
|
967
1278
|
}
|
|
1279
|
+
const store = (values, report) => {
|
|
1280
|
+
if (definition.settings?.anonymous !== true) return values;
|
|
1281
|
+
const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
|
|
1282
|
+
const kept = {};
|
|
1283
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1284
|
+
if (!identifying.has(name)) kept[name] = value;
|
|
1285
|
+
else if (report) ignored.add(`hidden.${name}`);
|
|
1286
|
+
}
|
|
1287
|
+
return kept;
|
|
1288
|
+
};
|
|
968
1289
|
return {
|
|
969
1290
|
ok: true,
|
|
970
1291
|
submission: {
|
|
971
1292
|
status: complete ? "completed" : "draft",
|
|
972
1293
|
answers,
|
|
973
|
-
hidden,
|
|
974
|
-
context,
|
|
1294
|
+
hidden: store(hidden, true),
|
|
1295
|
+
context: store(context, false),
|
|
975
1296
|
ending: complete ? state.ending : null,
|
|
976
|
-
ignored
|
|
1297
|
+
ignored: [...ignored]
|
|
977
1298
|
}
|
|
978
1299
|
};
|
|
979
1300
|
}
|