@nextlyhq/plugin-form-builder 0.0.2-alpha.74 → 0.0.2-alpha.76
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 +2 -1
- package/dist/admin/index.d.ts +117 -87
- package/dist/admin/index.js +9497 -4456
- package/dist/admin/index.js.map +1 -1
- package/dist/index.d.ts +45 -9
- package/dist/index.js +4648 -632
- package/dist/index.js.map +1 -1
- package/dist/submission-title-CVixn15Z.js +2699 -0
- package/dist/submission-title-CVixn15Z.js.map +1 -0
- package/dist/{new-submissions-CzlhVGhH.d.ts → types-XdBrUfi1.d.ts} +202 -44
- package/package.json +10 -10
- package/dist/field-references-CtzGg51W.js +0 -1318
- package/dist/field-references-CtzGg51W.js.map +0 -1
|
@@ -0,0 +1,2699 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// src/form-pages.ts
|
|
4
|
+
/**
|
|
5
|
+
* The form editor's pages, read by the plugin's declaration (which gives each
|
|
6
|
+
* page its address) and by the editor (which draws a tab for each). One list,
|
|
7
|
+
* so a tab cannot lead to an address nobody declared, nor an address go
|
|
8
|
+
* without its tab.
|
|
9
|
+
*
|
|
10
|
+
* No React and no admin imports here: the server reads this module too.
|
|
11
|
+
*
|
|
12
|
+
* @module form-pages
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The form editor, the forms collection's own edit view. The admin draws it at
|
|
16
|
+
* the form's edit address and at each page's address below, so it stays
|
|
17
|
+
* mounted, unsaved work and all, while a person moves between the tabs.
|
|
18
|
+
*/
|
|
19
|
+
const FORM_EDITOR_VIEW = "@nextlyhq/plugin-form-builder/admin#FormBuilderView";
|
|
20
|
+
/** Every page, in the order the tabs show them. */
|
|
21
|
+
const FORM_PAGES = [
|
|
22
|
+
{
|
|
23
|
+
label: "Builder",
|
|
24
|
+
path: void 0
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: "Preview",
|
|
28
|
+
path: "preview"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: "Settings",
|
|
32
|
+
path: "settings"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
label: "Notifications",
|
|
36
|
+
path: "notifications"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
label: "Submissions",
|
|
40
|
+
path: "submissions"
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
/** A page's id. */
|
|
44
|
+
function formPageId(page) {
|
|
45
|
+
return page.path ?? "builder";
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The page an address segment names: Builder for none, or for a segment that
|
|
49
|
+
* is not one of the editor's pages.
|
|
50
|
+
*/
|
|
51
|
+
function formPageOf(view) {
|
|
52
|
+
const page = FORM_PAGES.find((each) => each.path !== void 0 && each.path === view);
|
|
53
|
+
return page === void 0 ? "builder" : formPageId(page);
|
|
54
|
+
}
|
|
55
|
+
/** The pages with an address of their own, for the plugin's declaration. */
|
|
56
|
+
function addressedFormPages() {
|
|
57
|
+
return FORM_PAGES.flatMap((page) => page.path === void 0 ? [] : [{
|
|
58
|
+
path: page.path,
|
|
59
|
+
label: page.label
|
|
60
|
+
}]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/utils/redirect-reference.ts
|
|
64
|
+
/**
|
|
65
|
+
* The reference a `redirectPage` value holds, in any shape a read produces.
|
|
66
|
+
*
|
|
67
|
+
* Three arrive in practice and they are not interchangeable: a polymorphic
|
|
68
|
+
* field stores `{ relationTo, value }`; a read at depth replaces `value` with
|
|
69
|
+
* the populated row; and a single-target field stores the bare id. The last
|
|
70
|
+
* names no collection, so it resolves only when exactly one is configured —
|
|
71
|
+
* guessing which of several a bare id belongs to would send visitors to a
|
|
72
|
+
* URL built from the wrong collection's pattern.
|
|
73
|
+
*/
|
|
74
|
+
function parseRedirectReference(value, collections) {
|
|
75
|
+
if (value == null) return null;
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
const id = value.trim();
|
|
78
|
+
return id && collections.length === 1 ? {
|
|
79
|
+
collection: collections[0],
|
|
80
|
+
id
|
|
81
|
+
} : null;
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== "object" || Array.isArray(value)) return null;
|
|
84
|
+
const record = value;
|
|
85
|
+
const relationTo = record.relationTo;
|
|
86
|
+
const inner = record.value;
|
|
87
|
+
if (typeof relationTo === "string" && relationTo.trim()) {
|
|
88
|
+
const id = referenceId(inner);
|
|
89
|
+
return id ? {
|
|
90
|
+
collection: relationTo.trim(),
|
|
91
|
+
id
|
|
92
|
+
} : null;
|
|
93
|
+
}
|
|
94
|
+
const id = referenceId(record);
|
|
95
|
+
if (!id) return null;
|
|
96
|
+
return collections.length === 1 ? {
|
|
97
|
+
collection: collections[0],
|
|
98
|
+
id
|
|
99
|
+
} : null;
|
|
100
|
+
}
|
|
101
|
+
function referenceId(value) {
|
|
102
|
+
if (typeof value === "string") return value.trim() || null;
|
|
103
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
104
|
+
const { id } = value;
|
|
105
|
+
if (typeof id === "string" && id) return id;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The field the publish lifecycle puts on every row it manages.
|
|
111
|
+
*
|
|
112
|
+
* A collection without the lifecycle may legally define an ordinary field
|
|
113
|
+
* named `status` — "active", "archived", anything — so the NAME `status` does
|
|
114
|
+
* not identify a lifecycle and reading it as one marks live documents as
|
|
115
|
+
* drafts. This field does identify it: it is written by the framework, not by
|
|
116
|
+
* a schema author.
|
|
117
|
+
*
|
|
118
|
+
* Measured on 2026-08-25, creating one document in each kind of collection:
|
|
119
|
+
* a `status: true` collection returns
|
|
120
|
+
* `[createdAt, firstPublishedAt, id, slug, status, title, updatedAt]`, and a
|
|
121
|
+
* plain collection carrying its own `status` text field returns the same list
|
|
122
|
+
* WITHOUT `firstPublishedAt`. It is present on a never-published draft too, so
|
|
123
|
+
* presence marks the lifecycle rather than the act of publishing.
|
|
124
|
+
*/
|
|
125
|
+
const PUBLISH_LIFECYCLE_FIELD = "firstPublishedAt";
|
|
126
|
+
/**
|
|
127
|
+
* Whether this document comes from a collection with the publish lifecycle.
|
|
128
|
+
*
|
|
129
|
+
* Callers that project their reads must request
|
|
130
|
+
* {@link PUBLISH_LIFECYCLE_FIELD}; a projection that omits it makes every
|
|
131
|
+
* document look unmanaged, which reads as "always reachable".
|
|
132
|
+
*/
|
|
133
|
+
function hasPublishLifecycle(document) {
|
|
134
|
+
return PUBLISH_LIFECYCLE_FIELD in document;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Whether a visitor can reach this document.
|
|
138
|
+
*
|
|
139
|
+
* `collectionIsLocalized` is the caller's knowledge of the target collection's
|
|
140
|
+
* `localized` setting, or `undefined` where the caller has no way to know.
|
|
141
|
+
*
|
|
142
|
+
* A localized collection publishes PER LOCALE, on a companion row that no read
|
|
143
|
+
* available here returns. Measured on 2026-08-25 against a collection with
|
|
144
|
+
* `localized: true` whose Spanish translation was published while the default
|
|
145
|
+
* locale stayed a draft: `findByID` answers `status: "draft"` at the default
|
|
146
|
+
* locale AND at `es`, and a `find` filtered to `status: "published"` returns
|
|
147
|
+
* the document at no locale at all — no locale, `en` or `es`. So the main
|
|
148
|
+
* row's `status` does not answer for a localized document, and reading it as
|
|
149
|
+
* an answer marks a page visitors can reach as a draft, refuses a correct save
|
|
150
|
+
* and drops a working redirect.
|
|
151
|
+
*
|
|
152
|
+
* `firstPublishedAt` is the one fact that survives that: it records whether the
|
|
153
|
+
* document has ever been public in ANY language, so its absence is decisive
|
|
154
|
+
* whether or not the collection is localized.
|
|
155
|
+
*/
|
|
156
|
+
function documentReachability(document, collectionIsLocalized) {
|
|
157
|
+
if (!hasPublishLifecycle(document)) return "reachable";
|
|
158
|
+
if (document.status === "published") return "reachable";
|
|
159
|
+
if (collectionIsLocalized === false) return "unreachable";
|
|
160
|
+
return "unknown";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Which settings key names the document this form redirects to, if any.
|
|
164
|
+
*
|
|
165
|
+
* TWO options carry one. "Redirect to a page" stores `redirectPage`, and the
|
|
166
|
+
* URL option falls back to `redirectRelation` when no URL is typed — the shape
|
|
167
|
+
* code-first and legacy forms use, and one the submission path has always
|
|
168
|
+
* resolved. One definition of the question, so the rule that guards a target
|
|
169
|
+
* cannot recognise fewer shapes than the resolver that acts on it: a form the
|
|
170
|
+
* rule skips is a form the resolver still redirects.
|
|
171
|
+
*
|
|
172
|
+
* The two are not equally strict about being EMPTY, deliberately. Naming
|
|
173
|
+
* nothing under "Redirect to a page" contradicts the option itself, so it is
|
|
174
|
+
* reported. Under the URL option an absent relation only means no destination
|
|
175
|
+
* is configured yet — `validateFormConfig` already governs that, and refusing
|
|
176
|
+
* it here would reject saves this rule never governed.
|
|
177
|
+
*/
|
|
178
|
+
function pickedDocumentField(settings) {
|
|
179
|
+
if (settings.confirmationType === "relationship") return "redirectPage";
|
|
180
|
+
if (settings.confirmationType === "redirect" && !settings.redirectUrl && settings.redirectRelation != null) return "redirectRelation";
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/utils/schema-only-read.ts
|
|
185
|
+
/**
|
|
186
|
+
* The hook context that tells the forms collection's `afterRead` hook a read
|
|
187
|
+
* wants the forms themselves and nothing only a display needs, so it skips
|
|
188
|
+
* the submission count it would otherwise run per form: a write checking a
|
|
189
|
+
* submission against its form, and the dashboard card scanning which forms a
|
|
190
|
+
* reader may read, need the count no more than they need a second copy of
|
|
191
|
+
* the form.
|
|
192
|
+
*
|
|
193
|
+
* @module utils/schema-only-read
|
|
194
|
+
*/
|
|
195
|
+
/** The context key the forms `afterRead` hook reads; `true` skips the count. */
|
|
196
|
+
const SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
|
|
197
|
+
/** The context a schema-only read hands the forms collection's hooks. */
|
|
198
|
+
const SCHEMA_ONLY_CONTEXT = { [SCHEMA_ONLY_READ]: true };
|
|
199
|
+
|
|
200
|
+
// src/dashboard/new-submissions.ts
|
|
201
|
+
/** The widget's id, the one core's default names. */
|
|
202
|
+
const NEW_SUBMISSIONS_WIDGET_ID = "form-builder/new-submissions";
|
|
203
|
+
/** The status a submission arrives with, and keeps until someone opens it. */
|
|
204
|
+
const NEW_STATUS = "new";
|
|
205
|
+
/** The admin's root, which a row's link and the list's link start from. */
|
|
206
|
+
const ADMIN_ROOT = "/admin";
|
|
207
|
+
/** The most forms one read answers, the widget reads' own cap. */
|
|
208
|
+
const FORM_PAGE = 50;
|
|
209
|
+
/**
|
|
210
|
+
* The most pages of forms read for the filter: 1,000 forms. Past that, the
|
|
211
|
+
* later forms' submissions are left out of the card and the count, never
|
|
212
|
+
* shown: what is missed is a reader's own, not someone else's. The count then
|
|
213
|
+
* says it is a floor (`atLeast`), so nobody reads it as every one.
|
|
214
|
+
*/
|
|
215
|
+
const MAX_FORM_PAGES = 20;
|
|
216
|
+
/** The submission fields a row is made from; no other is asked for. */
|
|
217
|
+
const SUBMISSION_FIELDS = [
|
|
218
|
+
"id",
|
|
219
|
+
"form",
|
|
220
|
+
"data",
|
|
221
|
+
"submittedAt",
|
|
222
|
+
"createdAt"
|
|
223
|
+
];
|
|
224
|
+
/** The form fields a row is made from. */
|
|
225
|
+
const FORM_FIELDS = [
|
|
226
|
+
"id",
|
|
227
|
+
"name",
|
|
228
|
+
"fields"
|
|
229
|
+
];
|
|
230
|
+
/**
|
|
231
|
+
* Field names and labels that mark a form's name field, compared as letters
|
|
232
|
+
* only (`lettersOf`), so case, spaces, underscores, hyphens and camelCase do
|
|
233
|
+
* not matter: `firstName`, `first_name` and "First name" are all "firstname".
|
|
234
|
+
*/
|
|
235
|
+
const NAME_KEYS = /* @__PURE__ */ new Set([
|
|
236
|
+
"name",
|
|
237
|
+
"fullname",
|
|
238
|
+
"firstname",
|
|
239
|
+
"yourname",
|
|
240
|
+
"yourfullname",
|
|
241
|
+
"yourfirstname"
|
|
242
|
+
]);
|
|
243
|
+
/**
|
|
244
|
+
* The most characters of an answer or a name a row carries. A form may leave
|
|
245
|
+
* a field's length open, so a visitor's answer can be any length; the card
|
|
246
|
+
* draws one line, and the rest would only weigh down its data and the page.
|
|
247
|
+
*/
|
|
248
|
+
const MAX_ROW_TEXT = 140;
|
|
249
|
+
/** The answer types whose first line a row may show under its title. */
|
|
250
|
+
const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea"]);
|
|
251
|
+
/** Up to `limit` ids of forms the reader may read, by id, after `after`. */
|
|
252
|
+
async function formIdPage(services, forms, page) {
|
|
253
|
+
const { items } = await services.find(forms, {
|
|
254
|
+
...page.after === void 0 ? {} : { where: { id: { greater_than: page.after } } },
|
|
255
|
+
sort: "id",
|
|
256
|
+
limit: page.limit,
|
|
257
|
+
select: ["id"],
|
|
258
|
+
status: "all",
|
|
259
|
+
context: SCHEMA_ONLY_CONTEXT,
|
|
260
|
+
...page.signal === void 0 ? {} : { signal: page.signal }
|
|
261
|
+
});
|
|
262
|
+
return items.flatMap((item) => typeof item.id === "string" ? [item.id] : []);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The ids of the forms the reader may read, in pages of `FORM_PAGE`, by id,
|
|
266
|
+
* at most `MAX_FORM_PAGES` of them. A form a read rule keeps from them is
|
|
267
|
+
* never among them. Where the budget is spent, one more id says whether any
|
|
268
|
+
* form was left out.
|
|
269
|
+
*/
|
|
270
|
+
async function readableFormIds(services, forms, signal) {
|
|
271
|
+
const ids = [];
|
|
272
|
+
let after;
|
|
273
|
+
for (let page = 0; page < MAX_FORM_PAGES; page += 1) {
|
|
274
|
+
const pageIds = await formIdPage(services, forms, {
|
|
275
|
+
after,
|
|
276
|
+
limit: FORM_PAGE,
|
|
277
|
+
signal
|
|
278
|
+
});
|
|
279
|
+
ids.push(...pageIds);
|
|
280
|
+
after = pageIds.at(-1);
|
|
281
|
+
if (pageIds.length < FORM_PAGE) return {
|
|
282
|
+
ids,
|
|
283
|
+
complete: true
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
ids,
|
|
288
|
+
complete: (await formIdPage(services, forms, {
|
|
289
|
+
after,
|
|
290
|
+
limit: 1,
|
|
291
|
+
signal
|
|
292
|
+
})).length === 0
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The forms the reader may read, scanned once in `scope` where one is given:
|
|
297
|
+
* the card's data and the header's count, answered in one request, share one
|
|
298
|
+
* scan. Keyed by the forms collection, so a site that renamed it scans its own.
|
|
299
|
+
*/
|
|
300
|
+
function readableFormsOnce(services, forms, read) {
|
|
301
|
+
const scan = () => readableFormIds(services, forms, read.signal);
|
|
302
|
+
return read.scope === void 0 ? scan() : read.scope.getOrCompute(`readable-forms:${forms}`, scan);
|
|
303
|
+
}
|
|
304
|
+
/** The new submissions of the forms named: what the card lists and the header counts. */
|
|
305
|
+
function newSubmissionsWhere(formIds) {
|
|
306
|
+
return {
|
|
307
|
+
status: { equals: "new" },
|
|
308
|
+
form: { in: [...formIds] }
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The card's data: the newest new submissions the card holds, and how many
|
|
313
|
+
* there are. Asked with a `limit` of 0 it reads no rows and still counts
|
|
314
|
+
* them, which is the header's count.
|
|
315
|
+
*/
|
|
316
|
+
async function readNewSubmissions(services, collections, read) {
|
|
317
|
+
const signal = read.signal === void 0 ? {} : { signal: read.signal };
|
|
318
|
+
const allHref = `${ADMIN_ROOT}/collections/${read.submissionsPath}`;
|
|
319
|
+
const collection = read.submissionsPath;
|
|
320
|
+
const readable = await readableFormsOnce(services, collections.forms, read);
|
|
321
|
+
if (readable.ids.length === 0) return {
|
|
322
|
+
items: [],
|
|
323
|
+
total: 0,
|
|
324
|
+
allHref,
|
|
325
|
+
collection
|
|
326
|
+
};
|
|
327
|
+
const found = await services.find(collections.submissions, {
|
|
328
|
+
where: newSubmissionsWhere(readable.ids),
|
|
329
|
+
sort: "-submittedAt",
|
|
330
|
+
limit: read.limit,
|
|
331
|
+
select: SUBMISSION_FIELDS,
|
|
332
|
+
status: "all",
|
|
333
|
+
total: true,
|
|
334
|
+
depth: 0,
|
|
335
|
+
...signal
|
|
336
|
+
});
|
|
337
|
+
const forms = await formsOf(services, collections.forms, found.items, signal);
|
|
338
|
+
return {
|
|
339
|
+
items: found.items.map((item) => submissionRow(item, forms.get(formIdOf(item.form) ?? ""), allHref)),
|
|
340
|
+
total: found.total ?? found.items.length,
|
|
341
|
+
...found.atLeast === true || !readable.complete ? { atLeast: true } : {},
|
|
342
|
+
allHref,
|
|
343
|
+
collection
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** The forms of the submissions drawn, by id: one read, of those forms only. */
|
|
347
|
+
async function formsOf(services, forms, submissions, signal) {
|
|
348
|
+
const ids = [...new Set(submissions.flatMap((row) => formIdOf(row.form) ?? []))];
|
|
349
|
+
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
350
|
+
const { items } = await services.find(forms, {
|
|
351
|
+
where: { id: { in: ids } },
|
|
352
|
+
limit: ids.length,
|
|
353
|
+
select: FORM_FIELDS,
|
|
354
|
+
status: "all",
|
|
355
|
+
context: SCHEMA_ONLY_CONTEXT,
|
|
356
|
+
...signal
|
|
357
|
+
});
|
|
358
|
+
return new Map(items.flatMap((item) => typeof item.id === "string" ? [[item.id, item]] : []));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* One submission as its row: who sent it, which form and what it opens with,
|
|
362
|
+
* and when. `form` is absent where the reader may not read it, and every value
|
|
363
|
+
* comes from a field the read answered, so a field kept from the reader is a
|
|
364
|
+
* part of the row left out, never guessed.
|
|
365
|
+
*/
|
|
366
|
+
function submissionRow(submission, form, allHref) {
|
|
367
|
+
const id = typeof submission.id === "string" ? submission.id : "";
|
|
368
|
+
const answers = recordOf(submission.data);
|
|
369
|
+
const fields = fieldsOf(form?.fields);
|
|
370
|
+
const titleField = [fields.find(isNameField), fields.find((field) => field.type === "email")].find((field) => field !== void 0 && firstLine(answers[field.name]) !== void 0);
|
|
371
|
+
const title = titleField === void 0 ? void 0 : firstLine(answers[titleField.name]);
|
|
372
|
+
const excerpt = fields.filter((field) => TEXT_TYPES.has(field.type) && field !== titleField).map((field) => firstLine(answers[field.name])).find((line) => line !== void 0);
|
|
373
|
+
const formName = firstLine(form?.name);
|
|
374
|
+
const at = instantOf(submission.submittedAt) ?? instantOf(submission.createdAt);
|
|
375
|
+
return {
|
|
376
|
+
id,
|
|
377
|
+
title: title ?? "Submission",
|
|
378
|
+
...formName === void 0 ? {} : { form: formName },
|
|
379
|
+
...excerpt === void 0 ? {} : { excerpt },
|
|
380
|
+
...at === void 0 ? {} : { at },
|
|
381
|
+
href: `${allHref}/${encodeURIComponent(id)}`
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** Whether a form field asks for the submitter's name, by its name or its label. */
|
|
385
|
+
function isNameField(field) {
|
|
386
|
+
return field.type === "text" && (NAME_KEYS.has(lettersOf(field.name)) || NAME_KEYS.has(lettersOf(field.label ?? "")));
|
|
387
|
+
}
|
|
388
|
+
/** A name or label as its letters alone, lower case: "Full name" is "fullname". */
|
|
389
|
+
function lettersOf(text) {
|
|
390
|
+
return text.toLowerCase().replace(/[^a-z]/g, "");
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The first line of a text answer, trimmed and held to `MAX_ROW_TEXT`
|
|
394
|
+
* characters, or `undefined` for none.
|
|
395
|
+
*/
|
|
396
|
+
function firstLine(value) {
|
|
397
|
+
if (typeof value !== "string") return void 0;
|
|
398
|
+
const line = value.split(/\r?\n/).map((part) => part.trim()).find((part) => part !== "");
|
|
399
|
+
return line === void 0 ? void 0 : clipped(line);
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* `text` cut to its first `MAX_ROW_TEXT` characters with an ellipsis, where it
|
|
403
|
+
* is longer. Counted in whole characters, so an emoji is never split in two.
|
|
404
|
+
*/
|
|
405
|
+
function clipped(text) {
|
|
406
|
+
const characters = Array.from(text);
|
|
407
|
+
if (characters.length <= MAX_ROW_TEXT) return text;
|
|
408
|
+
return `${characters.slice(0, MAX_ROW_TEXT).join("").trimEnd()}…`;
|
|
409
|
+
}
|
|
410
|
+
/** A relationship's id, whether the read answered the id or the related row. */
|
|
411
|
+
function formIdOf(value) {
|
|
412
|
+
if (typeof value === "string") return value;
|
|
413
|
+
if (typeof value === "object" && value !== null && "id" in value) {
|
|
414
|
+
const { id } = value;
|
|
415
|
+
return typeof id === "string" ? id : void 0;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** A JSON column's value, which some dialects answer as its text. */
|
|
419
|
+
function parsedJson(value) {
|
|
420
|
+
if (typeof value !== "string") return value;
|
|
421
|
+
try {
|
|
422
|
+
return JSON.parse(value);
|
|
423
|
+
} catch {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** A submission's answers, by field name. */
|
|
428
|
+
function recordOf(value) {
|
|
429
|
+
const parsed = parsedJson(value);
|
|
430
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
431
|
+
}
|
|
432
|
+
/** A form's fields, as far as a row reads them; any other entry is skipped. */
|
|
433
|
+
function fieldsOf(value) {
|
|
434
|
+
const parsed = parsedJson(value);
|
|
435
|
+
if (!Array.isArray(parsed)) return [];
|
|
436
|
+
return parsed.flatMap((entry) => {
|
|
437
|
+
if (typeof entry !== "object" || entry === null) return [];
|
|
438
|
+
const { type, name, label } = entry;
|
|
439
|
+
if (typeof type !== "string" || typeof name !== "string") return [];
|
|
440
|
+
return [{
|
|
441
|
+
type,
|
|
442
|
+
name,
|
|
443
|
+
...typeof label === "string" ? { label } : {}
|
|
444
|
+
}];
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** A date as an ISO string, or `undefined` when it is not one. */
|
|
448
|
+
function instantOf(value) {
|
|
449
|
+
if (typeof value !== "string" && typeof value !== "number") return void 0;
|
|
450
|
+
const at = new Date(value);
|
|
451
|
+
return Number.isNaN(at.getTime()) ? void 0 : at.toISOString();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/utils/evaluate-conditions.ts
|
|
455
|
+
/**
|
|
456
|
+
* Checks if a value is considered "empty".
|
|
457
|
+
* Empty values: undefined, null, empty string, empty array.
|
|
458
|
+
*/
|
|
459
|
+
function isEmptyValue(value) {
|
|
460
|
+
if (value === void 0 || value === null) return true;
|
|
461
|
+
if (typeof value === "string" && value.trim() === "") return true;
|
|
462
|
+
if (Array.isArray(value) && value.length === 0) return true;
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Converts a value to a number for numeric comparisons.
|
|
467
|
+
* Returns NaN if conversion is not possible.
|
|
468
|
+
*/
|
|
469
|
+
function toNumber(value) {
|
|
470
|
+
if (typeof value === "number") return value;
|
|
471
|
+
if (typeof value === "string") {
|
|
472
|
+
const trimmed = value.trim();
|
|
473
|
+
if (trimmed === "") return NaN;
|
|
474
|
+
return Number(trimmed);
|
|
475
|
+
}
|
|
476
|
+
return NaN;
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Converts a value to a string for string comparisons.
|
|
480
|
+
*/
|
|
481
|
+
function toString(value) {
|
|
482
|
+
if (value === void 0 || value === null) return "";
|
|
483
|
+
if (typeof value === "string") return value;
|
|
484
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
485
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
486
|
+
return String(value);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Compares two values using the specified operator.
|
|
490
|
+
*
|
|
491
|
+
* @param operator - The comparison operator to use
|
|
492
|
+
* @param fieldValue - The actual value from the form data
|
|
493
|
+
* @param conditionValue - The expected value from the condition
|
|
494
|
+
* @returns Whether the comparison is true
|
|
495
|
+
*/
|
|
496
|
+
function compareValues(operator, fieldValue, conditionValue) {
|
|
497
|
+
switch (operator) {
|
|
498
|
+
case "equals":
|
|
499
|
+
if (Array.isArray(fieldValue)) {
|
|
500
|
+
if (Array.isArray(conditionValue)) {
|
|
501
|
+
if (fieldValue.length !== conditionValue.length) return false;
|
|
502
|
+
return fieldValue.every((v, i) => v === conditionValue[i]);
|
|
503
|
+
}
|
|
504
|
+
return fieldValue.includes(conditionValue);
|
|
505
|
+
}
|
|
506
|
+
return toString(fieldValue) === toString(conditionValue);
|
|
507
|
+
case "notEquals":
|
|
508
|
+
if (Array.isArray(fieldValue)) {
|
|
509
|
+
if (Array.isArray(conditionValue)) {
|
|
510
|
+
if (fieldValue.length !== conditionValue.length) return true;
|
|
511
|
+
return !fieldValue.every((v, i) => v === conditionValue[i]);
|
|
512
|
+
}
|
|
513
|
+
return !fieldValue.includes(conditionValue);
|
|
514
|
+
}
|
|
515
|
+
return toString(fieldValue) !== toString(conditionValue);
|
|
516
|
+
case "contains": {
|
|
517
|
+
const fieldStr = toString(fieldValue).toLowerCase();
|
|
518
|
+
const conditionStr = toString(conditionValue).toLowerCase();
|
|
519
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => toString(v).toLowerCase().includes(conditionStr));
|
|
520
|
+
return fieldStr.includes(conditionStr);
|
|
521
|
+
}
|
|
522
|
+
case "isEmpty": return isEmptyValue(fieldValue);
|
|
523
|
+
case "isNotEmpty": return !isEmptyValue(fieldValue);
|
|
524
|
+
case "greaterThan": {
|
|
525
|
+
const fieldNum = toNumber(fieldValue);
|
|
526
|
+
const conditionNum = toNumber(conditionValue);
|
|
527
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum > conditionNum;
|
|
528
|
+
return toString(fieldValue) > toString(conditionValue);
|
|
529
|
+
}
|
|
530
|
+
case "lessThan": {
|
|
531
|
+
const fieldNum = toNumber(fieldValue);
|
|
532
|
+
const conditionNum = toNumber(conditionValue);
|
|
533
|
+
if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum < conditionNum;
|
|
534
|
+
return toString(fieldValue) < toString(conditionValue);
|
|
535
|
+
}
|
|
536
|
+
default:
|
|
537
|
+
console.warn(`[evaluateConditions] Unknown comparison operator: ${String(operator)}`);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Evaluates a single condition against the form data.
|
|
543
|
+
*
|
|
544
|
+
* Exported for consumers that gate on one condition rather than a full
|
|
545
|
+
* conditional-logic block — notification send-conditions use exactly this.
|
|
546
|
+
*
|
|
547
|
+
* @param condition - The condition to evaluate
|
|
548
|
+
* @param formData - The current form data
|
|
549
|
+
* @returns Whether the condition is met
|
|
550
|
+
*/
|
|
551
|
+
function evaluateSingleCondition(condition, formData) {
|
|
552
|
+
const { field, comparison, value: conditionValue } = condition;
|
|
553
|
+
return compareValues(comparison, getNestedValue(formData, field), conditionValue);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Gets a nested value from an object using dot notation.
|
|
557
|
+
*
|
|
558
|
+
* @param obj - The object to get the value from
|
|
559
|
+
* @param path - The path to the value (e.g., "address.city")
|
|
560
|
+
* @returns The value at the path, or undefined if not found
|
|
561
|
+
*/
|
|
562
|
+
function getNestedValue(obj, path) {
|
|
563
|
+
if (!path) return;
|
|
564
|
+
if (!path.includes(".")) return obj[path];
|
|
565
|
+
const parts = path.split(".");
|
|
566
|
+
let current = obj;
|
|
567
|
+
for (const part of parts) {
|
|
568
|
+
if (current === void 0 || current === null) return;
|
|
569
|
+
if (typeof current !== "object") return;
|
|
570
|
+
current = current[part];
|
|
571
|
+
}
|
|
572
|
+
return current;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Evaluates conditional logic to determine field visibility.
|
|
576
|
+
*
|
|
577
|
+
* This is the main entry point for conditional logic evaluation.
|
|
578
|
+
* It evaluates all conditions according to the logical operator (AND/OR)
|
|
579
|
+
* and returns whether the field should be visible based on the action
|
|
580
|
+
* (show/hide).
|
|
581
|
+
*
|
|
582
|
+
* @param logic - The conditional logic configuration
|
|
583
|
+
* @param formData - The current form data (field values)
|
|
584
|
+
* @returns Whether the field should be visible
|
|
585
|
+
*
|
|
586
|
+
* @example
|
|
587
|
+
* ```typescript
|
|
588
|
+
* // Show field when country is US AND age is over 18
|
|
589
|
+
* const logic: ConditionalLogic = {
|
|
590
|
+
* enabled: true,
|
|
591
|
+
* action: 'show',
|
|
592
|
+
* operator: 'AND',
|
|
593
|
+
* conditions: [
|
|
594
|
+
* { field: 'country', comparison: 'equals', value: 'US' },
|
|
595
|
+
* { field: 'age', comparison: 'greaterThan', value: 18 },
|
|
596
|
+
* ],
|
|
597
|
+
* };
|
|
598
|
+
*
|
|
599
|
+
* evaluateConditions(logic, { country: 'US', age: 25 }); // true
|
|
600
|
+
* evaluateConditions(logic, { country: 'US', age: 15 }); // false
|
|
601
|
+
* evaluateConditions(logic, { country: 'CA', age: 25 }); // false
|
|
602
|
+
*
|
|
603
|
+
* // Hide field when status is "completed"
|
|
604
|
+
* const hideLogic: ConditionalLogic = {
|
|
605
|
+
* enabled: true,
|
|
606
|
+
* action: 'hide',
|
|
607
|
+
* operator: 'AND',
|
|
608
|
+
* conditions: [
|
|
609
|
+
* { field: 'status', comparison: 'equals', value: 'completed' },
|
|
610
|
+
* ],
|
|
611
|
+
* };
|
|
612
|
+
*
|
|
613
|
+
* evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
|
|
614
|
+
* evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
|
|
615
|
+
* ```
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* ```typescript
|
|
619
|
+
* // Show field when either condition is met (OR)
|
|
620
|
+
* const orLogic: ConditionalLogic = {
|
|
621
|
+
* enabled: true,
|
|
622
|
+
* action: 'show',
|
|
623
|
+
* operator: 'OR',
|
|
624
|
+
* conditions: [
|
|
625
|
+
* { field: 'role', comparison: 'equals', value: 'admin' },
|
|
626
|
+
* { field: 'role', comparison: 'equals', value: 'editor' },
|
|
627
|
+
* ],
|
|
628
|
+
* };
|
|
629
|
+
*
|
|
630
|
+
* evaluateConditions(orLogic, { role: 'admin' }); // true
|
|
631
|
+
* evaluateConditions(orLogic, { role: 'editor' }); // true
|
|
632
|
+
* evaluateConditions(orLogic, { role: 'viewer' }); // false
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
635
|
+
function evaluateConditions(logic, formData) {
|
|
636
|
+
if (!logic || !logic.enabled) return true;
|
|
637
|
+
const { action, operator, conditions } = logic;
|
|
638
|
+
if (!conditions || conditions.length === 0) return true;
|
|
639
|
+
const results = conditions.map((condition) => evaluateSingleCondition(condition, formData));
|
|
640
|
+
let conditionsMet;
|
|
641
|
+
if (operator === "OR") conditionsMet = results.some((result) => result);
|
|
642
|
+
else conditionsMet = results.every((result) => result);
|
|
643
|
+
if (action === "hide") return !conditionsMet;
|
|
644
|
+
return conditionsMet;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Checks if a comparison operator is valid.
|
|
648
|
+
*
|
|
649
|
+
* @param operator - The operator to check
|
|
650
|
+
* @returns Whether the operator is valid
|
|
651
|
+
*/
|
|
652
|
+
function isValidComparisonOperator(operator) {
|
|
653
|
+
return [
|
|
654
|
+
"equals",
|
|
655
|
+
"notEquals",
|
|
656
|
+
"contains",
|
|
657
|
+
"isEmpty",
|
|
658
|
+
"isNotEmpty",
|
|
659
|
+
"greaterThan",
|
|
660
|
+
"lessThan"
|
|
661
|
+
].includes(operator);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Gets the list of supported comparison operators.
|
|
665
|
+
*
|
|
666
|
+
* @returns Array of supported operator names
|
|
667
|
+
*/
|
|
668
|
+
function getSupportedComparisonOperators() {
|
|
669
|
+
return [
|
|
670
|
+
"equals",
|
|
671
|
+
"notEquals",
|
|
672
|
+
"contains",
|
|
673
|
+
"isEmpty",
|
|
674
|
+
"isNotEmpty",
|
|
675
|
+
"greaterThan",
|
|
676
|
+
"lessThan"
|
|
677
|
+
];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/utils/condition-visibility.ts
|
|
681
|
+
/** The names a field's enabled condition reads; none when it has none. */
|
|
682
|
+
function namesRead(field) {
|
|
683
|
+
const logic = field.conditionalLogic;
|
|
684
|
+
return logic?.enabled === true ? logic.conditions.map((condition) => condition.field) : [];
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Which of a form's fields its conditions hide, for a given set of answers.
|
|
688
|
+
*
|
|
689
|
+
* A condition reads the answers of fields that are themselves shown, so a
|
|
690
|
+
* hidden field's leftover answer never decides another field, and it may read
|
|
691
|
+
* a field before or after its own. Fields are decided in the order their
|
|
692
|
+
* conditions depend on each other, whatever their order on the form.
|
|
693
|
+
*
|
|
694
|
+
* Conditions can read each other in a loop (A shown when B is answered, B
|
|
695
|
+
* shown when A is), and no order settles a loop. The rule for one: fields in
|
|
696
|
+
* the same loop read each other's answers as given, and only the loop's
|
|
697
|
+
* fields decide it, so an unrelated field elsewhere on the form never changes
|
|
698
|
+
* the outcome.
|
|
699
|
+
*
|
|
700
|
+
* A field of type `hidden` is not one of them: it is sent with every
|
|
701
|
+
* submission, unseen.
|
|
702
|
+
*/
|
|
703
|
+
function fieldsHiddenByCondition(fields, answers) {
|
|
704
|
+
const byName = new Map(fields.map((field) => [field.name, field]));
|
|
705
|
+
const reach = /* @__PURE__ */ new Map();
|
|
706
|
+
/** Every field `name`'s condition depends on, directly or through others. */
|
|
707
|
+
const dependsOn = (name) => {
|
|
708
|
+
const known = reach.get(name);
|
|
709
|
+
if (known) return known;
|
|
710
|
+
const found = /* @__PURE__ */ new Set();
|
|
711
|
+
reach.set(name, found);
|
|
712
|
+
const pending = [name];
|
|
713
|
+
for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
|
|
714
|
+
const field = byName.get(next);
|
|
715
|
+
for (const read of field ? namesRead(field) : []) if (!found.has(read)) {
|
|
716
|
+
found.add(read);
|
|
717
|
+
pending.push(read);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return found;
|
|
721
|
+
};
|
|
722
|
+
const shown = /* @__PURE__ */ new Map();
|
|
723
|
+
/** Whether field `name` is on the page, deciding what it reads first. */
|
|
724
|
+
const isShown = (name) => {
|
|
725
|
+
const decided = shown.get(name);
|
|
726
|
+
if (decided !== void 0) return decided;
|
|
727
|
+
const field = byName.get(name);
|
|
728
|
+
const logic = field?.conditionalLogic;
|
|
729
|
+
if (!field || logic?.enabled !== true) return true;
|
|
730
|
+
const read = Object.fromEntries(Object.entries(answers).filter(([other]) => !byName.has(other) || !namesRead(field).includes(other) || dependsOn(other).has(name) || isShown(other)));
|
|
731
|
+
const result = evaluateConditions(logic, read);
|
|
732
|
+
shown.set(name, result);
|
|
733
|
+
return result;
|
|
734
|
+
};
|
|
735
|
+
return new Set(fields.filter((field) => !isShown(field.name)).map((field) => field.name));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/utils/asked-fields.ts
|
|
739
|
+
/** A stored option value or label as text: a string or a number. */
|
|
740
|
+
function textOf(value) {
|
|
741
|
+
if (typeof value === "string") return value;
|
|
742
|
+
return typeof value === "number" ? String(value) : "";
|
|
743
|
+
}
|
|
744
|
+
/** One form field as a submission keeps it. */
|
|
745
|
+
function askedField(field) {
|
|
746
|
+
const options = field.options;
|
|
747
|
+
return {
|
|
748
|
+
name: field.name,
|
|
749
|
+
label: field.label || field.name,
|
|
750
|
+
type: field.type,
|
|
751
|
+
...Array.isArray(options) ? { options: options.map((option) => {
|
|
752
|
+
const { value, label } = option;
|
|
753
|
+
const text = textOf(value);
|
|
754
|
+
return {
|
|
755
|
+
value: text,
|
|
756
|
+
label: textOf(label) || text
|
|
757
|
+
};
|
|
758
|
+
}) } : {}
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
/** What a new submission with `data` was asked, from its form's fields. */
|
|
762
|
+
function askedSnapshotOf(fields, data) {
|
|
763
|
+
const hidden = fieldsHiddenByCondition([...fields], data);
|
|
764
|
+
return {
|
|
765
|
+
fields: fields.map(askedField),
|
|
766
|
+
hidden: fields.map((field) => field.name).filter((name) => hidden.has(name))
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/** A stored snapshot, read: as written, or a JSON string of it. */
|
|
770
|
+
function readAskedSnapshot(value) {
|
|
771
|
+
let parsed = value;
|
|
772
|
+
if (typeof parsed === "string") try {
|
|
773
|
+
parsed = JSON.parse(parsed);
|
|
774
|
+
} catch {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
778
|
+
const { fields, hidden } = parsed;
|
|
779
|
+
if (!Array.isArray(fields)) return void 0;
|
|
780
|
+
return {
|
|
781
|
+
fields: fields.filter((field) => !!field && typeof field.name === "string" && typeof field.label === "string" && typeof field.type === "string"),
|
|
782
|
+
hidden: Array.isArray(hidden) ? hidden.filter((name) => typeof name === "string") : []
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* The snapshot after staff edit answers. An answer that changed is read with
|
|
787
|
+
* the field as it was when edited to, so an edit to an option added later
|
|
788
|
+
* keeps that option's label; every other field stays as the visitor was
|
|
789
|
+
* asked it. A submission with no snapshot keeps none.
|
|
790
|
+
*/
|
|
791
|
+
function askedAfterEdit(stored, fields, before, after) {
|
|
792
|
+
if (stored === void 0) return void 0;
|
|
793
|
+
const changed = new Set(fields.map((field) => field.name).filter((name) => JSON.stringify(before[name]) !== JSON.stringify(after[name])));
|
|
794
|
+
if (changed.size === 0) return stored;
|
|
795
|
+
const current = new Map(fields.map((field) => [field.name, field]));
|
|
796
|
+
const kept = stored.fields.map((field) => changed.has(field.name) && current.has(field.name) ? askedField(current.get(field.name)) : field);
|
|
797
|
+
for (const name of changed) if (!kept.some((field) => field.name === name) && current.has(name)) kept.push(askedField(current.get(name)));
|
|
798
|
+
return {
|
|
799
|
+
fields: kept,
|
|
800
|
+
hidden: stored.hidden.filter((name) => !changed.has(name))
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* The fields a submission's page reads its answers with: as it was asked, or
|
|
805
|
+
* the form as it is now for one stored before submissions kept that.
|
|
806
|
+
*/
|
|
807
|
+
function askedFieldsOf(stored, formFields, data) {
|
|
808
|
+
return readAskedSnapshot(stored) ?? askedSnapshotOf(formFields, data);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/utils/consent.ts
|
|
812
|
+
/** Said when an edit of a submission would change a consent. */
|
|
813
|
+
const CONSENT_EDIT_REFUSED = "A consent cannot be edited: it keeps what the visitor agreed to";
|
|
814
|
+
/** Whether a value is a stored consent answer. */
|
|
815
|
+
function isConsentAnswer(value) {
|
|
816
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
817
|
+
const answer = value;
|
|
818
|
+
return typeof answer.agreed === "boolean" && typeof answer.text === "string";
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Whether the visitor agreed. Only an explicit yes counts: a box sends `on`,
|
|
822
|
+
* a script `true`; anything else, a stray string included, is not consent.
|
|
823
|
+
*/
|
|
824
|
+
function agreedIn(value) {
|
|
825
|
+
if (value === true) return true;
|
|
826
|
+
if (typeof value === "string") return value === "true" || value === "1" || value === "on";
|
|
827
|
+
if (isConsentAnswer(value)) return value.agreed;
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* The answer to store for what the visitor sent. The text is the form's, so a
|
|
832
|
+
* visitor who posts an answer object with a text of their own still stores
|
|
833
|
+
* the text the form shows.
|
|
834
|
+
*/
|
|
835
|
+
function consentAnswerOf(value, field) {
|
|
836
|
+
return {
|
|
837
|
+
agreed: agreedIn(value),
|
|
838
|
+
text: field.agreementText
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function sameConsent(before, after) {
|
|
842
|
+
if (before === void 0) return after === void 0;
|
|
843
|
+
if (isConsentAnswer(before)) return isConsentAnswer(after) && before.agreed === after.agreed && before.text === after.text;
|
|
844
|
+
return JSON.stringify(before) === JSON.stringify(after);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* The answers of a stored submission that are consents: each consent field
|
|
848
|
+
* of the form, and any answer stored as a consent, whatever field now has
|
|
849
|
+
* its name. Read from the submission as well as the form, since a form can
|
|
850
|
+
* lose its consent field, or give its name to another field, while its
|
|
851
|
+
* submissions keep what was agreed to.
|
|
852
|
+
*/
|
|
853
|
+
function storedConsentNames(fields, stored) {
|
|
854
|
+
const names = new Set(fields.filter((field) => field.type === "consent").map((field) => field.name));
|
|
855
|
+
for (const [name, value] of Object.entries(stored)) if (isConsentAnswer(value)) names.add(name);
|
|
856
|
+
return [...names];
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* The consents whose stored answer an edit would change: the edit's answers
|
|
860
|
+
* against the stored ones, for each consent the submission holds. One left
|
|
861
|
+
* out of the edit is a change too, since it would drop the answer.
|
|
862
|
+
*/
|
|
863
|
+
function editedConsents(fields, stored, edited) {
|
|
864
|
+
return storedConsentNames(fields, stored).filter((name) => !sameConsent(stored[name], edited[name]));
|
|
865
|
+
}
|
|
866
|
+
/** Said when the builder is asked to make a consent optional. */
|
|
867
|
+
const CONSENT_REQUIRED_REASON = "A consent is always required: an answer nobody has to give records nothing";
|
|
868
|
+
/**
|
|
869
|
+
* A field as the builder keeps it after an edit, or why the edit is refused.
|
|
870
|
+
* A consent stays required, so turning Required off is refused and said; a
|
|
871
|
+
* field just made a consent becomes required, with a text to agree to.
|
|
872
|
+
*/
|
|
873
|
+
function consentFieldEdit(before, after) {
|
|
874
|
+
if (after.type !== "consent") return { field: after };
|
|
875
|
+
if (before.type === "consent" && after.required !== true) return { refused: CONSENT_REQUIRED_REASON };
|
|
876
|
+
const text = after.agreementText;
|
|
877
|
+
return { field: {
|
|
878
|
+
...after,
|
|
879
|
+
required: true,
|
|
880
|
+
agreementText: typeof text === "string" ? text : "I agree to the privacy policy"
|
|
881
|
+
} };
|
|
882
|
+
}
|
|
883
|
+
/** Said when a consent has no text to agree to. */
|
|
884
|
+
const CONSENT_TEXT_REQUIRED = "A consent field needs the text the visitor agrees to.";
|
|
885
|
+
/**
|
|
886
|
+
* The consent fields with no text to agree to. A consent with a blank text
|
|
887
|
+
* would record agreement to nothing, so a form holding one is not saved, by
|
|
888
|
+
* the builder or any other door.
|
|
889
|
+
*/
|
|
890
|
+
function blankConsentFields(fields) {
|
|
891
|
+
return fields.filter((field) => field.type === "consent" && (typeof field.agreementText !== "string" || field.agreementText.trim() === ""));
|
|
892
|
+
}
|
|
893
|
+
/** Said when a visitor agreed to a text the form no longer shows. */
|
|
894
|
+
const CONSENT_TEXT_CHANGED = "The text to agree to has changed since the form was loaded. Review it and agree again.";
|
|
895
|
+
/**
|
|
896
|
+
* The consent fields a new submission agreed to under a text the form no
|
|
897
|
+
* longer shows. A visitor who sends the text they were shown (an answer of
|
|
898
|
+
* `{ agreed, text }`) is held to it: if the author changed it while the form
|
|
899
|
+
* was open, the submission is refused rather than recorded as agreement to
|
|
900
|
+
* terms the visitor never saw. A bare yes carries no text, so it is taken
|
|
901
|
+
* as agreement to the text the form shows now.
|
|
902
|
+
*/
|
|
903
|
+
function staleConsentNames(fields, data) {
|
|
904
|
+
return fields.filter((field) => field.type === "consent").filter((field) => {
|
|
905
|
+
const sent = data[field.name];
|
|
906
|
+
if (!sent || typeof sent !== "object" || Array.isArray(sent)) return false;
|
|
907
|
+
const text = sent.text;
|
|
908
|
+
return typeof text === "string" && text !== field.agreementText;
|
|
909
|
+
}).map((field) => field.name);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// src/utils/export-formats.ts
|
|
913
|
+
/** UTF-8 BOM character sequence */
|
|
914
|
+
const UTF8_BOM = "";
|
|
915
|
+
/**
|
|
916
|
+
* Stringify a value safely, falling back to JSON for plain objects so we
|
|
917
|
+
* never emit the default `[object Object]` representation.
|
|
918
|
+
*/
|
|
919
|
+
function safeString(value) {
|
|
920
|
+
if (typeof value === "object" && value !== null) return JSON.stringify(value);
|
|
921
|
+
return String(value);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Format a field value for export based on field type.
|
|
925
|
+
*
|
|
926
|
+
* @param value - The raw value from submission data
|
|
927
|
+
* @param field - The field definition (optional, for type-specific formatting)
|
|
928
|
+
* @returns Formatted string representation of the value
|
|
929
|
+
*
|
|
930
|
+
* @example
|
|
931
|
+
* ```typescript
|
|
932
|
+
* formatExportValue(true, { type: 'checkbox' }); // "Yes"
|
|
933
|
+
* formatExportValue(['a', 'b'], { type: 'select' }); // "a, b"
|
|
934
|
+
* formatExportValue(new Date(), { type: 'date' }); // "2024-01-15"
|
|
935
|
+
* ```
|
|
936
|
+
*/
|
|
937
|
+
function formatExportValue(value, field) {
|
|
938
|
+
if (value === null || value === void 0) return "";
|
|
939
|
+
if (field) switch (field.type) {
|
|
940
|
+
case "checkbox": return value ? "Yes" : "No";
|
|
941
|
+
case "select":
|
|
942
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
943
|
+
return safeString(value);
|
|
944
|
+
case "file":
|
|
945
|
+
if (Array.isArray(value)) return value.map((v) => safeString(v)).join(", ");
|
|
946
|
+
return safeString(value);
|
|
947
|
+
case "date":
|
|
948
|
+
if (value instanceof Date) return value.toISOString().split("T")[0];
|
|
949
|
+
if (typeof value === "string") {
|
|
950
|
+
const parsed = new Date(value);
|
|
951
|
+
if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
|
|
952
|
+
}
|
|
953
|
+
return safeString(value);
|
|
954
|
+
case "time": return safeString(value);
|
|
955
|
+
case "hidden": return safeString(value);
|
|
956
|
+
case "consent":
|
|
957
|
+
if (isConsentAnswer(value)) return `${value.agreed ? "Agreed" : "Not agreed"}: "${value.text}"`;
|
|
958
|
+
return safeString(value);
|
|
959
|
+
}
|
|
960
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
961
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
962
|
+
if (value instanceof Date) return value.toISOString();
|
|
963
|
+
return safeString(value);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* A choice answer by its options' labels as asked, several joined. A value
|
|
967
|
+
* no option has (an option removed since) reads as itself.
|
|
968
|
+
*/
|
|
969
|
+
function optionLabels(field, value) {
|
|
970
|
+
return (Array.isArray(value) ? value : [value]).map((each) => {
|
|
971
|
+
const text = typeof each === "string" ? each : formatExportValue(each);
|
|
972
|
+
return field.options?.find((option) => option.value === text)?.label ?? text;
|
|
973
|
+
}).join(", ");
|
|
974
|
+
}
|
|
975
|
+
/** The kinds of field answered by picking among options. */
|
|
976
|
+
const CHOICE_TYPES = /* @__PURE__ */ new Set(["select", "radio"]);
|
|
977
|
+
/**
|
|
978
|
+
* An answer as one line of text, read with the field as it was asked: a
|
|
979
|
+
* choice by its options' labels, so a list and an export read "Backend
|
|
980
|
+
* engineer" rather than the stored `backend-engineer`; anything else as
|
|
981
|
+
* `formatExportValue` reads it.
|
|
982
|
+
*/
|
|
983
|
+
function answerText(value, field) {
|
|
984
|
+
if (value === null || value === void 0) return "";
|
|
985
|
+
return CHOICE_TYPES.has(field.type) ? optionLabels(field, value) : formatExportValue(value, field);
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* A submission's answers as text, each read with its field as the submission
|
|
989
|
+
* was asked it, or as the form has it now for a field it was not asked (one
|
|
990
|
+
* added since) or a submission stored before submissions kept that. The
|
|
991
|
+
* submissions list and the CSV export both read answers through this, so the
|
|
992
|
+
* two cannot show one answer differently.
|
|
993
|
+
*/
|
|
994
|
+
function answerReader(submission, formFields) {
|
|
995
|
+
const data = submission.data ?? {};
|
|
996
|
+
const asked = new Map(askedFieldsOf(submission.asked, formFields, data).fields.map((each) => [each.name, each]));
|
|
997
|
+
return (field) => answerText(data[field.name], asked.get(field.name) ?? askedField(field));
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Format a date for export.
|
|
1001
|
+
*
|
|
1002
|
+
* @param date - Date value (Date object or string)
|
|
1003
|
+
* @param format - Output format ("iso" or "locale")
|
|
1004
|
+
* @returns Formatted date string
|
|
1005
|
+
*/
|
|
1006
|
+
function formatDate(date, format = "iso") {
|
|
1007
|
+
if (!date) return "";
|
|
1008
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
1009
|
+
if (isNaN(d.getTime())) return "";
|
|
1010
|
+
return format === "iso" ? d.toISOString() : d.toLocaleString();
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Escape a value for CSV format (RFC 4180 compliant).
|
|
1014
|
+
*
|
|
1015
|
+
* Rules:
|
|
1016
|
+
* - Fields containing comma, double-quote, or newline must be quoted
|
|
1017
|
+
* - Double-quotes within fields must be escaped with another double-quote
|
|
1018
|
+
*
|
|
1019
|
+
* @param value - The value to escape
|
|
1020
|
+
* @param delimiter - The delimiter being used
|
|
1021
|
+
* @returns Properly escaped CSV value
|
|
1022
|
+
*/
|
|
1023
|
+
function escapeCSVValue(value, delimiter = ",") {
|
|
1024
|
+
const escapedFormula = /^[=+\-@\t\r]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
|
|
1025
|
+
if (!(escapedFormula.includes(delimiter) || escapedFormula.includes("\"") || escapedFormula.includes("\n") || escapedFormula.includes("\r"))) return escapedFormula;
|
|
1026
|
+
return `"${escapedFormula.replace(/"/g, "\"\"")}"`;
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Export submissions to CSV format.
|
|
1030
|
+
*
|
|
1031
|
+
* Generates an RFC 4180 compliant CSV string with:
|
|
1032
|
+
* - Form field values as columns (in form definition order), headed by the
|
|
1033
|
+
* fields' labels, a choice answer by its option's label
|
|
1034
|
+
* - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
|
|
1035
|
+
* - Proper escaping of special characters
|
|
1036
|
+
* - Optional UTF-8 BOM for Excel compatibility
|
|
1037
|
+
*
|
|
1038
|
+
* @param submissions - Array of submission documents to export
|
|
1039
|
+
* @param form - Form document containing field definitions
|
|
1040
|
+
* @param options - Export options
|
|
1041
|
+
* @returns CSV formatted string
|
|
1042
|
+
*
|
|
1043
|
+
* @example
|
|
1044
|
+
* ```typescript
|
|
1045
|
+
* const csv = exportToCSV(submissions, form, {
|
|
1046
|
+
* includeMetadata: true,
|
|
1047
|
+
* includeBOM: true,
|
|
1048
|
+
* });
|
|
1049
|
+
*
|
|
1050
|
+
* // Download in browser
|
|
1051
|
+
* downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
|
|
1052
|
+
* ```
|
|
1053
|
+
*/
|
|
1054
|
+
function exportToCSV(submissions, form, options = {}) {
|
|
1055
|
+
const { includeMetadata = true, delimiter = ",", includeBOM = true, dateFormat = "iso" } = options;
|
|
1056
|
+
const exportableFields = form.fields;
|
|
1057
|
+
const headers = [];
|
|
1058
|
+
for (const field of exportableFields) headers.push(field.label);
|
|
1059
|
+
if (includeMetadata) headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
|
|
1060
|
+
const rows = [];
|
|
1061
|
+
for (const submission of submissions) {
|
|
1062
|
+
const row = [];
|
|
1063
|
+
const answer = answerReader(submission, exportableFields);
|
|
1064
|
+
for (const field of exportableFields) row.push(escapeCSVValue(answer(field), delimiter));
|
|
1065
|
+
if (includeMetadata) {
|
|
1066
|
+
row.push(escapeCSVValue(submission.id, delimiter));
|
|
1067
|
+
row.push(escapeCSVValue(submission.status, delimiter));
|
|
1068
|
+
row.push(escapeCSVValue(formatDate(submission.submittedAt, dateFormat), delimiter));
|
|
1069
|
+
row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
|
|
1070
|
+
row.push(escapeCSVValue(submission.userAgent || "", delimiter));
|
|
1071
|
+
}
|
|
1072
|
+
rows.push(row);
|
|
1073
|
+
}
|
|
1074
|
+
const csvContent = [headers.map((h) => escapeCSVValue(h, delimiter)).join(delimiter), ...rows.map((row) => row.join(delimiter))].join("\r\n");
|
|
1075
|
+
return includeBOM ? UTF8_BOM + csvContent : csvContent;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Export submissions to JSON format.
|
|
1079
|
+
*
|
|
1080
|
+
* Generates a structured JSON export with:
|
|
1081
|
+
* - Export metadata (timestamp)
|
|
1082
|
+
* - Optional form definition
|
|
1083
|
+
* - Array of submissions with field values
|
|
1084
|
+
* - Optional submission metadata (_id, _status, etc.)
|
|
1085
|
+
*
|
|
1086
|
+
* @param submissions - Array of submission documents to export
|
|
1087
|
+
* @param form - Form document containing field definitions
|
|
1088
|
+
* @param options - Export options
|
|
1089
|
+
* @returns JSON formatted string
|
|
1090
|
+
*
|
|
1091
|
+
* @example
|
|
1092
|
+
* ```typescript
|
|
1093
|
+
* const json = exportToJSON(submissions, form, {
|
|
1094
|
+
* includeMetadata: true,
|
|
1095
|
+
* includeFormDefinition: true,
|
|
1096
|
+
* indent: 2,
|
|
1097
|
+
* });
|
|
1098
|
+
*
|
|
1099
|
+
* // Download in browser
|
|
1100
|
+
* downloadFile(json, 'submissions.json', 'application/json');
|
|
1101
|
+
* ```
|
|
1102
|
+
*/
|
|
1103
|
+
function exportToJSON(submissions, form, options = {}) {
|
|
1104
|
+
const { includeMetadata = true, includeFormDefinition = true, indent = 2 } = options;
|
|
1105
|
+
const exportableFields = form.fields;
|
|
1106
|
+
const formDefinition = includeFormDefinition ? {
|
|
1107
|
+
id: form.id,
|
|
1108
|
+
name: form.name,
|
|
1109
|
+
slug: form.slug,
|
|
1110
|
+
fields: exportableFields.map((f) => ({
|
|
1111
|
+
name: f.name,
|
|
1112
|
+
label: f.label,
|
|
1113
|
+
type: f.type
|
|
1114
|
+
}))
|
|
1115
|
+
} : void 0;
|
|
1116
|
+
const exportedSubmissions = submissions.map((submission) => {
|
|
1117
|
+
const data = submission.data;
|
|
1118
|
+
const fieldData = {};
|
|
1119
|
+
for (const field of exportableFields) {
|
|
1120
|
+
const value = data[field.name];
|
|
1121
|
+
fieldData[field.name] = value;
|
|
1122
|
+
}
|
|
1123
|
+
if (includeMetadata) return {
|
|
1124
|
+
...fieldData,
|
|
1125
|
+
_metadata: {
|
|
1126
|
+
id: submission.id,
|
|
1127
|
+
status: submission.status,
|
|
1128
|
+
submittedAt: submission.submittedAt,
|
|
1129
|
+
ipAddress: submission.ipAddress || null,
|
|
1130
|
+
userAgent: submission.userAgent || null,
|
|
1131
|
+
createdAt: submission.createdAt,
|
|
1132
|
+
updatedAt: submission.updatedAt
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
return fieldData;
|
|
1136
|
+
});
|
|
1137
|
+
const exportData = {
|
|
1138
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1139
|
+
totalSubmissions: submissions.length,
|
|
1140
|
+
submissions: exportedSubmissions
|
|
1141
|
+
};
|
|
1142
|
+
if (formDefinition) exportData.form = formDefinition;
|
|
1143
|
+
return JSON.stringify(exportData, null, indent);
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Trigger a file download in the browser.
|
|
1147
|
+
*
|
|
1148
|
+
* Creates a Blob from the content and triggers a download using a temporary
|
|
1149
|
+
* anchor element. This function is browser-only and will throw in Node.js.
|
|
1150
|
+
*
|
|
1151
|
+
* @param content - File content as string
|
|
1152
|
+
* @param filename - Name for the downloaded file
|
|
1153
|
+
* @param mimeType - MIME type of the file
|
|
1154
|
+
*
|
|
1155
|
+
* @example
|
|
1156
|
+
* ```typescript
|
|
1157
|
+
* const csv = exportToCSV(submissions, form);
|
|
1158
|
+
* downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
|
|
1159
|
+
*
|
|
1160
|
+
* const json = exportToJSON(submissions, form);
|
|
1161
|
+
* downloadFile(json, 'submissions.json', 'application/json');
|
|
1162
|
+
* ```
|
|
1163
|
+
*/
|
|
1164
|
+
function downloadFile(content, filename, mimeType) {
|
|
1165
|
+
if (typeof window === "undefined" || typeof document === "undefined") throw new Error("downloadFile is only available in browser environments");
|
|
1166
|
+
const blob = new Blob([content], { type: mimeType });
|
|
1167
|
+
const url = URL.createObjectURL(blob);
|
|
1168
|
+
const link = document.createElement("a");
|
|
1169
|
+
link.href = url;
|
|
1170
|
+
link.download = filename;
|
|
1171
|
+
document.body.appendChild(link);
|
|
1172
|
+
link.click();
|
|
1173
|
+
document.body.removeChild(link);
|
|
1174
|
+
URL.revokeObjectURL(url);
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Generate a filename for export.
|
|
1178
|
+
*
|
|
1179
|
+
* Creates a filename with form slug and current timestamp.
|
|
1180
|
+
*
|
|
1181
|
+
* @param formSlug - The form's slug
|
|
1182
|
+
* @param format - Export format ('csv' or 'json')
|
|
1183
|
+
* @returns Generated filename
|
|
1184
|
+
*
|
|
1185
|
+
* @example
|
|
1186
|
+
* ```typescript
|
|
1187
|
+
* generateExportFilename('contact-form', 'csv');
|
|
1188
|
+
* // Returns: "contact-form-submissions-2024-01-15.csv"
|
|
1189
|
+
* ```
|
|
1190
|
+
*/
|
|
1191
|
+
function generateExportFilename(formSlug, format) {
|
|
1192
|
+
return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Export submissions and trigger browser download.
|
|
1196
|
+
*
|
|
1197
|
+
* Convenience function that combines export and download in one call.
|
|
1198
|
+
* Browser-only.
|
|
1199
|
+
*
|
|
1200
|
+
* @param submissions - Array of submission documents
|
|
1201
|
+
* @param form - Form document
|
|
1202
|
+
* @param format - Export format ('csv' or 'json')
|
|
1203
|
+
* @param options - Format-specific options
|
|
1204
|
+
*
|
|
1205
|
+
* @example
|
|
1206
|
+
* ```typescript
|
|
1207
|
+
* // In a React component
|
|
1208
|
+
* const handleExport = (format: 'csv' | 'json') => {
|
|
1209
|
+
* exportAndDownload(submissions, form, format);
|
|
1210
|
+
* };
|
|
1211
|
+
* ```
|
|
1212
|
+
*/
|
|
1213
|
+
function exportAndDownload(submissions, form, format, options) {
|
|
1214
|
+
const filename = generateExportFilename(form.slug, format);
|
|
1215
|
+
if (format === "csv") downloadFile(exportToCSV(submissions, form, options), filename, "text/csv;charset=utf-8");
|
|
1216
|
+
else downloadFile(exportToJSON(submissions, form, options), filename, "application/json");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// src/emails/starter-messages.ts
|
|
1220
|
+
/** The team's email: every answer, so the inbox has the whole submission. */
|
|
1221
|
+
const FORM_SUBMISSION_TEMPLATE = "forms-submission";
|
|
1222
|
+
/** The visitor's email: confirms the submission arrived, with a copy. */
|
|
1223
|
+
const FORM_CONFIRMATION_TEMPLATE = "forms-confirmation";
|
|
1224
|
+
/** The answers as a two-column table, label then value, one row each. */
|
|
1225
|
+
const ANSWERS_TABLE = "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width: 100%; border-collapse: collapse;\">{% for answer in _submission.answers %}<tr><td style=\"width: 40%; padding: 8px 16px 8px 0; vertical-align: top; font-weight: 600; overflow-wrap: anywhere;\">{{ answer.label }}</td><td style=\"padding: 8px 0; vertical-align: top; overflow-wrap: anywhere;\">{{ answer.value }}</td></tr>{% endfor %}</table>";
|
|
1226
|
+
/**
|
|
1227
|
+
* Shown where the table would be when no answer list was sent: the Email hub's
|
|
1228
|
+
* preview and Send test, whose sample data has a placeholder string for each
|
|
1229
|
+
* variable. A real send always carries a list, even an empty one, which
|
|
1230
|
+
* Liquid treats as present, so a sent email never shows this line.
|
|
1231
|
+
*/
|
|
1232
|
+
const PREVIEW_NOTE = `{% unless _submission.answers %}<p><em>Each answer appears here, its question then what was answered.</em></p>{% endunless %}`;
|
|
1233
|
+
/** The answers as plain text, one per line, for mail clients that show text. */
|
|
1234
|
+
const ANSWERS_TEXT = `{% for answer in _submission.answers %}{{ answer.label }}: {{ answer.value }}\n{% endfor %}`;
|
|
1235
|
+
/** The variables both messages are sent with, as the Email hub lists them. */
|
|
1236
|
+
const VARIABLES = [{
|
|
1237
|
+
name: "formName",
|
|
1238
|
+
description: "The form's name",
|
|
1239
|
+
required: true
|
|
1240
|
+
}, {
|
|
1241
|
+
name: "_submission",
|
|
1242
|
+
description: "The submission: `_submission.answers` holds each answer as `label` and `value`, in the form's order, leaving out questions left blank or hidden by a condition; `_submission.id` is its id. It starts with an underscore, which no form field's name may, so it never meets a submitted field of the same name",
|
|
1243
|
+
required: true
|
|
1244
|
+
}];
|
|
1245
|
+
/** The messages the plugin contributes to Settings › Email. */
|
|
1246
|
+
function formsEmailTemplates() {
|
|
1247
|
+
return [{
|
|
1248
|
+
slug: FORM_SUBMISSION_TEMPLATE,
|
|
1249
|
+
name: "Form submission",
|
|
1250
|
+
description: "Sent to your team when someone submits a form, with every answer.",
|
|
1251
|
+
subject: "New submission: {{ formName }}",
|
|
1252
|
+
htmlContent: `<p>Someone submitted <strong>{{ formName }}</strong>.</p>${ANSWERS_TABLE}${PREVIEW_NOTE}`,
|
|
1253
|
+
plainTextContent: `Someone submitted {{ formName }}.\n\n${ANSWERS_TEXT}`,
|
|
1254
|
+
variables: VARIABLES,
|
|
1255
|
+
engine: "liquid"
|
|
1256
|
+
}, {
|
|
1257
|
+
slug: FORM_CONFIRMATION_TEMPLATE,
|
|
1258
|
+
name: "Form confirmation",
|
|
1259
|
+
description: "Sent to the person who submitted a form, confirming it arrived, with a copy of their answers.",
|
|
1260
|
+
subject: "We received your submission",
|
|
1261
|
+
htmlContent: `<p>Thank you. We received your answers to <strong>{{ formName }}</strong> and will be in touch if we need anything else.</p><p>Here is a copy of what you sent:</p>${ANSWERS_TABLE}${PREVIEW_NOTE}`,
|
|
1262
|
+
plainTextContent: `Thank you. We received your answers to {{ formName }} and will be in touch if we need anything else.\n\nHere is a copy of what you sent:\n\n${ANSWERS_TEXT}`,
|
|
1263
|
+
variables: VARIABLES,
|
|
1264
|
+
engine: "liquid"
|
|
1265
|
+
}];
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* The starter message for a notification's kind: the confirmation when it goes
|
|
1269
|
+
* to the visitor (the address in one of the form's fields), the team's email
|
|
1270
|
+
* otherwise.
|
|
1271
|
+
*/
|
|
1272
|
+
function starterTemplateFor(recipientType) {
|
|
1273
|
+
return recipientType === "field" ? FORM_CONFIRMATION_TEMPLATE : FORM_SUBMISSION_TEMPLATE;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// src/config/removed-options.ts
|
|
1277
|
+
/** Forms already told, so a config passing through both entry points hears it once. */
|
|
1278
|
+
const warned = /* @__PURE__ */ new Set();
|
|
1279
|
+
/**
|
|
1280
|
+
* Say so when a form config still sets `notifications.webhooks`.
|
|
1281
|
+
*
|
|
1282
|
+
* The plugin sends no webhooks of its own: a submission reaches other systems
|
|
1283
|
+
* through Nextly's `form.submission.created` webhook, so the option sends
|
|
1284
|
+
* nothing and the config is told what to use instead.
|
|
1285
|
+
*/
|
|
1286
|
+
function warnIfWebhooksConfigured(config) {
|
|
1287
|
+
const notifications = config.notifications;
|
|
1288
|
+
if (typeof notifications !== "object" || notifications === null) return;
|
|
1289
|
+
if (!("webhooks" in notifications) || warned.has(config.slug)) return;
|
|
1290
|
+
warned.add(config.slug);
|
|
1291
|
+
console.warn(`[form-builder] Form "${config.slug}" sets notifications.webhooks, which the Form Builder no longer reads, so those webhooks are not sent. Subscribe an endpoint to Nextly's form.submission.created webhook instead: https://nextlyhq.com/docs/plugins/form-builder#webhooks`);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// src/config/defaults.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* Default form settings.
|
|
1297
|
+
*/
|
|
1298
|
+
const DEFAULT_FORM_SETTINGS = {
|
|
1299
|
+
submitButtonText: "Submit",
|
|
1300
|
+
confirmationType: "message",
|
|
1301
|
+
successMessage: "Thank you for your submission!",
|
|
1302
|
+
allowMultipleSubmissions: true,
|
|
1303
|
+
oneResponse: "off",
|
|
1304
|
+
oneResponseVersion: 0,
|
|
1305
|
+
botCheck: "default",
|
|
1306
|
+
storeIpAndBrowser: true
|
|
1307
|
+
};
|
|
1308
|
+
/**
|
|
1309
|
+
* Default notification settings.
|
|
1310
|
+
*/
|
|
1311
|
+
const DEFAULT_NOTIFICATION_SETTINGS = {
|
|
1312
|
+
enabled: true,
|
|
1313
|
+
replyTo: true
|
|
1314
|
+
};
|
|
1315
|
+
/**
|
|
1316
|
+
* Converts a slug to title case.
|
|
1317
|
+
*
|
|
1318
|
+
* @param str - The slug string to convert
|
|
1319
|
+
* @returns Title case string
|
|
1320
|
+
*
|
|
1321
|
+
* @example
|
|
1322
|
+
* ```typescript
|
|
1323
|
+
* toTitleCase('contact-form'); // 'Contact Form'
|
|
1324
|
+
* toTitleCase('newsletter_signup'); // 'Newsletter Signup'
|
|
1325
|
+
* ```
|
|
1326
|
+
*/
|
|
1327
|
+
function toTitleCase(str) {
|
|
1328
|
+
return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Generates a plural form from a singular label.
|
|
1332
|
+
* Handles common English pluralization rules.
|
|
1333
|
+
*
|
|
1334
|
+
* @param singular - The singular form
|
|
1335
|
+
* @returns The plural form
|
|
1336
|
+
*
|
|
1337
|
+
* @example
|
|
1338
|
+
* ```typescript
|
|
1339
|
+
* pluralize('Form'); // 'Forms'
|
|
1340
|
+
* pluralize('Category'); // 'Categories'
|
|
1341
|
+
* pluralize('Quiz'); // 'Quizzes'
|
|
1342
|
+
* ```
|
|
1343
|
+
*/
|
|
1344
|
+
function pluralize(singular) {
|
|
1345
|
+
if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) return singular.slice(0, -1) + "ies";
|
|
1346
|
+
if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) return singular + "es";
|
|
1347
|
+
return singular + "s";
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Apply default values to form configuration.
|
|
1351
|
+
*
|
|
1352
|
+
* This function takes a partial form configuration and returns a complete
|
|
1353
|
+
* configuration with all default values applied. User-specified values
|
|
1354
|
+
* are preserved and take precedence over defaults.
|
|
1355
|
+
*
|
|
1356
|
+
* @param config - Form configuration (may be partial)
|
|
1357
|
+
* @returns Configuration with defaults applied
|
|
1358
|
+
*
|
|
1359
|
+
* @example
|
|
1360
|
+
* ```typescript
|
|
1361
|
+
* import { applyFormDefaults } from '@nextly/plugin-form-builder';
|
|
1362
|
+
*
|
|
1363
|
+
* const config = {
|
|
1364
|
+
* slug: 'contact-form',
|
|
1365
|
+
* fields: [
|
|
1366
|
+
* { type: 'text', name: 'name', label: 'Name' },
|
|
1367
|
+
* { type: 'email', name: 'email', label: 'Email' },
|
|
1368
|
+
* ],
|
|
1369
|
+
* };
|
|
1370
|
+
*
|
|
1371
|
+
* const result = applyFormDefaults(config);
|
|
1372
|
+
*
|
|
1373
|
+
* // Result:
|
|
1374
|
+
* // {
|
|
1375
|
+
* // slug: 'contact-form',
|
|
1376
|
+
* // labels: { singular: 'Contact Form', plural: 'Contact Forms' },
|
|
1377
|
+
* // fields: [...],
|
|
1378
|
+
* // settings: {
|
|
1379
|
+
* // submitButtonText: 'Submit',
|
|
1380
|
+
* // confirmationType: 'message',
|
|
1381
|
+
* // successMessage: 'Thank you for your submission!',
|
|
1382
|
+
* // allowMultipleSubmissions: true,
|
|
1383
|
+
* // },
|
|
1384
|
+
* // access: {
|
|
1385
|
+
* // submit: () => true,
|
|
1386
|
+
* // read: ({ req }) => !!req.user,
|
|
1387
|
+
* // },
|
|
1388
|
+
* // }
|
|
1389
|
+
* ```
|
|
1390
|
+
*/
|
|
1391
|
+
function applyFormDefaults(config) {
|
|
1392
|
+
warnIfWebhooksConfigured(config);
|
|
1393
|
+
const singularLabel = config.labels?.singular || toTitleCase(config.slug);
|
|
1394
|
+
return {
|
|
1395
|
+
...config,
|
|
1396
|
+
labels: {
|
|
1397
|
+
singular: singularLabel,
|
|
1398
|
+
plural: config.labels?.plural || pluralize(singularLabel)
|
|
1399
|
+
},
|
|
1400
|
+
settings: {
|
|
1401
|
+
...config.settings,
|
|
1402
|
+
submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
1403
|
+
confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
|
|
1404
|
+
successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
|
|
1405
|
+
allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions
|
|
1406
|
+
},
|
|
1407
|
+
notifications: config.notifications ? {
|
|
1408
|
+
enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
|
|
1409
|
+
recipients: config.notifications.recipients || [],
|
|
1410
|
+
subject: config.notifications.subject || `New submission for ${singularLabel}`,
|
|
1411
|
+
replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
|
|
1412
|
+
template: config.notifications.template
|
|
1413
|
+
} : void 0,
|
|
1414
|
+
access: {
|
|
1415
|
+
submit: config.access?.submit || (() => true),
|
|
1416
|
+
read: config.access?.read || (({ req }) => !!req.user)
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Apply default values to individual form field.
|
|
1422
|
+
*
|
|
1423
|
+
* This is a utility function for applying defaults to a single field,
|
|
1424
|
+
* useful when processing fields individually (e.g., in the Schema Builder).
|
|
1425
|
+
*
|
|
1426
|
+
* @param field - The field to apply defaults to
|
|
1427
|
+
* @returns Field with defaults applied
|
|
1428
|
+
*
|
|
1429
|
+
* @example
|
|
1430
|
+
* ```typescript
|
|
1431
|
+
* import { applyFieldDefaults } from '@nextly/plugin-form-builder';
|
|
1432
|
+
*
|
|
1433
|
+
* const field = { type: 'text', name: 'firstName' };
|
|
1434
|
+
* const fieldWithDefaults = applyFieldDefaults(field);
|
|
1435
|
+
* // { type: 'text', name: 'firstName', label: 'First Name', required: false }
|
|
1436
|
+
* ```
|
|
1437
|
+
*/
|
|
1438
|
+
function applyFieldDefaults(field) {
|
|
1439
|
+
const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
|
|
1440
|
+
return {
|
|
1441
|
+
...field,
|
|
1442
|
+
label,
|
|
1443
|
+
required: field.required ?? false
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
/**
|
|
1447
|
+
* Creates a complete form config with minimal input.
|
|
1448
|
+
*
|
|
1449
|
+
* This is a convenience function for quickly creating form configs
|
|
1450
|
+
* with sensible defaults. Only the slug and fields are required.
|
|
1451
|
+
*
|
|
1452
|
+
* @param slug - The form slug
|
|
1453
|
+
* @param fields - Array of form fields
|
|
1454
|
+
* @param options - Optional additional configuration
|
|
1455
|
+
* @returns Complete form configuration
|
|
1456
|
+
*
|
|
1457
|
+
* @example
|
|
1458
|
+
* ```typescript
|
|
1459
|
+
* import { createFormConfig } from '@nextly/plugin-form-builder';
|
|
1460
|
+
*
|
|
1461
|
+
* const config = createFormConfig('contact', [
|
|
1462
|
+
* { type: 'text', name: 'name', label: 'Name', required: true },
|
|
1463
|
+
* { type: 'email', name: 'email', label: 'Email', required: true },
|
|
1464
|
+
* { type: 'textarea', name: 'message', label: 'Message' },
|
|
1465
|
+
* ]);
|
|
1466
|
+
* ```
|
|
1467
|
+
*/
|
|
1468
|
+
function createFormConfig(slug, fields, options) {
|
|
1469
|
+
return applyFormDefaults({
|
|
1470
|
+
slug,
|
|
1471
|
+
fields,
|
|
1472
|
+
...options
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// src/utils/form-settings.ts
|
|
1477
|
+
/**
|
|
1478
|
+
* The ONE reader for stored form settings. Settings were historically
|
|
1479
|
+
* written in two shapes (the builder's old context shape and the collection
|
|
1480
|
+
* group's declared shape), so every consumer — the builder UI and the
|
|
1481
|
+
* submit handler alike — normalizes through here instead of trusting the
|
|
1482
|
+
* stored keys.
|
|
1483
|
+
*/
|
|
1484
|
+
function asOptionalBoolean(value) {
|
|
1485
|
+
return typeof value === "boolean" ? value : void 0;
|
|
1486
|
+
}
|
|
1487
|
+
function asOptionalString(value) {
|
|
1488
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
1489
|
+
}
|
|
1490
|
+
/** A whole number of zero or more, or `undefined`. */
|
|
1491
|
+
function asWholeNumber(value) {
|
|
1492
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
1493
|
+
}
|
|
1494
|
+
/** A whole number above zero, or `undefined`. */
|
|
1495
|
+
function asPositiveWholeNumber(value) {
|
|
1496
|
+
const whole = asWholeNumber(value);
|
|
1497
|
+
return whole === void 0 || whole === 0 ? void 0 : whole;
|
|
1498
|
+
}
|
|
1499
|
+
const ONE_RESPONSE_MODES = [
|
|
1500
|
+
"off",
|
|
1501
|
+
"email",
|
|
1502
|
+
"browser",
|
|
1503
|
+
"ip"
|
|
1504
|
+
];
|
|
1505
|
+
const BOT_CHECK_MODES = [
|
|
1506
|
+
"default",
|
|
1507
|
+
"off",
|
|
1508
|
+
"recaptcha",
|
|
1509
|
+
"turnstile"
|
|
1510
|
+
];
|
|
1511
|
+
/**
|
|
1512
|
+
* The form's one-response mode: as stored, or read from the setting it
|
|
1513
|
+
* replaced, so no form's behaviour changes. Several responses allowed reads
|
|
1514
|
+
* as `"off"`; one per network address as `"ip"`, the only check it had.
|
|
1515
|
+
*/
|
|
1516
|
+
function oneResponseOf(source) {
|
|
1517
|
+
const stored = ONE_RESPONSE_MODES.find((mode) => mode === source.oneResponse);
|
|
1518
|
+
if (stored !== void 0) return stored;
|
|
1519
|
+
if (asOptionalBoolean(source.allowMultipleSubmissions) === false) return "ip";
|
|
1520
|
+
return DEFAULT_FORM_SETTINGS.oneResponse;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* The form's bot check: as stored, or read from the per-form reCAPTCHA
|
|
1524
|
+
* switch it replaced: unset follows the plugin, on is reCAPTCHA, off is off,
|
|
1525
|
+
* so no form's protection changes.
|
|
1526
|
+
*/
|
|
1527
|
+
function botCheckOf(source, captchaEnabled) {
|
|
1528
|
+
const stored = BOT_CHECK_MODES.find((mode) => mode === source.botCheck);
|
|
1529
|
+
if (stored !== void 0) return stored;
|
|
1530
|
+
if (captchaEnabled === true) return "recaptcha";
|
|
1531
|
+
if (captchaEnabled === false) return "off";
|
|
1532
|
+
return DEFAULT_FORM_SETTINGS.botCheck;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Normalize a stored settings value into the canonical {@link FormSettings}.
|
|
1536
|
+
*
|
|
1537
|
+
* Legacy keys are migrated on read (no data is lost, nothing is rewritten
|
|
1538
|
+
* until the form is next saved):
|
|
1539
|
+
* - `confirmationMessage` → `successMessage` + `confirmationType: "message"`
|
|
1540
|
+
* - nested `captcha: { enabled, siteKey }` → flat `captchaSiteKey`, and its
|
|
1541
|
+
* `enabled` read as `botCheck`
|
|
1542
|
+
* - `allowMultipleSubmissions` → `oneResponse`; `captchaEnabled` → `botCheck`
|
|
1543
|
+
*
|
|
1544
|
+
* Keys with no consumer (`showResetButton`, `resetButtonText`,
|
|
1545
|
+
* `storeSubmissions`) are dropped: a setting that does nothing must not
|
|
1546
|
+
* survive into the UI.
|
|
1547
|
+
*/
|
|
1548
|
+
function normalizeFormSettings(raw) {
|
|
1549
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...DEFAULT_FORM_SETTINGS };
|
|
1550
|
+
const source = raw;
|
|
1551
|
+
const legacyMessage = asOptionalString(source.confirmationMessage);
|
|
1552
|
+
const legacyCaptcha = source.captcha && typeof source.captcha === "object" ? source.captcha : void 0;
|
|
1553
|
+
const confirmationType = source.confirmationType === "redirect" || source.confirmationType === "relationship" ? source.confirmationType : "message";
|
|
1554
|
+
return {
|
|
1555
|
+
submitButtonText: asOptionalString(source.submitButtonText) ?? DEFAULT_FORM_SETTINGS.submitButtonText,
|
|
1556
|
+
confirmationType,
|
|
1557
|
+
successMessage: asOptionalString(source.successMessage) ?? legacyMessage ?? DEFAULT_FORM_SETTINGS.successMessage,
|
|
1558
|
+
redirectUrl: asOptionalString(source.redirectUrl),
|
|
1559
|
+
redirectPage: source.redirectPage ?? void 0,
|
|
1560
|
+
redirectRelation: source.redirectRelation && typeof source.redirectRelation === "object" ? source.redirectRelation : void 0,
|
|
1561
|
+
oneResponse: oneResponseOf(source),
|
|
1562
|
+
oneResponseField: asOptionalString(source.oneResponseField),
|
|
1563
|
+
oneResponseVersion: asWholeNumber(source.oneResponseVersion) ?? DEFAULT_FORM_SETTINGS.oneResponseVersion,
|
|
1564
|
+
oneResponseKeyedVersion: asWholeNumber(source.oneResponseKeyedVersion),
|
|
1565
|
+
honeypotEnabled: asOptionalBoolean(source.honeypotEnabled),
|
|
1566
|
+
botCheck: botCheckOf(source, asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled)),
|
|
1567
|
+
closesAt: asOptionalString(source.closesAt),
|
|
1568
|
+
submissionLimit: asPositiveWholeNumber(source.submissionLimit),
|
|
1569
|
+
storeIpAndBrowser: asOptionalBoolean(source.storeIpAndBrowser) ?? DEFAULT_FORM_SETTINGS.storeIpAndBrowser,
|
|
1570
|
+
keepDays: asPositiveWholeNumber(source.keepDays),
|
|
1571
|
+
internalNote: asOptionalString(source.internalNote),
|
|
1572
|
+
captchaSiteKey: asOptionalString(source.captchaSiteKey) ?? asOptionalString(legacyCaptcha?.siteKey)
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/utils/send-status.ts
|
|
1577
|
+
/** The states in which another attempt may still come. */
|
|
1578
|
+
const IN_PROGRESS = /* @__PURE__ */ new Set([
|
|
1579
|
+
"waiting",
|
|
1580
|
+
"retrying",
|
|
1581
|
+
"sending"
|
|
1582
|
+
]);
|
|
1583
|
+
/** Where a send stands, an unrecorded one read as waiting. */
|
|
1584
|
+
function stateOf(send) {
|
|
1585
|
+
return send.state ?? "waiting";
|
|
1586
|
+
}
|
|
1587
|
+
/** Whether another attempt at `send` may still come. */
|
|
1588
|
+
function sendInProgress(send) {
|
|
1589
|
+
return IN_PROGRESS.has(stateOf(send));
|
|
1590
|
+
}
|
|
1591
|
+
/** The recipients a send lists: those it went to, else those it was for. */
|
|
1592
|
+
function listedOf(send) {
|
|
1593
|
+
const sent = send.recipients ?? [];
|
|
1594
|
+
return sent.length > 0 ? sent : send.configured ?? [];
|
|
1595
|
+
}
|
|
1596
|
+
/** A recipient's state in one send, from its outcome and the send's state. */
|
|
1597
|
+
function recipientState(send, recipient) {
|
|
1598
|
+
if (recipient.outcome === "accepted") return "sent";
|
|
1599
|
+
const state = stateOf(send);
|
|
1600
|
+
if (state === "sent") return "failed";
|
|
1601
|
+
return state;
|
|
1602
|
+
}
|
|
1603
|
+
/** One recipient's result in one send. */
|
|
1604
|
+
function statusIn(send, recipient) {
|
|
1605
|
+
const state = recipientState(send, recipient);
|
|
1606
|
+
return {
|
|
1607
|
+
digest: recipient.digest,
|
|
1608
|
+
hint: recipient.hint,
|
|
1609
|
+
kind: recipient.kind,
|
|
1610
|
+
source: recipient.source,
|
|
1611
|
+
state,
|
|
1612
|
+
retries: state === "sent" ? recipient.retries ?? 0 : 0,
|
|
1613
|
+
at: send.changedAt ?? send.createdAt ?? null,
|
|
1614
|
+
...state === "cancelled" && send.cancel ? { cancel: send.cancel } : {}
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Every recipient a notification's sends had for a submission, each with its
|
|
1619
|
+
* latest result, oldest first. A recipient a send left out (one Resend found
|
|
1620
|
+
* already reached) keeps the result it had.
|
|
1621
|
+
*/
|
|
1622
|
+
function latestByRecipient(sends) {
|
|
1623
|
+
const latest = /* @__PURE__ */ new Map();
|
|
1624
|
+
for (const send of sends) for (const recipient of listedOf(send)) {
|
|
1625
|
+
if (recipient.outcome === "skipped") continue;
|
|
1626
|
+
latest.set(recipient.digest, statusIn(send, recipient));
|
|
1627
|
+
}
|
|
1628
|
+
return [...latest.values()];
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Who Resend can send a notification to now: each recipient the notification
|
|
1632
|
+
* addresses as it is now (`configured`), then each recipient a send filter
|
|
1633
|
+
* added that its sends have not reached, since the filters add it again to
|
|
1634
|
+
* the next send. Whether each has received it is read from `history`, the
|
|
1635
|
+
* latest result per recipient.
|
|
1636
|
+
*/
|
|
1637
|
+
function addressableNow(configured, history) {
|
|
1638
|
+
const latest = new Map(history.map((status) => [status.digest, status]));
|
|
1639
|
+
const now = configured.map((recipient) => ({
|
|
1640
|
+
digest: recipient.digest,
|
|
1641
|
+
hint: recipient.hint,
|
|
1642
|
+
source: recipient.source,
|
|
1643
|
+
reached: latest.get(recipient.digest)?.state === "sent"
|
|
1644
|
+
}));
|
|
1645
|
+
const listed = new Set(now.map((recipient) => recipient.digest));
|
|
1646
|
+
const missedAdded = history.filter((status) => status.source === "added" && status.state !== "sent" && !listed.has(status.digest)).map((status) => ({
|
|
1647
|
+
digest: status.digest,
|
|
1648
|
+
hint: status.hint,
|
|
1649
|
+
source: status.source,
|
|
1650
|
+
reached: false
|
|
1651
|
+
}));
|
|
1652
|
+
return [...now, ...missedAdded];
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* The recipients a resend leaves out: each one `history` shows has received
|
|
1656
|
+
* it, except those ticked to send it to again.
|
|
1657
|
+
*/
|
|
1658
|
+
function reachedExcept(history, ticked) {
|
|
1659
|
+
const again = new Set(ticked);
|
|
1660
|
+
return history.filter((status) => status.state === "sent" && !again.has(status.digest)).map((status) => status.digest);
|
|
1661
|
+
}
|
|
1662
|
+
/** A recipient's status in words. */
|
|
1663
|
+
function recipientLook(status) {
|
|
1664
|
+
switch (status.state) {
|
|
1665
|
+
case "sent": return status.retries === 0 ? {
|
|
1666
|
+
label: "Sent",
|
|
1667
|
+
tone: "ok"
|
|
1668
|
+
} : {
|
|
1669
|
+
label: `Sent after ${status.retries} ${status.retries === 1 ? "retry" : "retries"}`,
|
|
1670
|
+
tone: "warn"
|
|
1671
|
+
};
|
|
1672
|
+
case "failed": return {
|
|
1673
|
+
label: "Failed",
|
|
1674
|
+
tone: "bad"
|
|
1675
|
+
};
|
|
1676
|
+
case "cancelled": return {
|
|
1677
|
+
label: "Cancelled",
|
|
1678
|
+
tone: "quiet"
|
|
1679
|
+
};
|
|
1680
|
+
case "sending": return {
|
|
1681
|
+
label: "Sending",
|
|
1682
|
+
tone: "quiet"
|
|
1683
|
+
};
|
|
1684
|
+
case "retrying": return {
|
|
1685
|
+
label: "Retrying",
|
|
1686
|
+
tone: "warn"
|
|
1687
|
+
};
|
|
1688
|
+
default: return {
|
|
1689
|
+
label: "Waiting",
|
|
1690
|
+
tone: "quiet"
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* A notification's status in words: the one its recipients share, or Partly
|
|
1696
|
+
* sent when they differ. Recipients all sent read as the one that took the
|
|
1697
|
+
* most retries.
|
|
1698
|
+
*/
|
|
1699
|
+
function notificationLook(statuses) {
|
|
1700
|
+
const [first] = statuses;
|
|
1701
|
+
if (!first) return {
|
|
1702
|
+
label: "Waiting",
|
|
1703
|
+
tone: "quiet"
|
|
1704
|
+
};
|
|
1705
|
+
if (!statuses.every((status) => status.state === first.state)) return {
|
|
1706
|
+
label: "Partly sent",
|
|
1707
|
+
tone: "warn"
|
|
1708
|
+
};
|
|
1709
|
+
return recipientLook(statuses.reduce((latest, status) => status.retries > latest.retries ? status : latest, first));
|
|
1710
|
+
}
|
|
1711
|
+
/** Why a send was cancelled, in words. */
|
|
1712
|
+
function cancelText(cancel) {
|
|
1713
|
+
switch (cancel?.by) {
|
|
1714
|
+
case "person": return "Cancelled in Settings › Email";
|
|
1715
|
+
case "spam": return "Cancelled when the submission was marked Spam";
|
|
1716
|
+
case "erasure": return "Cancelled when the address was erased";
|
|
1717
|
+
default: return "Cancelled";
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// src/types.ts
|
|
1722
|
+
/** Every built-in form field type, as a value list (the single source shared
|
|
1723
|
+
* by the field-type guard and the config validator). */
|
|
1724
|
+
const BUILT_IN_FORM_FIELD_TYPES = [
|
|
1725
|
+
"text",
|
|
1726
|
+
"email",
|
|
1727
|
+
"number",
|
|
1728
|
+
"phone",
|
|
1729
|
+
"url",
|
|
1730
|
+
"textarea",
|
|
1731
|
+
"select",
|
|
1732
|
+
"checkbox",
|
|
1733
|
+
"radio",
|
|
1734
|
+
"file",
|
|
1735
|
+
"date",
|
|
1736
|
+
"time",
|
|
1737
|
+
"hidden",
|
|
1738
|
+
"consent"
|
|
1739
|
+
];
|
|
1740
|
+
const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
|
|
1741
|
+
/**
|
|
1742
|
+
* Narrow an `AnyFormField` to the closed built-in `FormField` union. Returns
|
|
1743
|
+
* `false` for a plugin-contributed field, so callers handle it generically
|
|
1744
|
+
* before switching on the built-in discriminant.
|
|
1745
|
+
*/
|
|
1746
|
+
function isKnownFormField(field) {
|
|
1747
|
+
return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Check if a value is a FormField.
|
|
1751
|
+
*/
|
|
1752
|
+
function isFormField(value) {
|
|
1753
|
+
return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Check if a FormField is a specific type.
|
|
1757
|
+
*/
|
|
1758
|
+
function isFormFieldType(field, type) {
|
|
1759
|
+
return field.type === type;
|
|
1760
|
+
}
|
|
1761
|
+
/**
|
|
1762
|
+
* Type guard for text field.
|
|
1763
|
+
*/
|
|
1764
|
+
function isTextFormField(field) {
|
|
1765
|
+
return field.type === "text";
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Type guard for email field.
|
|
1769
|
+
*/
|
|
1770
|
+
function isEmailFormField(field) {
|
|
1771
|
+
return field.type === "email";
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Type guard for select field.
|
|
1775
|
+
*/
|
|
1776
|
+
function isSelectFormField(field) {
|
|
1777
|
+
return field.type === "select";
|
|
1778
|
+
}
|
|
1779
|
+
/**
|
|
1780
|
+
* Type guard for checkbox field.
|
|
1781
|
+
*/
|
|
1782
|
+
function isCheckboxFormField(field) {
|
|
1783
|
+
return field.type === "checkbox";
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* Type guard for file field.
|
|
1787
|
+
*/
|
|
1788
|
+
function isFileFormField(field) {
|
|
1789
|
+
return field.type === "file";
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// src/utils/enforced-validation.ts
|
|
1793
|
+
/**
|
|
1794
|
+
* `message` appears on every row because it is honoured everywhere: each
|
|
1795
|
+
* generator passes `validation.errorMessage` to `applyRequired`, so even a type
|
|
1796
|
+
* with no bounds of its own uses it for the required failure.
|
|
1797
|
+
*
|
|
1798
|
+
* Typed as a total `Record` rather than a partial one, so adding a field type
|
|
1799
|
+
* without deciding what it enforces is a compile error instead of a type that
|
|
1800
|
+
* silently offers nothing.
|
|
1801
|
+
*/
|
|
1802
|
+
const ENFORCED_VALIDATION_RULES = {
|
|
1803
|
+
text: [
|
|
1804
|
+
"minLength",
|
|
1805
|
+
"maxLength",
|
|
1806
|
+
"pattern",
|
|
1807
|
+
"message"
|
|
1808
|
+
],
|
|
1809
|
+
email: ["pattern", "message"],
|
|
1810
|
+
number: [
|
|
1811
|
+
"min",
|
|
1812
|
+
"max",
|
|
1813
|
+
"message"
|
|
1814
|
+
],
|
|
1815
|
+
phone: ["pattern", "message"],
|
|
1816
|
+
url: ["pattern", "message"],
|
|
1817
|
+
textarea: [
|
|
1818
|
+
"minLength",
|
|
1819
|
+
"maxLength",
|
|
1820
|
+
"message"
|
|
1821
|
+
],
|
|
1822
|
+
select: ["message"],
|
|
1823
|
+
checkbox: ["message"],
|
|
1824
|
+
radio: ["message"],
|
|
1825
|
+
file: ["message"],
|
|
1826
|
+
date: ["message"],
|
|
1827
|
+
time: ["message"],
|
|
1828
|
+
hidden: ["message"],
|
|
1829
|
+
consent: ["message"]
|
|
1830
|
+
};
|
|
1831
|
+
/**
|
|
1832
|
+
* The rules a field of this type both accepts and has enforced.
|
|
1833
|
+
*
|
|
1834
|
+
* A plugin-contributed type has no row, and correctly gets nothing: this
|
|
1835
|
+
* runtime's generator has no clause for it, so every rule would be inert.
|
|
1836
|
+
*/
|
|
1837
|
+
function enforcedValidationRules(type) {
|
|
1838
|
+
return ENFORCED_VALIDATION_RULES[type] ?? [];
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/utils/generate-schema.ts
|
|
1842
|
+
/**
|
|
1843
|
+
* Generate a Zod validation schema from form field configuration.
|
|
1844
|
+
*
|
|
1845
|
+
* Creates a dynamic Zod schema based on the form fields array,
|
|
1846
|
+
* applying appropriate validation rules for each field type.
|
|
1847
|
+
*
|
|
1848
|
+
* @param fields - Array of form field configurations
|
|
1849
|
+
* @returns Zod object schema for validating submission data
|
|
1850
|
+
*
|
|
1851
|
+
* @example
|
|
1852
|
+
* ```typescript
|
|
1853
|
+
* const fields: FormField[] = [
|
|
1854
|
+
* { type: 'text', name: 'firstName', label: 'First Name', required: true },
|
|
1855
|
+
* { type: 'email', name: 'email', label: 'Email', required: true },
|
|
1856
|
+
* { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },
|
|
1857
|
+
* ];
|
|
1858
|
+
*
|
|
1859
|
+
* const schema = generateZodSchema(fields);
|
|
1860
|
+
* const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });
|
|
1861
|
+
* ```
|
|
1862
|
+
*/
|
|
1863
|
+
function generateZodSchema(fields) {
|
|
1864
|
+
const schemaShape = {};
|
|
1865
|
+
for (const field of fields) {
|
|
1866
|
+
if (!isKnownFormField(field)) {
|
|
1867
|
+
schemaShape[field.name] = field.required ? z.unknown().refine((value) => value !== void 0 && value !== null && value !== "", { message: `${field.label || field.name} is required` }) : z.unknown();
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
if (field.type === "hidden" && !field.required) {
|
|
1871
|
+
schemaShape[field.name] = z.string().optional();
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
const fieldSchema = generateFieldSchema(field);
|
|
1875
|
+
if (fieldSchema) schemaShape[field.name] = field.required ? answered(fieldSchema, requiredMessage(field)) : fieldSchema;
|
|
1876
|
+
}
|
|
1877
|
+
return z.object(schemaShape);
|
|
1878
|
+
}
|
|
1879
|
+
/** What a required field missing from a submission says. */
|
|
1880
|
+
function requiredMessage(field) {
|
|
1881
|
+
return customMessage(field) || `${field.label || field.name} is required`;
|
|
1882
|
+
}
|
|
1883
|
+
/**
|
|
1884
|
+
* `schema`, after a check that an answer was given at all. A required field
|
|
1885
|
+
* left out of a submission would otherwise fail on its type ("expected
|
|
1886
|
+
* string, received undefined"), which tells the visitor nothing; it is
|
|
1887
|
+
* refused as required instead, and the field's own checks run on an answer
|
|
1888
|
+
* that is there.
|
|
1889
|
+
*/
|
|
1890
|
+
function answered(schema, message) {
|
|
1891
|
+
return z.custom((value) => value !== void 0 && value !== null, { message }).pipe(schema);
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Generate a Zod schema for a single field.
|
|
1895
|
+
*/
|
|
1896
|
+
function generateFieldSchema(field) {
|
|
1897
|
+
switch (field.type) {
|
|
1898
|
+
case "text": return generateTextSchema(field);
|
|
1899
|
+
case "email": return generateEmailSchema(field);
|
|
1900
|
+
case "number": return generateNumberSchema(field);
|
|
1901
|
+
case "phone": return generatePhoneSchema(field);
|
|
1902
|
+
case "url": return generateUrlSchema(field);
|
|
1903
|
+
case "textarea": return generateTextareaSchema(field);
|
|
1904
|
+
case "select": return generateSelectSchema(field);
|
|
1905
|
+
case "checkbox": return generateCheckboxSchema(field);
|
|
1906
|
+
case "radio": return generateRadioSchema(field);
|
|
1907
|
+
case "file": return generateFileSchema(field);
|
|
1908
|
+
case "date": return generateDateSchema(field);
|
|
1909
|
+
case "time": return generateTimeSchema(field);
|
|
1910
|
+
case "hidden": return generateHiddenSchema(field);
|
|
1911
|
+
case "consent": return generateConsentSchema(field);
|
|
1912
|
+
default: return z.unknown();
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Generate schema for text field.
|
|
1917
|
+
*/
|
|
1918
|
+
/**
|
|
1919
|
+
* The author's own wording for a failure, or `undefined` when this field's type
|
|
1920
|
+
* does not enforce `message`.
|
|
1921
|
+
*
|
|
1922
|
+
* Asked of the row rather than read off `validation` directly, for the reason
|
|
1923
|
+
* the table exists at all: the editor decides whether to OFFER the control from
|
|
1924
|
+
* that row, so a generator honouring a stored message regardless would enforce
|
|
1925
|
+
* a value the author was never shown a box for — and, worse, the table would
|
|
1926
|
+
* describe a rule it did not govern.
|
|
1927
|
+
*
|
|
1928
|
+
* `message` was the one rule that stayed outside this. It agreed with the table
|
|
1929
|
+
* only because every row happens to list it, which is agreement by coincidence:
|
|
1930
|
+
* drop `message` from a row and the control disappears while the wording went
|
|
1931
|
+
* on being applied.
|
|
1932
|
+
*/
|
|
1933
|
+
function customMessage(field) {
|
|
1934
|
+
if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("message")) return void 0;
|
|
1935
|
+
return field.validation?.errorMessage;
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* A field's stored bounds, with the custom message already resolved by the row.
|
|
1939
|
+
*
|
|
1940
|
+
* Handed to the appliers so they cannot read an ungated message: they take a
|
|
1941
|
+
* validation object, and giving them one whose `errorMessage` has been through
|
|
1942
|
+
* {@link customMessage} makes the rule hold for every applier without each of
|
|
1943
|
+
* them having to remember it.
|
|
1944
|
+
*/
|
|
1945
|
+
function enforcedValidation(field) {
|
|
1946
|
+
return {
|
|
1947
|
+
...field.validation ?? {},
|
|
1948
|
+
errorMessage: customMessage(field)
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Which rules a type's schema applies, and how each one constrains a string.
|
|
1953
|
+
*
|
|
1954
|
+
* The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field
|
|
1955
|
+
* editor also reads to decide what to offer. One definition answers both
|
|
1956
|
+
* questions, so a rule cannot be offered by the editor and ignored here, or
|
|
1957
|
+
* honoured here and hidden there — the two used to state the same thing
|
|
1958
|
+
* separately and could only agree by being edited together.
|
|
1959
|
+
*
|
|
1960
|
+
* The HOW stays here, because it is about zod and the table is deliberately
|
|
1961
|
+
* free of it: the editor imports the table, and pulling the schema builder in
|
|
1962
|
+
* behind it would ship this file and its validation library to every admin
|
|
1963
|
+
* client before anyone opened a field.
|
|
1964
|
+
*/
|
|
1965
|
+
const STRING_RULE_APPLIERS = {
|
|
1966
|
+
minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(validation.minLength, validation.errorMessage || `Minimum ${validation.minLength} characters required`),
|
|
1967
|
+
maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(validation.maxLength, validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`),
|
|
1968
|
+
pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(new RegExp(validation.pattern), validation.errorMessage || fallback) : schema
|
|
1969
|
+
};
|
|
1970
|
+
/** The same, for the bounds a number carries. */
|
|
1971
|
+
const NUMBER_RULE_APPLIERS = {
|
|
1972
|
+
min: (schema, validation) => validation.min === void 0 ? schema : schema.min(validation.min, validation.errorMessage || `Minimum value is ${validation.min}`),
|
|
1973
|
+
max: (schema, validation) => validation.max === void 0 ? schema : schema.max(validation.max, validation.errorMessage || `Maximum value is ${validation.max}`)
|
|
1974
|
+
};
|
|
1975
|
+
/**
|
|
1976
|
+
* Whether an author-supplied pattern actually replaces a type's intrinsic one.
|
|
1977
|
+
*
|
|
1978
|
+
* Two conditions, and both are load-bearing: a pattern must be stored, AND the
|
|
1979
|
+
* type's row must enforce `pattern`. A type that keeps its own shape check —
|
|
1980
|
+
* a phone, a URL — stands it down only for an override that will really be
|
|
1981
|
+
* applied, because standing down for one that will not leaves nothing behind.
|
|
1982
|
+
*/
|
|
1983
|
+
function overridesPattern(field) {
|
|
1984
|
+
if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("pattern")) return false;
|
|
1985
|
+
const validation = field.validation ?? {};
|
|
1986
|
+
return Boolean(validation.pattern);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Constrain a string schema by the rules this field's TYPE enforces.
|
|
1990
|
+
*
|
|
1991
|
+
* A rule the type does not list is not applied even when the field carries a
|
|
1992
|
+
* value for it, which is the point: a `pattern` stored on a textarea is a bound
|
|
1993
|
+
* the editor never offered, and honouring it here would enforce a restriction
|
|
1994
|
+
* its author could not see.
|
|
1995
|
+
*
|
|
1996
|
+
* `patternMessage` is the type's own wording for a failed pattern — "Invalid
|
|
1997
|
+
* URL format" reads differently from "Invalid format" — so the rule set decides
|
|
1998
|
+
* WHICH constraints apply while each type keeps its own voice.
|
|
1999
|
+
*/
|
|
2000
|
+
function applyStringRules(schema, field, patternMessage) {
|
|
2001
|
+
const validation = enforcedValidation(field);
|
|
2002
|
+
let constrained = schema;
|
|
2003
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
2004
|
+
const apply = STRING_RULE_APPLIERS[rule];
|
|
2005
|
+
if (apply) constrained = apply(constrained, validation, patternMessage);
|
|
2006
|
+
}
|
|
2007
|
+
return constrained;
|
|
2008
|
+
}
|
|
2009
|
+
function generateTextSchema(field) {
|
|
2010
|
+
return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Generate schema for email field.
|
|
2014
|
+
*/
|
|
2015
|
+
function generateEmailSchema(field) {
|
|
2016
|
+
return applyRequired(applyStringRules(z.string().email(customMessage(field) || "Invalid email address"), field, "Invalid email format"), field.required, customMessage(field));
|
|
2017
|
+
}
|
|
2018
|
+
/**
|
|
2019
|
+
* Generate schema for number field.
|
|
2020
|
+
*/
|
|
2021
|
+
function generateNumberSchema(field) {
|
|
2022
|
+
let schema = z.number({ error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number" });
|
|
2023
|
+
const validation = enforcedValidation(field);
|
|
2024
|
+
for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
|
|
2025
|
+
const apply = NUMBER_RULE_APPLIERS[rule];
|
|
2026
|
+
if (apply) schema = apply(schema, validation);
|
|
2027
|
+
}
|
|
2028
|
+
if (field.required) return schema;
|
|
2029
|
+
return schema.optional().nullable();
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* Generate schema for phone field.
|
|
2033
|
+
*/
|
|
2034
|
+
function generatePhoneSchema(field) {
|
|
2035
|
+
return applyRequired(applyStringRules(overridesPattern(field) ? z.string() : z.string().regex(/^[\d\s\-+()]+$/, customMessage(field) || "Invalid phone number"), field, "Invalid phone number format"), field.required, customMessage(field));
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Generate schema for URL field.
|
|
2039
|
+
*/
|
|
2040
|
+
function generateUrlSchema(field) {
|
|
2041
|
+
return applyRequired(applyStringRules(z.string().url(customMessage(field) || "Invalid URL"), field, "Invalid URL format"), field.required, customMessage(field));
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Generate schema for textarea field.
|
|
2045
|
+
*/
|
|
2046
|
+
function generateTextareaSchema(field) {
|
|
2047
|
+
return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Generate schema for select field.
|
|
2051
|
+
*/
|
|
2052
|
+
function generateSelectSchema(field) {
|
|
2053
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
2054
|
+
if (field.allowMultiple) {
|
|
2055
|
+
const schema = z.array(z.enum(validValues));
|
|
2056
|
+
if (field.required) return schema.min(1, customMessage(field) || "Please select at least one option");
|
|
2057
|
+
return schema.optional();
|
|
2058
|
+
}
|
|
2059
|
+
const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select a valid option" });
|
|
2060
|
+
if (field.required) return schema;
|
|
2061
|
+
return schema.optional();
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* Generate schema for checkbox field.
|
|
2065
|
+
*/
|
|
2066
|
+
function generateCheckboxSchema(field) {
|
|
2067
|
+
const schema = z.boolean();
|
|
2068
|
+
if (field.required) return schema.refine((val) => val === true, { message: customMessage(field) || "This field is required" });
|
|
2069
|
+
return schema.optional();
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Generate schema for radio field.
|
|
2073
|
+
*/
|
|
2074
|
+
function generateRadioSchema(field) {
|
|
2075
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
2076
|
+
const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select an option" });
|
|
2077
|
+
if (field.required) return schema;
|
|
2078
|
+
return schema.optional();
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Generate schema for file field.
|
|
2082
|
+
*
|
|
2083
|
+
* Note: File validation is typically handled server-side.
|
|
2084
|
+
* This schema validates file references (IDs or URLs).
|
|
2085
|
+
*/
|
|
2086
|
+
function generateFileSchema(field) {
|
|
2087
|
+
if (field.multiple) {
|
|
2088
|
+
const schema = z.array(z.string());
|
|
2089
|
+
if (field.required) return schema.min(1, customMessage(field) || "Please upload at least one file");
|
|
2090
|
+
return schema.optional();
|
|
2091
|
+
}
|
|
2092
|
+
return applyRequired(z.string(), field.required, customMessage(field));
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Generate schema for date field.
|
|
2096
|
+
*/
|
|
2097
|
+
function generateDateSchema(field) {
|
|
2098
|
+
let schema = z.string();
|
|
2099
|
+
schema = schema.refine((val) => {
|
|
2100
|
+
if (!val) return true;
|
|
2101
|
+
const date = new Date(val);
|
|
2102
|
+
return !isNaN(date.getTime());
|
|
2103
|
+
}, { message: customMessage(field) || "Invalid date" });
|
|
2104
|
+
if (field.min || field.max) schema = schema.refine((val) => {
|
|
2105
|
+
if (!val) return true;
|
|
2106
|
+
const date = new Date(val);
|
|
2107
|
+
if (field.min && date < new Date(field.min)) return false;
|
|
2108
|
+
if (field.max && date > new Date(field.max)) return false;
|
|
2109
|
+
return true;
|
|
2110
|
+
}, { message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}` });
|
|
2111
|
+
if (field.required) return schema.refine((val) => val !== void 0 && val !== null && val !== "", { message: customMessage(field) || "This field is required" });
|
|
2112
|
+
return schema.optional().or(z.literal(""));
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Generate schema for time field.
|
|
2116
|
+
*/
|
|
2117
|
+
function generateTimeSchema(field) {
|
|
2118
|
+
return applyRequired(z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, customMessage(field) || "Invalid time format (HH:mm)"), field.required, customMessage(field));
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Generate schema for a consent field: the stored answer, which must say the
|
|
2122
|
+
* visitor agreed. Always required, whatever the field's own flag says, since
|
|
2123
|
+
* a consent nobody has to give records nothing.
|
|
2124
|
+
*/
|
|
2125
|
+
function generateConsentSchema(field) {
|
|
2126
|
+
return z.object({
|
|
2127
|
+
agreed: z.boolean(),
|
|
2128
|
+
text: z.string()
|
|
2129
|
+
}).refine((answer) => answer.agreed, { message: customMessage(field) || "This field is required" });
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Generate schema for hidden field.
|
|
2133
|
+
*/
|
|
2134
|
+
function generateHiddenSchema(field) {
|
|
2135
|
+
return applyRequired(z.string(), field.required, customMessage(field));
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* Apply required/optional to a schema.
|
|
2139
|
+
*/
|
|
2140
|
+
function applyRequired(schema, required, errorMessage) {
|
|
2141
|
+
if (required) return schema.min(1, errorMessage || "This field is required");
|
|
2142
|
+
return schema.optional().or(z.literal(""));
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Transform form data before validation.
|
|
2146
|
+
*
|
|
2147
|
+
* Handles type coercion and normalization:
|
|
2148
|
+
* - Converts string numbers to actual numbers
|
|
2149
|
+
* - Converts string booleans to actual booleans
|
|
2150
|
+
* - Trims string values
|
|
2151
|
+
* - Handles empty strings
|
|
2152
|
+
*
|
|
2153
|
+
* @param data - Raw form data
|
|
2154
|
+
* @param fields - Form field configurations
|
|
2155
|
+
* @returns Transformed data ready for validation
|
|
2156
|
+
*/
|
|
2157
|
+
function transformFormData(data, fields) {
|
|
2158
|
+
const transformed = {};
|
|
2159
|
+
for (const field of fields) {
|
|
2160
|
+
const value = data[field.name];
|
|
2161
|
+
if (value === void 0) continue;
|
|
2162
|
+
transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
|
|
2163
|
+
}
|
|
2164
|
+
return transformed;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Transform a single field value based on its type.
|
|
2168
|
+
*/
|
|
2169
|
+
function transformFieldValue(value, field) {
|
|
2170
|
+
if (value === null || value === void 0) return value;
|
|
2171
|
+
switch (field.type) {
|
|
2172
|
+
case "number":
|
|
2173
|
+
if (typeof value === "string") {
|
|
2174
|
+
const num = parseFloat(value);
|
|
2175
|
+
return isNaN(num) ? value : num;
|
|
2176
|
+
}
|
|
2177
|
+
return value;
|
|
2178
|
+
case "checkbox":
|
|
2179
|
+
if (typeof value === "string") return value === "true" || value === "1" || value === "on";
|
|
2180
|
+
return Boolean(value);
|
|
2181
|
+
case "text":
|
|
2182
|
+
case "email":
|
|
2183
|
+
case "phone":
|
|
2184
|
+
case "url":
|
|
2185
|
+
case "textarea":
|
|
2186
|
+
case "hidden":
|
|
2187
|
+
if (typeof value === "string") return value.trim();
|
|
2188
|
+
return value;
|
|
2189
|
+
case "consent": return consentAnswerOf(value, field);
|
|
2190
|
+
case "select":
|
|
2191
|
+
if (field.allowMultiple) {
|
|
2192
|
+
if (!Array.isArray(value)) return value ? [value] : [];
|
|
2193
|
+
}
|
|
2194
|
+
return value;
|
|
2195
|
+
default: return value;
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Validate form data against a form's field configuration.
|
|
2200
|
+
*
|
|
2201
|
+
* Combines transformation and validation in one step.
|
|
2202
|
+
*
|
|
2203
|
+
* @param data - Raw form data
|
|
2204
|
+
* @param fields - Form field configurations
|
|
2205
|
+
* @returns Zod safe parse result
|
|
2206
|
+
*
|
|
2207
|
+
* @example
|
|
2208
|
+
* ```typescript
|
|
2209
|
+
* const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);
|
|
2210
|
+
*
|
|
2211
|
+
* if (result.success) {
|
|
2212
|
+
* console.log('Valid data:', result.data);
|
|
2213
|
+
* } else {
|
|
2214
|
+
* console.log('Validation errors:', result.error.flatten());
|
|
2215
|
+
* }
|
|
2216
|
+
* ```
|
|
2217
|
+
*/
|
|
2218
|
+
function validateFormData(data, fields) {
|
|
2219
|
+
const transformed = transformFormData(data, fields);
|
|
2220
|
+
return generateZodSchema(fields).safeParse(transformed);
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Whether the visitor supplied nothing at this field.
|
|
2224
|
+
*
|
|
2225
|
+
* The same four shapes the endpoint has always counted as blank. A `false`
|
|
2226
|
+
* checkbox and a `0` are answers, so neither is absent.
|
|
2227
|
+
*/
|
|
2228
|
+
function isBlank(value) {
|
|
2229
|
+
return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* The failures with their codes, first one per field.
|
|
2233
|
+
*
|
|
2234
|
+
* A code as well as a sentence, because the public submission error has always
|
|
2235
|
+
* carried one and a client uses it to tell "you left this blank" from "this is
|
|
2236
|
+
* not an email" without parsing English. Flattening every failure to `INVALID`
|
|
2237
|
+
* silently stopped required-field detection working for those clients.
|
|
2238
|
+
*
|
|
2239
|
+
* The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.
|
|
2240
|
+
* Zod's codes do not divide the same way: `too_small` covers a blank answer and
|
|
2241
|
+
* a four-character password under a minimum of eight, and `invalid_type` covers
|
|
2242
|
+
* an absent key and a string sent to a number field. Reading the code alone
|
|
2243
|
+
* told a visitor to fill in a field they had already filled in.
|
|
2244
|
+
*/
|
|
2245
|
+
function getValidationIssues(result, submitted = {}) {
|
|
2246
|
+
if (result.success) return [];
|
|
2247
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2248
|
+
const issues = [];
|
|
2249
|
+
for (const issue of result.error.issues) {
|
|
2250
|
+
const path = issue.path.join(".");
|
|
2251
|
+
if (seen.has(path)) continue;
|
|
2252
|
+
seen.add(path);
|
|
2253
|
+
issues.push({
|
|
2254
|
+
path,
|
|
2255
|
+
code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
|
|
2256
|
+
message: issue.message
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
return issues;
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* The same failures as a field-to-message map.
|
|
2263
|
+
*
|
|
2264
|
+
* Derived from {@link getValidationIssues} rather than walking the issues a
|
|
2265
|
+
* second time, so the two cannot disagree about which message belongs to a
|
|
2266
|
+
* field or which failure won when one field had several.
|
|
2267
|
+
*/
|
|
2268
|
+
function getValidationErrors(result) {
|
|
2269
|
+
return Object.fromEntries(getValidationIssues(result).map((issue) => [issue.path, issue.message]));
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/utils/one-response-field.ts
|
|
2273
|
+
/** Why the chosen field cannot be made optional, conditional, or deleted. */
|
|
2274
|
+
const ONE_RESPONSE_FIELD_REASON = "This field is used for one response per person, so it stays required and always shown. Choose another Email field in Settings first.";
|
|
2275
|
+
/** Whether `field` can be the Email field one response per email compares. */
|
|
2276
|
+
function comparesByEmail(field) {
|
|
2277
|
+
return field.type === "email" && field.required === true && field.conditionalLogic?.enabled !== true;
|
|
2278
|
+
}
|
|
2279
|
+
/** The fields one response per email may compare, in the form's order. */
|
|
2280
|
+
function emailFieldsToCompare(fields) {
|
|
2281
|
+
return fields.filter(comparesByEmail);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Why a form's one response per email cannot stand as saved, or undefined
|
|
2285
|
+
* when it can (or the form does not compare by email).
|
|
2286
|
+
*/
|
|
2287
|
+
function oneResponseFieldProblem(settings, fields) {
|
|
2288
|
+
if (settings.oneResponse !== "email") return void 0;
|
|
2289
|
+
const name = settings.oneResponseField;
|
|
2290
|
+
if (!name) return "One response per email address needs an Email field to compare. Choose one in Settings.";
|
|
2291
|
+
const field = fields.find((each) => each.name === name);
|
|
2292
|
+
if (!field || field.type !== "email") return `One response per email address compares "${name}", which is not an Email field of this form.`;
|
|
2293
|
+
return comparesByEmail(field) ? void 0 : `The Email field one response per person compares, "${field.label || name}", must be required and not hidden by a condition.`;
|
|
2294
|
+
}
|
|
2295
|
+
/**
|
|
2296
|
+
* Why an edit of `before` into `after` is refused, or undefined: the field
|
|
2297
|
+
* one response per email compares may be renamed but not made optional,
|
|
2298
|
+
* conditional or another type.
|
|
2299
|
+
*/
|
|
2300
|
+
function oneResponseEditProblem(before, after, settings) {
|
|
2301
|
+
if (settings.oneResponse !== "email") return void 0;
|
|
2302
|
+
if (settings.oneResponseField !== before.name) return void 0;
|
|
2303
|
+
return comparesByEmail(after) ? void 0 : ONE_RESPONSE_FIELD_REASON;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/utils/sent-from.ts
|
|
2307
|
+
/**
|
|
2308
|
+
* The page a submission was sent from, as the submission keeps it: the web
|
|
2309
|
+
* address the visitor's browser reported in `Referer`, reduced to its origin
|
|
2310
|
+
* and path. Its query and fragment are dropped, since a page's address can
|
|
2311
|
+
* carry personal data there (a search, an email in a link, a token), and an
|
|
2312
|
+
* address that is not a web page (another scheme, or none) is not kept. The
|
|
2313
|
+
* browser reports it, so it is a hint for staff, not evidence.
|
|
2314
|
+
*
|
|
2315
|
+
* @module utils/sent-from
|
|
2316
|
+
*/
|
|
2317
|
+
/** The longest address kept, in characters; a longer one is not kept. */
|
|
2318
|
+
const PAGE_MAX = 2048;
|
|
2319
|
+
/** The page a `Referer` names, or undefined when it names no web page. */
|
|
2320
|
+
function pageFromReferer(referer) {
|
|
2321
|
+
if (!referer) return void 0;
|
|
2322
|
+
let url;
|
|
2323
|
+
try {
|
|
2324
|
+
url = new URL(referer);
|
|
2325
|
+
} catch {
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
|
|
2329
|
+
const page = `${url.origin}${url.pathname}`;
|
|
2330
|
+
return page.length <= PAGE_MAX ? page : void 0;
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// src/admin/components/submissions/submission-list-scope.ts
|
|
2334
|
+
const STATUS_TABS = [
|
|
2335
|
+
"all",
|
|
2336
|
+
"new",
|
|
2337
|
+
"read",
|
|
2338
|
+
"archived",
|
|
2339
|
+
"spam"
|
|
2340
|
+
];
|
|
2341
|
+
/**
|
|
2342
|
+
* The scope's dates as a filter of `submittedAt`, or none for any date. The
|
|
2343
|
+
* upper bound is exclusive, so a range ending at the start of a day leaves
|
|
2344
|
+
* that day out, whatever the precision a dialect stores the time with.
|
|
2345
|
+
*/
|
|
2346
|
+
function sentBetween(scope) {
|
|
2347
|
+
if (!scope.from && !scope.to) return void 0;
|
|
2348
|
+
return {
|
|
2349
|
+
...scope.from ? { greater_than_equal: scope.from } : {},
|
|
2350
|
+
...scope.to ? { less_than: scope.to } : {}
|
|
2351
|
+
};
|
|
2352
|
+
}
|
|
2353
|
+
/** The list's filter as the entries API takes it. */
|
|
2354
|
+
function listWhere(scope) {
|
|
2355
|
+
const where = {};
|
|
2356
|
+
if (scope.form) where.form = { equals: scope.form };
|
|
2357
|
+
where.status = scope.status === "all" ? { not_equals: "spam" } : { equals: scope.status };
|
|
2358
|
+
const sent = sentBetween(scope);
|
|
2359
|
+
if (sent) where.submittedAt = sent;
|
|
2360
|
+
return where;
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* The list's order: newest first, and among submissions sent at the same
|
|
2364
|
+
* moment, by id. The id makes the order total, so a submission's newer and
|
|
2365
|
+
* older neighbours are always defined even when timestamps tie (imported
|
|
2366
|
+
* rows can share one). It is the tiebreak the entries API adds last anyway,
|
|
2367
|
+
* spelled out so the page's neighbour queries mirror it exactly.
|
|
2368
|
+
*/
|
|
2369
|
+
const LIST_SORT = "-submittedAt,id";
|
|
2370
|
+
/**
|
|
2371
|
+
* The list a submission's page counts it in: the list's filter, and the
|
|
2372
|
+
* submission itself. Opened from the New tab, the submission is marked Read
|
|
2373
|
+
* and leaves that filter; archiving or recovering it can too. Counted with
|
|
2374
|
+
* it, the page's "3 of 3" stays true to the list it was opened from rather
|
|
2375
|
+
* than reading "3 of 2", and its newer neighbours still count its place.
|
|
2376
|
+
*/
|
|
2377
|
+
function listWithWhere(scope, id) {
|
|
2378
|
+
return { or: [listWhere(scope), { id: { equals: id } }] };
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* The queries that find the submissions just newer and just older than the
|
|
2382
|
+
* one sent at `at` with id `id`, in LIST_SORT's order: each side is a strict
|
|
2383
|
+
* comparison on the timestamp, or the same timestamp and the id on that side.
|
|
2384
|
+
* The newer side is read in reverse, so its first row is the nearest one, and
|
|
2385
|
+
* its total is how many rows come before this submission in the list.
|
|
2386
|
+
*/
|
|
2387
|
+
function neighbourQueries(scope, at, id) {
|
|
2388
|
+
const base = listWhere(scope);
|
|
2389
|
+
const side = (time, tie) => ({
|
|
2390
|
+
...base,
|
|
2391
|
+
or: [{ submittedAt: { [time]: at } }, { and: [{ submittedAt: { equals: at } }, { id: { [tie]: id } }] }]
|
|
2392
|
+
});
|
|
2393
|
+
return {
|
|
2394
|
+
newer: {
|
|
2395
|
+
where: side("greater_than", "less_than"),
|
|
2396
|
+
sort: "submittedAt,-id"
|
|
2397
|
+
},
|
|
2398
|
+
older: {
|
|
2399
|
+
where: side("less_than", "greater_than"),
|
|
2400
|
+
sort: LIST_SORT
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
/** The scope as an address carries it: a submission page's, or the export's. */
|
|
2405
|
+
function scopeParams(scope) {
|
|
2406
|
+
const params = new URLSearchParams({ status: scope.status });
|
|
2407
|
+
if (scope.form) params.set("form", scope.form);
|
|
2408
|
+
if (scope.from) params.set("from", scope.from);
|
|
2409
|
+
if (scope.to) params.set("to", scope.to);
|
|
2410
|
+
return params;
|
|
2411
|
+
}
|
|
2412
|
+
/** A moment an address names, as an ISO date and time; none for anything else. */
|
|
2413
|
+
function momentOf(text) {
|
|
2414
|
+
if (!text) return void 0;
|
|
2415
|
+
const at = Date.parse(text);
|
|
2416
|
+
return Number.isNaN(at) ? void 0 : new Date(at).toISOString();
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* The scope an address carries, read defensively: an unknown status reads as
|
|
2420
|
+
* "all" and a date that is not one as no bound, so a hand-edited address
|
|
2421
|
+
* still lists something.
|
|
2422
|
+
*/
|
|
2423
|
+
function scopeFromSearch(search) {
|
|
2424
|
+
const params = new URLSearchParams(search);
|
|
2425
|
+
const status = params.get("status");
|
|
2426
|
+
const form = params.get("form");
|
|
2427
|
+
const from = momentOf(params.get("from"));
|
|
2428
|
+
const to = momentOf(params.get("to"));
|
|
2429
|
+
return {
|
|
2430
|
+
status: STATUS_TABS.includes(status) ? status : "all",
|
|
2431
|
+
...form ? { form } : {},
|
|
2432
|
+
...from ? { from } : {},
|
|
2433
|
+
...to ? { to } : {}
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
/** A submission's page, opened from the list `scope`. */
|
|
2437
|
+
function submissionPageAddress(submissionsSlug, id, scope) {
|
|
2438
|
+
return `/admin/collections/${encodeURIComponent(submissionsSlug)}/${encodeURIComponent(id)}?${scopeParams(scope).toString()}`;
|
|
2439
|
+
}
|
|
2440
|
+
/** The export route, as the admin's API serves it. */
|
|
2441
|
+
const EXPORT_ROUTE = "/admin/api/plugins/@nextlyhq/plugin-form-builder/submissions/export";
|
|
2442
|
+
/**
|
|
2443
|
+
* The export of what a list shows, in `format`: every submission its scope
|
|
2444
|
+
* matches, or, given `ids`, exactly those (the rows ticked in it).
|
|
2445
|
+
*/
|
|
2446
|
+
function exportAddress(scope, format, ids) {
|
|
2447
|
+
const params = scopeParams(scope);
|
|
2448
|
+
params.set("format", format);
|
|
2449
|
+
if (ids !== void 0) params.set("ids", ids.join(","));
|
|
2450
|
+
return `${EXPORT_ROUTE}?${params.toString()}`;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
// src/utils/notification-model.ts
|
|
2454
|
+
/**
|
|
2455
|
+
* Split typed addresses into a list: separated by commas, semicolons,
|
|
2456
|
+
* spaces or new lines, each trimmed, blanks and repeats dropped.
|
|
2457
|
+
*/
|
|
2458
|
+
function parseAddressList(text) {
|
|
2459
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2460
|
+
const list = [];
|
|
2461
|
+
for (const part of text.split(/[\s,;]+/)) {
|
|
2462
|
+
const address = part.trim();
|
|
2463
|
+
if (!address || seen.has(address.toLowerCase())) continue;
|
|
2464
|
+
seen.add(address.toLowerCase());
|
|
2465
|
+
list.push(address);
|
|
2466
|
+
}
|
|
2467
|
+
return list;
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* A team notification's addresses: its list, or the single address it was
|
|
2471
|
+
* saved with. Trimmed, blanks dropped.
|
|
2472
|
+
*/
|
|
2473
|
+
function teamAddressesOf(notification) {
|
|
2474
|
+
return (Array.isArray(notification.recipients) ? notification.recipients : typeof notification.to === "string" ? [notification.to] : []).map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
|
|
2475
|
+
}
|
|
2476
|
+
/** A notification's conditions, as a match and a list. */
|
|
2477
|
+
function conditionsOf(notification) {
|
|
2478
|
+
const stored = notification.conditions;
|
|
2479
|
+
if (stored && Array.isArray(stored.list)) return {
|
|
2480
|
+
match: stored.match === "any" ? "any" : "all",
|
|
2481
|
+
list: stored.list.filter(isCondition)
|
|
2482
|
+
};
|
|
2483
|
+
return {
|
|
2484
|
+
match: "all",
|
|
2485
|
+
list: notification.condition ? [notification.condition] : []
|
|
2486
|
+
};
|
|
2487
|
+
}
|
|
2488
|
+
function isCondition(value) {
|
|
2489
|
+
return !!value && typeof value === "object" && typeof value.field === "string" && typeof value.comparison === "string";
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Whether a notification sends for these answers: every condition holds
|
|
2493
|
+
* (all) or one does (any). One with no conditions always sends.
|
|
2494
|
+
*/
|
|
2495
|
+
function conditionsMet(conditions, data) {
|
|
2496
|
+
if (conditions.list.length === 0) return true;
|
|
2497
|
+
const holds = (condition) => evaluateSingleCondition(condition, data);
|
|
2498
|
+
return conditions.match === "any" ? conditions.list.some(holds) : conditions.list.every(holds);
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* The file fields whose files go with a notification, by name: its own
|
|
2502
|
+
* choice, or for a rule saved before it, the fields marked to attach.
|
|
2503
|
+
*/
|
|
2504
|
+
function attachFieldsOf(notification, fields) {
|
|
2505
|
+
if (Array.isArray(notification.attachFields)) return notification.attachFields.filter((name) => typeof name === "string");
|
|
2506
|
+
return fieldsMarkedToAttach(fields);
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* The file fields marked to attach to notification emails, by name: what
|
|
2510
|
+
* every email carried before each notification chose its own.
|
|
2511
|
+
*/
|
|
2512
|
+
function fieldsMarkedToAttach(fields) {
|
|
2513
|
+
return fields.filter((field) => field.type === "file" && field.attachToEmail === true && typeof field.name === "string").map((field) => field.name);
|
|
2514
|
+
}
|
|
2515
|
+
/** The media ids a submission holds under the named file fields. */
|
|
2516
|
+
function attachmentsFrom(fieldNames, data) {
|
|
2517
|
+
const attachments = [];
|
|
2518
|
+
for (const name of fieldNames) {
|
|
2519
|
+
const value = data[name];
|
|
2520
|
+
for (const item of Array.isArray(value) ? value : [value]) if (typeof item === "string" && item) attachments.push({ mediaId: item });
|
|
2521
|
+
}
|
|
2522
|
+
return attachments;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/utils/field-references.ts
|
|
2526
|
+
/**
|
|
2527
|
+
* The one spelling of a field reference this plugin recognises: `{{fieldName}}`
|
|
2528
|
+
* with no spaces, and `\w` names.
|
|
2529
|
+
*
|
|
2530
|
+
* Exact, because the SEND path is exact — a value the admin accepts as a
|
|
2531
|
+
* reference and the send path does not is delivered with its braces intact,
|
|
2532
|
+
* straight to the mail provider. Both sides read this constant so the question
|
|
2533
|
+
* has one answer.
|
|
2534
|
+
*/
|
|
2535
|
+
const FIELD_REF_PATTERN = /^\{\{(\w+)\}\}$/;
|
|
2536
|
+
/** The field named by a reference, or `null` when the value is not one. */
|
|
2537
|
+
function parseFieldRef(value) {
|
|
2538
|
+
if (!value) return null;
|
|
2539
|
+
const match = value.match(FIELD_REF_PATTERN);
|
|
2540
|
+
return match ? match[1] : null;
|
|
2541
|
+
}
|
|
2542
|
+
/**
|
|
2543
|
+
* Match `{{ fieldName }}` interpolations (whitespace-tolerant), the syntax the
|
|
2544
|
+
* notification send path resolves recipients and reply-to values with.
|
|
2545
|
+
*/
|
|
2546
|
+
function templatePattern(fieldName, flags) {
|
|
2547
|
+
return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`, flags);
|
|
2548
|
+
}
|
|
2549
|
+
function referencesInTemplate(template, fieldName) {
|
|
2550
|
+
return templatePattern(fieldName).test(template);
|
|
2551
|
+
}
|
|
2552
|
+
/** The label a setting that refers to a field is named by. */
|
|
2553
|
+
const ONE_RESPONSE_SETTING = "One response per person";
|
|
2554
|
+
/**
|
|
2555
|
+
* The field one response per email compares, when the form takes one
|
|
2556
|
+
* response per email address; none otherwise.
|
|
2557
|
+
*/
|
|
2558
|
+
function oneResponseEmailField(settings) {
|
|
2559
|
+
return settings?.oneResponse === "email" ? settings.oneResponseField : void 0;
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* All references to `fieldName` across the form's fields, notifications and
|
|
2563
|
+
* settings (the Email field one response per person compares).
|
|
2564
|
+
*/
|
|
2565
|
+
function findFieldReferences(fieldName, fields, notifications, settings) {
|
|
2566
|
+
const references = [];
|
|
2567
|
+
if (oneResponseEmailField(settings) === fieldName) references.push({
|
|
2568
|
+
kind: "setting",
|
|
2569
|
+
label: ONE_RESPONSE_SETTING
|
|
2570
|
+
});
|
|
2571
|
+
for (const field of fields) {
|
|
2572
|
+
if (field.name === fieldName) continue;
|
|
2573
|
+
if (!field.conditionalLogic?.enabled) continue;
|
|
2574
|
+
if ((field.conditionalLogic.conditions ?? []).some((condition) => condition.field === fieldName)) references.push({
|
|
2575
|
+
kind: "condition",
|
|
2576
|
+
label: field.label || field.name
|
|
2577
|
+
});
|
|
2578
|
+
}
|
|
2579
|
+
for (const notification of notifications) {
|
|
2580
|
+
if (!notification.enabled) continue;
|
|
2581
|
+
const referencedByTemplate = [
|
|
2582
|
+
notification.to,
|
|
2583
|
+
...notification.cc ?? [],
|
|
2584
|
+
...notification.bcc ?? [],
|
|
2585
|
+
notification.replyTo
|
|
2586
|
+
].filter((value) => typeof value === "string").some((template) => referencesInTemplate(template, fieldName));
|
|
2587
|
+
const referencedByCondition = conditionsOf(notification).list.some((condition) => condition.field === fieldName);
|
|
2588
|
+
const referencedByAttachment = notification.attachFields?.includes(fieldName) ?? false;
|
|
2589
|
+
if (referencedByTemplate || referencedByCondition || referencedByAttachment) references.push({
|
|
2590
|
+
kind: "notification",
|
|
2591
|
+
label: notification.name || "Notification"
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
return references;
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Reference lists for every field in one pass, keyed by field name, so a
|
|
2598
|
+
* per-card lookup is O(1) instead of re-walking fields and notifications for
|
|
2599
|
+
* each card on every render.
|
|
2600
|
+
*/
|
|
2601
|
+
function buildFieldReferenceMap(fields, notifications, settings) {
|
|
2602
|
+
const map = /* @__PURE__ */ new Map();
|
|
2603
|
+
for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications, settings));
|
|
2604
|
+
return map;
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* The fields with every condition on `from` naming `to` instead: the
|
|
2608
|
+
* conditions `findFieldReferences` reads, disabled ones too, so a rename never
|
|
2609
|
+
* leaves one pointing at a field that no longer has that name.
|
|
2610
|
+
*/
|
|
2611
|
+
function renameInFieldConditions(fields, from, to) {
|
|
2612
|
+
return fields.map((field) => {
|
|
2613
|
+
const logic = field.conditionalLogic;
|
|
2614
|
+
if (!logic?.conditions?.some((condition) => condition.field === from)) return field;
|
|
2615
|
+
return {
|
|
2616
|
+
...field,
|
|
2617
|
+
conditionalLogic: {
|
|
2618
|
+
...logic,
|
|
2619
|
+
conditions: logic.conditions.map((condition) => condition.field === from ? {
|
|
2620
|
+
...condition,
|
|
2621
|
+
field: to
|
|
2622
|
+
} : condition)
|
|
2623
|
+
}
|
|
2624
|
+
};
|
|
2625
|
+
});
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The notifications with every reference to `from` naming `to` instead: the
|
|
2629
|
+
* addresses (`{{ from }}`), the conditions and the attached file fields
|
|
2630
|
+
* `findFieldReferences` reads.
|
|
2631
|
+
*/
|
|
2632
|
+
function renameInNotifications(notifications, from, to) {
|
|
2633
|
+
const pattern = templatePattern(from, "g");
|
|
2634
|
+
const rename = (template) => template.replace(pattern, `{{${to}}}`);
|
|
2635
|
+
return notifications.map((notification) => {
|
|
2636
|
+
const { cc, bcc } = notification;
|
|
2637
|
+
const next = {
|
|
2638
|
+
...notification,
|
|
2639
|
+
to: rename(notification.to)
|
|
2640
|
+
};
|
|
2641
|
+
if (cc) next.cc = cc.map(rename);
|
|
2642
|
+
if (bcc) next.bcc = bcc.map(rename);
|
|
2643
|
+
if (notification.replyTo !== void 0) next.replyTo = rename(notification.replyTo);
|
|
2644
|
+
if (notification.condition?.field === from) next.condition = {
|
|
2645
|
+
...notification.condition,
|
|
2646
|
+
field: to
|
|
2647
|
+
};
|
|
2648
|
+
if (notification.conditions) next.conditions = {
|
|
2649
|
+
...notification.conditions,
|
|
2650
|
+
list: notification.conditions.list.map((condition) => condition.field === from ? {
|
|
2651
|
+
...condition,
|
|
2652
|
+
field: to
|
|
2653
|
+
} : condition)
|
|
2654
|
+
};
|
|
2655
|
+
if (notification.attachFields) next.attachFields = notification.attachFields.map((name) => name === from ? to : name);
|
|
2656
|
+
return next;
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// src/utils/submission-title.ts
|
|
2661
|
+
/**
|
|
2662
|
+
* Who sent a submission, as the admin names it: the first answer to a text
|
|
2663
|
+
* field named or labelled as a name, else the first email address answered,
|
|
2664
|
+
* else none. One rule, read by the write seam (which keeps it as the
|
|
2665
|
+
* submission's title, the admin's breadcrumb and list name) and by the
|
|
2666
|
+
* submission's page.
|
|
2667
|
+
*
|
|
2668
|
+
* @module utils/submission-title
|
|
2669
|
+
*/
|
|
2670
|
+
/**
|
|
2671
|
+
* The longest title kept, in characters. The title is a display copy of an
|
|
2672
|
+
* answer, stored as a text column, which MySQL holds to 255 characters; an
|
|
2673
|
+
* answer has no such limit, so a longer one is shortened here rather than
|
|
2674
|
+
* refusing the submission it names.
|
|
2675
|
+
*/
|
|
2676
|
+
const TITLE_MAX = 200;
|
|
2677
|
+
/**
|
|
2678
|
+
* The title a submission is stored with: who sent it, shortened to
|
|
2679
|
+
* TITLE_MAX characters with an ellipsis, or none.
|
|
2680
|
+
*/
|
|
2681
|
+
function submissionTitleOf(fields, data) {
|
|
2682
|
+
const sender = senderOf(fields, data);
|
|
2683
|
+
if (sender === void 0) return void 0;
|
|
2684
|
+
const characters = Array.from(sender);
|
|
2685
|
+
return characters.length <= TITLE_MAX ? sender : `${characters.slice(0, 199).join("").trimEnd()}…`;
|
|
2686
|
+
}
|
|
2687
|
+
function senderOf(fields, data) {
|
|
2688
|
+
const answer = (name) => {
|
|
2689
|
+
const value = data[name];
|
|
2690
|
+
return typeof value === "string" ? value.trim() : "";
|
|
2691
|
+
};
|
|
2692
|
+
const named = fields.find((field) => field.type === "text" && /name/i.test(`${field.name} ${field.label ?? ""}`) && answer(field.name));
|
|
2693
|
+
if (named) return answer(named.name);
|
|
2694
|
+
const email = fields.find((field) => field.type === "email" && answer(field.name));
|
|
2695
|
+
return email ? answer(email.name) : void 0;
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
export { applyFieldDefaults as $, getValidationIssues as A, fieldsHiddenByCondition as At, isKnownFormField as B, pickedDocumentField as Bt, ONE_RESPONSE_FIELD_REASON as C, isConsentAnswer as Ct, oneResponseFieldProblem as D, askedFieldsOf as Dt, oneResponseEditProblem as E, askedAfterEdit as Et, isCheckboxFormField as F, readNewSubmissions as Ft, latestByRecipient as G, formPageOf as Gt, isTextFormField as H, FORM_PAGES as Ht, isEmailFormField as I, SCHEMA_ONLY_CONTEXT as It, recipientLook as J, notificationLook as K, isFileFormField as L, SCHEMA_ONLY_READ as Lt, validateFormData as M, getSupportedComparisonOperators as Mt, enforcedValidationRules as N, isValidComparisonOperator as Nt, generateZodSchema as O, askedSnapshotOf as Ot, BUILT_IN_FORM_FIELD_TYPES as P, NEW_SUBMISSIONS_WIDGET_ID as Pt, DEFAULT_NOTIFICATION_SETTINGS as Q, isFormField as R, documentReachability as Rt, pageFromReferer as S, editedConsents as St, emailFieldsToCompare as T, storedConsentNames as Tt, addressableNow as U, addressedFormPages as Ut, isSelectFormField as V, FORM_EDITOR_VIEW as Vt, cancelText as W, formPageId as Wt, normalizeFormSettings as X, sendInProgress as Y, DEFAULT_FORM_SETTINGS as Z, listWhere as _, CONSENT_EDIT_REFUSED as _t, parseFieldRef as a, FORM_CONFIRMATION_TEMPLATE as at, scopeFromSearch as b, blankConsentFields as bt, attachFieldsOf as c, starterTemplateFor as ct, conditionsOf as d, exportAndDownload as dt, applyFormDefaults as et, fieldsMarkedToAttach as f, exportToCSV as ft, exportAddress as g, optionLabels as gt, LIST_SORT as h, generateExportFilename as ht, oneResponseEmailField as i, warnIfWebhooksConfigured as it, transformFormData as j, evaluateConditions as jt, getValidationErrors as k, readAskedSnapshot as kt, attachmentsFrom as l, answerReader as lt, teamAddressesOf as m, formatExportValue as mt, submissionTitleOf as n, pluralize as nt, renameInFieldConditions as o, FORM_SUBMISSION_TEMPLATE as ot, parseAddressList as p, exportToJSON as pt, reachedExcept as q, buildFieldReferenceMap as r, toTitleCase as rt, renameInNotifications as s, formsEmailTemplates as st, senderOf as t, createFormConfig as tt, conditionsMet as u, downloadFile as ut, listWithWhere as v, CONSENT_TEXT_CHANGED as vt, comparesByEmail as w, staleConsentNames as wt, submissionPageAddress as x, consentFieldEdit as xt, neighbourQueries as y, CONSENT_TEXT_REQUIRED as yt, isFormFieldType as z, parseRedirectReference as zt };
|
|
2699
|
+
//# sourceMappingURL=submission-title-CVixn15Z.js.map
|