@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.d.cts
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
|
-
import { J as Json,
|
|
1
|
+
import { J as Json, e as FormEvent, F as FormDefinition } from './types-BBKBQ8sp.cjs';
|
|
2
|
+
import { F as FieldValidator } from './validate-BXXqi0fq.cjs';
|
|
2
3
|
|
|
3
4
|
interface SubmissionInput {
|
|
4
5
|
/** Answers keyed by field id. Ignored when `events` is given. */
|
|
5
|
-
answers?: Record<string, Json
|
|
6
|
+
answers?: Record<string, Json> | undefined;
|
|
6
7
|
/** The respondent's event log. Replayed instead of trusting `answers`. */
|
|
7
|
-
events?: FormEvent[];
|
|
8
|
+
events?: FormEvent[] | undefined;
|
|
8
9
|
/** URL and host inputs sent by the client. Server inputs here are ignored. */
|
|
9
|
-
hidden?: Record<string, Json
|
|
10
|
+
hidden?: Record<string, Json> | undefined;
|
|
10
11
|
}
|
|
11
12
|
interface SubmissionOptions {
|
|
12
|
-
/** Values for `source: "server"` inputs, resolved by the server (
|
|
13
|
-
context?: Record<string, Json
|
|
13
|
+
/** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
|
|
14
|
+
context?: Record<string, Json> | undefined;
|
|
14
15
|
/** `false` checks a draft: formats only, nothing required. Default true. */
|
|
15
|
-
complete?: boolean;
|
|
16
|
-
validators?: Record<string, FieldValidator
|
|
16
|
+
complete?: boolean | undefined;
|
|
17
|
+
validators?: Record<string, FieldValidator> | undefined;
|
|
17
18
|
/** Most events accepted, counted after `compactEvents`. Default 10 000. */
|
|
18
|
-
maxEvents?: number;
|
|
19
|
+
maxEvents?: number | undefined;
|
|
19
20
|
}
|
|
20
21
|
interface CheckedSubmission {
|
|
21
22
|
status: "draft" | "completed";
|
|
22
23
|
/** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
|
|
23
24
|
answers: Record<string, Json>;
|
|
24
|
-
/** Untrusted URL and host inputs, coerced to their declared types. */
|
|
25
|
+
/** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
|
|
25
26
|
hidden: Record<string, Json>;
|
|
26
|
-
/** Trusted server inputs. */
|
|
27
|
+
/** Trusted server inputs. Identifying ones are left out on anonymous forms. */
|
|
27
28
|
context: Record<string, Json>;
|
|
28
29
|
ending: string | null;
|
|
29
|
-
/** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
|
|
30
|
+
/** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
|
|
30
31
|
ignored: string[];
|
|
31
32
|
}
|
|
32
33
|
type SubmissionResult = {
|
package/dist/server.d.ts
CHANGED
|
@@ -1,32 +1,33 @@
|
|
|
1
|
-
import { J as Json,
|
|
1
|
+
import { J as Json, e as FormEvent, F as FormDefinition } from './types-BBKBQ8sp.js';
|
|
2
|
+
import { F as FieldValidator } from './validate-BjNwF1UE.js';
|
|
2
3
|
|
|
3
4
|
interface SubmissionInput {
|
|
4
5
|
/** Answers keyed by field id. Ignored when `events` is given. */
|
|
5
|
-
answers?: Record<string, Json
|
|
6
|
+
answers?: Record<string, Json> | undefined;
|
|
6
7
|
/** The respondent's event log. Replayed instead of trusting `answers`. */
|
|
7
|
-
events?: FormEvent[];
|
|
8
|
+
events?: FormEvent[] | undefined;
|
|
8
9
|
/** URL and host inputs sent by the client. Server inputs here are ignored. */
|
|
9
|
-
hidden?: Record<string, Json
|
|
10
|
+
hidden?: Record<string, Json> | undefined;
|
|
10
11
|
}
|
|
11
12
|
interface SubmissionOptions {
|
|
12
|
-
/** Values for `source: "server"` inputs, resolved by the server (
|
|
13
|
-
context?: Record<string, Json
|
|
13
|
+
/** Values for `source: "server"` inputs, resolved by the server (account id, location, user id…). */
|
|
14
|
+
context?: Record<string, Json> | undefined;
|
|
14
15
|
/** `false` checks a draft: formats only, nothing required. Default true. */
|
|
15
|
-
complete?: boolean;
|
|
16
|
-
validators?: Record<string, FieldValidator
|
|
16
|
+
complete?: boolean | undefined;
|
|
17
|
+
validators?: Record<string, FieldValidator> | undefined;
|
|
17
18
|
/** Most events accepted, counted after `compactEvents`. Default 10 000. */
|
|
18
|
-
maxEvents?: number;
|
|
19
|
+
maxEvents?: number | undefined;
|
|
19
20
|
}
|
|
20
21
|
interface CheckedSubmission {
|
|
21
22
|
status: "draft" | "completed";
|
|
22
23
|
/** Visible answers only, keyed by field id (kept hidden answers too, with `clearHiddenValues: false`). */
|
|
23
24
|
answers: Record<string, Json>;
|
|
24
|
-
/** Untrusted URL and host inputs, coerced to their declared types. */
|
|
25
|
+
/** Untrusted URL and host inputs, coerced to their declared types. Identifying ones are left out on anonymous forms. */
|
|
25
26
|
hidden: Record<string, Json>;
|
|
26
|
-
/** Trusted server inputs. */
|
|
27
|
+
/** Trusted server inputs. Identifying ones are left out on anonymous forms. */
|
|
27
28
|
context: Record<string, Json>;
|
|
28
29
|
ending: string | null;
|
|
29
|
-
/** Client-sent keys that were dropped: unknown fields, server inputs, undeclared hidden names. */
|
|
30
|
+
/** Client-sent keys that were dropped, each once: unknown fields, server inputs, undeclared or withheld hidden names. */
|
|
30
31
|
ignored: string[];
|
|
31
32
|
}
|
|
32
33
|
type SubmissionResult = {
|
package/dist/server.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
|
}
|
|
@@ -597,7 +780,31 @@ function replay(definition, events, options = {}) {
|
|
|
597
780
|
}
|
|
598
781
|
|
|
599
782
|
// src/parse.ts
|
|
600
|
-
|
|
783
|
+
function shown(v) {
|
|
784
|
+
let text;
|
|
785
|
+
try {
|
|
786
|
+
text = JSON.stringify(v);
|
|
787
|
+
} catch {
|
|
788
|
+
text = void 0;
|
|
789
|
+
}
|
|
790
|
+
text ??= typeof v === "bigint" ? `${v}n` : typeof v;
|
|
791
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
792
|
+
}
|
|
793
|
+
var isObject2 = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
794
|
+
var isInt = (v) => typeof v === "number" && Number.isInteger(v);
|
|
795
|
+
var LITERAL_TYPE = {
|
|
796
|
+
"short-text": "string",
|
|
797
|
+
"long-text": "string",
|
|
798
|
+
email: "string",
|
|
799
|
+
phone: "string",
|
|
800
|
+
rating: "number",
|
|
801
|
+
nps: "number",
|
|
802
|
+
"single-choice": "string",
|
|
803
|
+
"multi-choice": "string",
|
|
804
|
+
"yes-no": "boolean",
|
|
805
|
+
list: "string"
|
|
806
|
+
};
|
|
807
|
+
var ORDERING = /* @__PURE__ */ new Set(["lt", "lte", "gt", "gte"]);
|
|
601
808
|
var OPERATORS = /* @__PURE__ */ new Set([
|
|
602
809
|
"eq",
|
|
603
810
|
"neq",
|
|
@@ -637,7 +844,48 @@ function parseForm(input) {
|
|
|
637
844
|
}
|
|
638
845
|
return true;
|
|
639
846
|
};
|
|
640
|
-
|
|
847
|
+
const optionalInt = (v, path, floor) => {
|
|
848
|
+
if (v !== void 0 && !(isInt(v) && v >= floor)) issue(path, `Must be a whole number of ${floor} or more.`);
|
|
849
|
+
};
|
|
850
|
+
const ordered = (low, high, path, what) => {
|
|
851
|
+
if (isInt(low) && isInt(high) && low > high) issue(path, `Can't be more than the ${what}.`);
|
|
852
|
+
};
|
|
853
|
+
const structuredProps = (type, props, path) => {
|
|
854
|
+
if (type === "list") {
|
|
855
|
+
optionalInt(props.min, `${path}.min`, 0);
|
|
856
|
+
optionalInt(props.max, `${path}.max`, 1);
|
|
857
|
+
optionalInt(props.itemMaxLength, `${path}.itemMaxLength`, 1);
|
|
858
|
+
optionalText(props.placeholder, `${path}.placeholder`);
|
|
859
|
+
ordered(props.min, props.max ?? 10, `${path}.min`, "most items");
|
|
860
|
+
} else if (type === "table") {
|
|
861
|
+
const columns = props.columns;
|
|
862
|
+
if (!Array.isArray(columns) || columns.length < 1 || columns.length > MAX_TABLE_COLUMNS) {
|
|
863
|
+
issue(`${path}.columns`, `Must be a list of 1 to ${MAX_TABLE_COLUMNS} columns.`);
|
|
864
|
+
} else {
|
|
865
|
+
const seen = /* @__PURE__ */ new Set();
|
|
866
|
+
columns.forEach((c, j) => {
|
|
867
|
+
const at = `${path}.columns[${j}]`;
|
|
868
|
+
if (!isObject2(c)) return issue(at, "Each column needs a key and a label.");
|
|
869
|
+
if (name(c.key, `${at}.key`, "A column key")) {
|
|
870
|
+
if (seen.has(c.key)) issue(`${at}.key`, `Duplicate column key "${c.key}".`);
|
|
871
|
+
seen.add(c.key);
|
|
872
|
+
}
|
|
873
|
+
text(c.label, `${at}.label`);
|
|
874
|
+
optionalBoolean(c.long, `${at}.long`);
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
optionalInt(props.minRows, `${path}.minRows`, 0);
|
|
878
|
+
optionalInt(props.maxRows, `${path}.maxRows`, 1);
|
|
879
|
+
ordered(props.minRows, props.maxRows ?? 3, `${path}.minRows`, "most rows");
|
|
880
|
+
} else if (type === "media-notes") {
|
|
881
|
+
optionalInt(props.maxNotes, `${path}.maxNotes`, 1);
|
|
882
|
+
optionalInt(props.textMaxLength, `${path}.textMaxLength`, 1);
|
|
883
|
+
} else if (type === "text-highlights") {
|
|
884
|
+
optionalText(props.text, `${path}.text`);
|
|
885
|
+
optionalInt(props.maxHighlights, `${path}.maxHighlights`, 1);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
if (!isObject2(input)) {
|
|
641
889
|
issue("", "A form definition must be an object.");
|
|
642
890
|
return fail();
|
|
643
891
|
}
|
|
@@ -646,19 +894,19 @@ function parseForm(input) {
|
|
|
646
894
|
text(input.title, "title");
|
|
647
895
|
optionalText(input.description, "description");
|
|
648
896
|
if (input.layout !== void 0) {
|
|
649
|
-
if (!
|
|
897
|
+
if (!isObject2(input.layout)) issue("layout", "Must be an object.");
|
|
650
898
|
else if (input.layout.mode !== "focus" && input.layout.mode !== "classic") {
|
|
651
899
|
issue("layout.mode", "Must be focus or classic.");
|
|
652
900
|
}
|
|
653
901
|
}
|
|
654
902
|
if (input.settings !== void 0) {
|
|
655
|
-
if (!
|
|
903
|
+
if (!isObject2(input.settings)) issue("settings", "Must be an object.");
|
|
656
904
|
else {
|
|
657
905
|
optionalBoolean(input.settings.anonymous, "settings.anonymous");
|
|
658
906
|
optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
|
|
659
907
|
}
|
|
660
908
|
}
|
|
661
|
-
if (input.meta !== void 0 && (!
|
|
909
|
+
if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
|
|
662
910
|
issue("meta", "Must be a JSON object.");
|
|
663
911
|
}
|
|
664
912
|
if (!Array.isArray(input.blocks)) {
|
|
@@ -670,9 +918,10 @@ function parseForm(input) {
|
|
|
670
918
|
const keys = /* @__PURE__ */ new Map();
|
|
671
919
|
const fieldTypes = /* @__PURE__ */ new Map();
|
|
672
920
|
const order = /* @__PURE__ */ new Map();
|
|
921
|
+
const choices = /* @__PURE__ */ new Map();
|
|
673
922
|
input.blocks.forEach((block, i) => {
|
|
674
923
|
const path = `blocks[${i}]`;
|
|
675
|
-
if (!
|
|
924
|
+
if (!isObject2(block) || typeof block.id !== "string" || !block.id) {
|
|
676
925
|
issue(path, "Every block needs a string id.");
|
|
677
926
|
return;
|
|
678
927
|
}
|
|
@@ -693,18 +942,30 @@ function parseForm(input) {
|
|
|
693
942
|
issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
|
|
694
943
|
}
|
|
695
944
|
if (block.props !== void 0) {
|
|
696
|
-
if (!
|
|
945
|
+
if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
|
|
697
946
|
else if (block.props.options !== void 0) {
|
|
698
947
|
const options = block.props.options;
|
|
699
948
|
if (!Array.isArray(options)) issue(`${path}.props.options`, "Must be an array.");
|
|
700
949
|
else {
|
|
701
950
|
options.forEach((o, j) => {
|
|
702
951
|
const at = `${path}.props.options[${j}]`;
|
|
703
|
-
if (!
|
|
952
|
+
if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
|
|
704
953
|
else optionalText(o.label, `${at}.label`);
|
|
705
954
|
});
|
|
955
|
+
choices.set(
|
|
956
|
+
block.id,
|
|
957
|
+
new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
|
|
958
|
+
);
|
|
706
959
|
}
|
|
707
960
|
}
|
|
961
|
+
const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
|
|
962
|
+
if (block.type === "rating" && max !== void 0 && !(isInt(max) && max >= 2 && max <= 10)) {
|
|
963
|
+
issue(`${path}.props.max`, "Must be a whole number from 2 to 10.");
|
|
964
|
+
}
|
|
965
|
+
if (maxLength2 !== void 0 && !(isInt(maxLength2) && maxLength2 > 0)) {
|
|
966
|
+
issue(`${path}.props.maxLength`, "Must be a whole number above 0.");
|
|
967
|
+
}
|
|
968
|
+
if (isObject2(block.props) && typeof block.type === "string") structuredProps(block.type, block.props, `${path}.props`);
|
|
708
969
|
}
|
|
709
970
|
if (validKey) {
|
|
710
971
|
if (keys.has(block.key)) issue(`${path}.key`, `Duplicate key "${String(block.key)}".`);
|
|
@@ -721,9 +982,10 @@ function parseForm(input) {
|
|
|
721
982
|
}
|
|
722
983
|
});
|
|
723
984
|
const hiddenNames = /* @__PURE__ */ new Set();
|
|
985
|
+
const hiddenTypes = /* @__PURE__ */ new Map();
|
|
724
986
|
if (input.hidden !== void 0 && !Array.isArray(input.hidden)) issue("hidden", "Must be an array.");
|
|
725
987
|
(Array.isArray(input.hidden) ? input.hidden : []).forEach((h, i) => {
|
|
726
|
-
if (!
|
|
988
|
+
if (!isObject2(h) || typeof h.name !== "string" || !h.name) {
|
|
727
989
|
issue(`hidden[${i}]`, "Each hidden input needs a name.");
|
|
728
990
|
return;
|
|
729
991
|
}
|
|
@@ -736,13 +998,15 @@ function parseForm(input) {
|
|
|
736
998
|
issue(`hidden[${i}].type`, "Must be string, number or boolean.");
|
|
737
999
|
}
|
|
738
1000
|
optionalBoolean(h.sensitive, `hidden[${i}].sensitive`);
|
|
1001
|
+
optionalBoolean(h.identifying, `hidden[${i}].identifying`);
|
|
739
1002
|
hiddenNames.add(h.name);
|
|
1003
|
+
if (typeof h.type === "string") hiddenTypes.set(h.name, h.type);
|
|
740
1004
|
});
|
|
741
1005
|
const endingIds = /* @__PURE__ */ new Set();
|
|
742
1006
|
if (input.endings !== void 0 && !Array.isArray(input.endings)) issue("endings", "Must be an array.");
|
|
743
1007
|
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
744
1008
|
const path = `endings[${i}]`;
|
|
745
|
-
if (!
|
|
1009
|
+
if (!isObject2(e)) {
|
|
746
1010
|
issue(path, "Each ending must be an object with an id and a title.");
|
|
747
1011
|
return;
|
|
748
1012
|
}
|
|
@@ -754,7 +1018,7 @@ function parseForm(input) {
|
|
|
754
1018
|
});
|
|
755
1019
|
const checkOperand = (o, path, reads) => {
|
|
756
1020
|
if (o === void 0) return;
|
|
757
|
-
if (!
|
|
1021
|
+
if (!isObject2(o)) return issue(path, "An operand must be an object.");
|
|
758
1022
|
if ("field" in o) {
|
|
759
1023
|
if (typeof o.field !== "string" || !fieldTypes.has(o.field)) return issue(path, `No field "${String(o.field)}".`);
|
|
760
1024
|
reads.push(o.field);
|
|
@@ -767,7 +1031,7 @@ function parseForm(input) {
|
|
|
767
1031
|
}
|
|
768
1032
|
};
|
|
769
1033
|
const checkCondition = (c, path, reads, depth = 0) => {
|
|
770
|
-
if (!
|
|
1034
|
+
if (!isObject2(c)) return issue(path, "A condition must be an object.");
|
|
771
1035
|
if (depth > MAX_CONDITION_DEPTH) return issue(path, "The condition is nested too deeply.");
|
|
772
1036
|
if ("all" in c || "any" in c) {
|
|
773
1037
|
const group = "all" in c ? "all" : "any";
|
|
@@ -776,7 +1040,7 @@ function parseForm(input) {
|
|
|
776
1040
|
return list.forEach((x, i) => checkCondition(x, `${path}.${group}[${i}]`, reads, depth + 1));
|
|
777
1041
|
}
|
|
778
1042
|
if ("not" in c) return checkCondition(c.not, `${path}.not`, reads, depth + 1);
|
|
779
|
-
if (typeof c.op !== "string" || !
|
|
1043
|
+
if (typeof c.op !== "string" || !isObject2(c.left)) {
|
|
780
1044
|
return issue(path, "A comparison needs an `op` and a `left` operand.");
|
|
781
1045
|
}
|
|
782
1046
|
if (!OPERATORS.has(c.op)) issue(`${path}.op`, `Unknown operator "${c.op}".`);
|
|
@@ -785,16 +1049,53 @@ function parseForm(input) {
|
|
|
785
1049
|
if (typeof c.left.field === "string") {
|
|
786
1050
|
const manifest = getFieldType(fieldTypes.get(c.left.field) ?? "");
|
|
787
1051
|
if (manifest && OPERATORS.has(c.op) && !manifest.operators.includes(c.op)) {
|
|
788
|
-
issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
1052
|
+
return issue(`${path}.op`, `"${c.op}" does not apply to a ${manifest.type} field.`);
|
|
789
1053
|
}
|
|
790
1054
|
}
|
|
1055
|
+
checkLiterals(c.op, c.left, c.right, path);
|
|
1056
|
+
};
|
|
1057
|
+
const checkLiterals = (op, left, right, path) => {
|
|
1058
|
+
if (op === "answered" || op === "empty" || !OPERATORS.has(op)) return;
|
|
1059
|
+
const literal = isObject2(right) && "value" in right;
|
|
1060
|
+
if (right !== void 0 && !literal) return;
|
|
1061
|
+
let what = "the left side";
|
|
1062
|
+
let type;
|
|
1063
|
+
let options;
|
|
1064
|
+
if (typeof left.field === "string" && fieldTypes.has(left.field)) {
|
|
1065
|
+
const fieldType = fieldTypes.get(left.field);
|
|
1066
|
+
what = `"${keyOf(left.field)}"`;
|
|
1067
|
+
type = LITERAL_TYPE[fieldType];
|
|
1068
|
+
if (fieldType === "single-choice" || fieldType === "multi-choice") options = choices.get(left.field);
|
|
1069
|
+
} else if (typeof left.hidden === "string" && hiddenTypes.has(left.hidden)) {
|
|
1070
|
+
what = `hidden input "${left.hidden}"`;
|
|
1071
|
+
type = hiddenTypes.get(left.hidden);
|
|
1072
|
+
}
|
|
1073
|
+
const outcome = op === "neq" ? "always true" : "never true";
|
|
1074
|
+
let values;
|
|
1075
|
+
if (op === "in") {
|
|
1076
|
+
if (!literal || !Array.isArray(right.value)) {
|
|
1077
|
+
return void warnings.push({ path: `${path}.right`, message: "`in` needs a list of values; it never matches." });
|
|
1078
|
+
}
|
|
1079
|
+
values = right.value;
|
|
1080
|
+
} else if (!literal) return;
|
|
1081
|
+
else values = [right.value];
|
|
1082
|
+
if (ORDERING.has(op)) {
|
|
1083
|
+
if (type !== void 0 && type !== "number") {
|
|
1084
|
+
return void warnings.push({ path: `${path}.op`, message: `"${op}" compares numbers, but ${what} holds a ${type}; it is never true.` });
|
|
1085
|
+
}
|
|
1086
|
+
type = "number";
|
|
1087
|
+
}
|
|
1088
|
+
for (const v of values) {
|
|
1089
|
+
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;
|
|
1090
|
+
if (message) warnings.push({ path: `${path}.right`, message });
|
|
1091
|
+
}
|
|
791
1092
|
};
|
|
792
1093
|
const keyOf = (id) => [...keys].find(([, v]) => v === id)?.[0] ?? id;
|
|
793
1094
|
const ruleIds = /* @__PURE__ */ new Set();
|
|
794
1095
|
if (input.logic !== void 0 && !Array.isArray(input.logic)) issue("logic", "Must be an array.");
|
|
795
1096
|
(Array.isArray(input.logic) ? input.logic : []).forEach((rule, i) => {
|
|
796
1097
|
const path = `logic[${i}]`;
|
|
797
|
-
if (!
|
|
1098
|
+
if (!isObject2(rule) || typeof rule.id !== "string" || !isObject2(rule.when)) {
|
|
798
1099
|
issue(path, "Each rule needs an id and a `when` condition.");
|
|
799
1100
|
return;
|
|
800
1101
|
}
|
|
@@ -813,7 +1114,7 @@ function parseForm(input) {
|
|
|
813
1114
|
at = rule.from;
|
|
814
1115
|
inclusive = true;
|
|
815
1116
|
}
|
|
816
|
-
if (
|
|
1117
|
+
if (isObject2(rule.to)) {
|
|
817
1118
|
if (typeof rule.to.ending !== "string" || !endingIds.has(rule.to.ending)) {
|
|
818
1119
|
issue(`${path}.to`, `No ending "${String(rule.to.ending)}".`);
|
|
819
1120
|
}
|
|
@@ -845,7 +1146,7 @@ function parseForm(input) {
|
|
|
845
1146
|
}
|
|
846
1147
|
});
|
|
847
1148
|
input.blocks.forEach((block, i) => {
|
|
848
|
-
if (!
|
|
1149
|
+
if (!isObject2(block)) return;
|
|
849
1150
|
const raw = block.kind === "field" ? [block.label, block.description] : block.kind === "content" ? [block.text] : [];
|
|
850
1151
|
const texts = raw.filter((t) => typeof t === "string");
|
|
851
1152
|
for (const t of texts) {
|
|
@@ -863,6 +1164,18 @@ function parseForm(input) {
|
|
|
863
1164
|
}
|
|
864
1165
|
}
|
|
865
1166
|
});
|
|
1167
|
+
const extra = [["description", input.description]];
|
|
1168
|
+
(Array.isArray(input.endings) ? input.endings : []).forEach((e, i) => {
|
|
1169
|
+
if (isObject2(e)) extra.push([`endings[${i}]`, e.title], [`endings[${i}]`, e.description]);
|
|
1170
|
+
});
|
|
1171
|
+
for (const [path, t] of extra) {
|
|
1172
|
+
if (typeof t !== "string") continue;
|
|
1173
|
+
for (const ref of recallRefs(t)) {
|
|
1174
|
+
if (ref.kind === "hidden" ? !hiddenNames.has(ref.name) : !keys.has(ref.name)) {
|
|
1175
|
+
warnings.push({ path, message: `Recalls unknown ${ref.kind === "hidden" ? "hidden input" : "field"} "${ref.name}"; it shows its fallback text.` });
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
866
1179
|
return issues.length ? fail() : { ok: true, form: input, warnings };
|
|
867
1180
|
}
|
|
868
1181
|
|
|
@@ -899,18 +1212,16 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
899
1212
|
if (invalid) return { ok: false, errors: { "": invalid } };
|
|
900
1213
|
const input = wellFormed(submission);
|
|
901
1214
|
if (!input) return MALFORMED;
|
|
902
|
-
const ignored =
|
|
1215
|
+
const ignored = /* @__PURE__ */ new Set();
|
|
903
1216
|
const client = coerceHidden(definition, input.hidden, { sources: ["url", "host"] });
|
|
904
|
-
for (const name of client.ignored) ignored.
|
|
1217
|
+
for (const name of client.ignored) ignored.add(`hidden.${name}`);
|
|
905
1218
|
const hidden = client.values;
|
|
906
1219
|
const context = coerceHidden(definition, options.context, { sources: ["server"] }).values;
|
|
907
1220
|
const fields = fieldMap(definition);
|
|
908
|
-
const dropped = /* @__PURE__ */ new Set();
|
|
909
1221
|
const accept = (id) => {
|
|
910
|
-
if (!fields.has(id)) return void ignored.
|
|
1222
|
+
if (!fields.has(id)) return void ignored.add(id);
|
|
911
1223
|
if (!withheld(definition, fields.get(id))) return true;
|
|
912
|
-
|
|
913
|
-
dropped.add(id);
|
|
1224
|
+
ignored.add(id);
|
|
914
1225
|
return false;
|
|
915
1226
|
};
|
|
916
1227
|
const engineHidden = { ...hidden, ...context };
|
|
@@ -963,15 +1274,25 @@ function validateSubmission(definition, submission, options = {}) {
|
|
|
963
1274
|
if (message === null) answers[id] = normalizeValue(value, field);
|
|
964
1275
|
}
|
|
965
1276
|
}
|
|
1277
|
+
const store = (values, report) => {
|
|
1278
|
+
if (definition.settings?.anonymous !== true) return values;
|
|
1279
|
+
const identifying = new Set((definition.hidden ?? []).filter((h) => h.identifying === true).map((h) => h.name));
|
|
1280
|
+
const kept = {};
|
|
1281
|
+
for (const [name, value] of Object.entries(values)) {
|
|
1282
|
+
if (!identifying.has(name)) kept[name] = value;
|
|
1283
|
+
else if (report) ignored.add(`hidden.${name}`);
|
|
1284
|
+
}
|
|
1285
|
+
return kept;
|
|
1286
|
+
};
|
|
966
1287
|
return {
|
|
967
1288
|
ok: true,
|
|
968
1289
|
submission: {
|
|
969
1290
|
status: complete ? "completed" : "draft",
|
|
970
1291
|
answers,
|
|
971
|
-
hidden,
|
|
972
|
-
context,
|
|
1292
|
+
hidden: store(hidden, true),
|
|
1293
|
+
context: store(context, false),
|
|
973
1294
|
ending: complete ? state.ending : null,
|
|
974
|
-
ignored
|
|
1295
|
+
ignored: [...ignored]
|
|
975
1296
|
}
|
|
976
1297
|
};
|
|
977
1298
|
}
|