@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.
@@ -1,1318 +0,0 @@
1
- // src/utils/redirect-reference.ts
2
- /**
3
- * The reference a `redirectPage` value holds, in any shape a read produces.
4
- *
5
- * Three arrive in practice and they are not interchangeable: a polymorphic
6
- * field stores `{ relationTo, value }`; a read at depth replaces `value` with
7
- * the populated row; and a single-target field stores the bare id. The last
8
- * names no collection, so it resolves only when exactly one is configured —
9
- * guessing which of several a bare id belongs to would send visitors to a
10
- * URL built from the wrong collection's pattern.
11
- */
12
- function parseRedirectReference(value, collections) {
13
- if (value == null) return null;
14
- if (typeof value === "string") {
15
- const id = value.trim();
16
- return id && collections.length === 1 ? {
17
- collection: collections[0],
18
- id
19
- } : null;
20
- }
21
- if (typeof value !== "object" || Array.isArray(value)) return null;
22
- const record = value;
23
- const relationTo = record.relationTo;
24
- const inner = record.value;
25
- if (typeof relationTo === "string" && relationTo.trim()) {
26
- const id = referenceId(inner);
27
- return id ? {
28
- collection: relationTo.trim(),
29
- id
30
- } : null;
31
- }
32
- const id = referenceId(record);
33
- if (!id) return null;
34
- return collections.length === 1 ? {
35
- collection: collections[0],
36
- id
37
- } : null;
38
- }
39
- function referenceId(value) {
40
- if (typeof value === "string") return value.trim() || null;
41
- if (value !== null && typeof value === "object" && !Array.isArray(value)) {
42
- const { id } = value;
43
- if (typeof id === "string" && id) return id;
44
- }
45
- return null;
46
- }
47
- /**
48
- * The field the publish lifecycle puts on every row it manages.
49
- *
50
- * A collection without the lifecycle may legally define an ordinary field
51
- * named `status` — "active", "archived", anything — so the NAME `status` does
52
- * not identify a lifecycle and reading it as one marks live documents as
53
- * drafts. This field does identify it: it is written by the framework, not by
54
- * a schema author.
55
- *
56
- * Measured on 2026-08-25, creating one document in each kind of collection:
57
- * a `status: true` collection returns
58
- * `[createdAt, firstPublishedAt, id, slug, status, title, updatedAt]`, and a
59
- * plain collection carrying its own `status` text field returns the same list
60
- * WITHOUT `firstPublishedAt`. It is present on a never-published draft too, so
61
- * presence marks the lifecycle rather than the act of publishing.
62
- */
63
- const PUBLISH_LIFECYCLE_FIELD = "firstPublishedAt";
64
- /**
65
- * Whether this document comes from a collection with the publish lifecycle.
66
- *
67
- * Callers that project their reads must request
68
- * {@link PUBLISH_LIFECYCLE_FIELD}; a projection that omits it makes every
69
- * document look unmanaged, which reads as "always reachable".
70
- */
71
- function hasPublishLifecycle(document) {
72
- return PUBLISH_LIFECYCLE_FIELD in document;
73
- }
74
- /**
75
- * Whether a visitor can reach this document.
76
- *
77
- * `collectionIsLocalized` is the caller's knowledge of the target collection's
78
- * `localized` setting, or `undefined` where the caller has no way to know.
79
- *
80
- * A localized collection publishes PER LOCALE, on a companion row that no read
81
- * available here returns. Measured on 2026-08-25 against a collection with
82
- * `localized: true` whose Spanish translation was published while the default
83
- * locale stayed a draft: `findByID` answers `status: "draft"` at the default
84
- * locale AND at `es`, and a `find` filtered to `status: "published"` returns
85
- * the document at no locale at all — no locale, `en` or `es`. So the main
86
- * row's `status` does not answer for a localized document, and reading it as
87
- * an answer marks a page visitors can reach as a draft, refuses a correct save
88
- * and drops a working redirect.
89
- *
90
- * `firstPublishedAt` is the one fact that survives that: it records whether the
91
- * document has ever been public in ANY language, so its absence is decisive
92
- * whether or not the collection is localized.
93
- */
94
- function documentReachability(document, collectionIsLocalized) {
95
- if (!hasPublishLifecycle(document)) return "reachable";
96
- if (document.status === "published") return "reachable";
97
- if (collectionIsLocalized === false) return "unreachable";
98
- return "unknown";
99
- }
100
- /**
101
- * Which settings key names the document this form redirects to, if any.
102
- *
103
- * TWO options carry one. "Redirect to a page" stores `redirectPage`, and the
104
- * URL option falls back to `redirectRelation` when no URL is typed — the shape
105
- * code-first and legacy forms use, and one the submission path has always
106
- * resolved. One definition of the question, so the rule that guards a target
107
- * cannot recognise fewer shapes than the resolver that acts on it: a form the
108
- * rule skips is a form the resolver still redirects.
109
- *
110
- * The two are not equally strict about being EMPTY, deliberately. Naming
111
- * nothing under "Redirect to a page" contradicts the option itself, so it is
112
- * reported. Under the URL option an absent relation only means no destination
113
- * is configured yet — `validateFormConfig` already governs that, and refusing
114
- * it here would reject saves this rule never governed.
115
- */
116
- function pickedDocumentField(settings) {
117
- if (settings.confirmationType === "relationship") return "redirectPage";
118
- if (settings.confirmationType === "redirect" && !settings.redirectUrl && settings.redirectRelation != null) return "redirectRelation";
119
- return null;
120
- }
121
-
122
- // src/utils/schema-only-read.ts
123
- /**
124
- * The hook context that tells the forms collection's `afterRead` hook a read
125
- * wants the forms themselves and nothing only a display needs, so it skips
126
- * the submission count it would otherwise run per form: a write checking a
127
- * submission against its form, and the dashboard card scanning which forms a
128
- * reader may read, need the count no more than they need a second copy of
129
- * the form.
130
- *
131
- * @module utils/schema-only-read
132
- */
133
- /** The context key the forms `afterRead` hook reads; `true` skips the count. */
134
- const SCHEMA_ONLY_READ = "formBuilder.schemaOnlyRead";
135
- /** The context a schema-only read hands the forms collection's hooks. */
136
- const SCHEMA_ONLY_CONTEXT = { [SCHEMA_ONLY_READ]: true };
137
-
138
- // src/dashboard/new-submissions.ts
139
- /** The widget's id, the one core's default names. */
140
- const NEW_SUBMISSIONS_WIDGET_ID = "form-builder/new-submissions";
141
- /** The status a submission arrives with, and keeps until someone opens it. */
142
- const NEW_STATUS = "new";
143
- /** The admin's root, which a row's link and the list's link start from. */
144
- const ADMIN_ROOT = "/admin";
145
- /** The most forms one read answers, the widget reads' own cap. */
146
- const FORM_PAGE = 50;
147
- /**
148
- * The most pages of forms read for the filter: 1,000 forms. Past that, the
149
- * later forms' submissions are left out of the card and the count, never
150
- * shown: what is missed is a reader's own, not someone else's. The count then
151
- * says it is a floor (`atLeast`), so nobody reads it as every one.
152
- */
153
- const MAX_FORM_PAGES = 20;
154
- /** The submission fields a row is made from; no other is asked for. */
155
- const SUBMISSION_FIELDS = [
156
- "id",
157
- "form",
158
- "data",
159
- "submittedAt",
160
- "createdAt"
161
- ];
162
- /** The form fields a row is made from. */
163
- const FORM_FIELDS = [
164
- "id",
165
- "name",
166
- "fields"
167
- ];
168
- /**
169
- * Field names and labels that mark a form's name field, compared as letters
170
- * only (`lettersOf`), so case, spaces, underscores, hyphens and camelCase do
171
- * not matter: `firstName`, `first_name` and "First name" are all "firstname".
172
- */
173
- const NAME_KEYS = /* @__PURE__ */ new Set([
174
- "name",
175
- "fullname",
176
- "firstname",
177
- "yourname",
178
- "yourfullname",
179
- "yourfirstname"
180
- ]);
181
- /**
182
- * The most characters of an answer or a name a row carries. A form may leave
183
- * a field's length open, so a visitor's answer can be any length; the card
184
- * draws one line, and the rest would only weigh down its data and the page.
185
- */
186
- const MAX_ROW_TEXT = 140;
187
- /** The answer types whose first line a row may show under its title. */
188
- const TEXT_TYPES = /* @__PURE__ */ new Set(["text", "textarea"]);
189
- /** Up to `limit` ids of forms the reader may read, by id, after `after`. */
190
- async function formIdPage(services, forms, page) {
191
- const { items } = await services.find(forms, {
192
- ...page.after === void 0 ? {} : { where: { id: { greater_than: page.after } } },
193
- sort: "id",
194
- limit: page.limit,
195
- select: ["id"],
196
- status: "all",
197
- context: SCHEMA_ONLY_CONTEXT,
198
- ...page.signal === void 0 ? {} : { signal: page.signal }
199
- });
200
- return items.flatMap((item) => typeof item.id === "string" ? [item.id] : []);
201
- }
202
- /**
203
- * The ids of the forms the reader may read, in pages of `FORM_PAGE`, by id,
204
- * at most `MAX_FORM_PAGES` of them. A form a read rule keeps from them is
205
- * never among them. Where the budget is spent, one more id says whether any
206
- * form was left out.
207
- */
208
- async function readableFormIds(services, forms, signal) {
209
- const ids = [];
210
- let after;
211
- for (let page = 0; page < MAX_FORM_PAGES; page += 1) {
212
- const pageIds = await formIdPage(services, forms, {
213
- after,
214
- limit: FORM_PAGE,
215
- signal
216
- });
217
- ids.push(...pageIds);
218
- after = pageIds.at(-1);
219
- if (pageIds.length < FORM_PAGE) return {
220
- ids,
221
- complete: true
222
- };
223
- }
224
- return {
225
- ids,
226
- complete: (await formIdPage(services, forms, {
227
- after,
228
- limit: 1,
229
- signal
230
- })).length === 0
231
- };
232
- }
233
- /**
234
- * The forms the reader may read, scanned once in `scope` where one is given:
235
- * the card's data and the header's count, answered in one request, share one
236
- * scan. Keyed by the forms collection, so a site that renamed it scans its own.
237
- */
238
- function readableFormsOnce(services, forms, read) {
239
- const scan = () => readableFormIds(services, forms, read.signal);
240
- return read.scope === void 0 ? scan() : read.scope.getOrCompute(`readable-forms:${forms}`, scan);
241
- }
242
- /** The new submissions of the forms named: what the card lists and the header counts. */
243
- function newSubmissionsWhere(formIds) {
244
- return {
245
- status: { equals: "new" },
246
- form: { in: [...formIds] }
247
- };
248
- }
249
- /**
250
- * The card's data: the newest new submissions the card holds, and how many
251
- * there are. Asked with a `limit` of 0 it reads no rows and still counts
252
- * them, which is the header's count.
253
- */
254
- async function readNewSubmissions(services, collections, read) {
255
- const signal = read.signal === void 0 ? {} : { signal: read.signal };
256
- const allHref = `${ADMIN_ROOT}/collections/${read.submissionsPath}`;
257
- const collection = read.submissionsPath;
258
- const readable = await readableFormsOnce(services, collections.forms, read);
259
- if (readable.ids.length === 0) return {
260
- items: [],
261
- total: 0,
262
- allHref,
263
- collection
264
- };
265
- const found = await services.find(collections.submissions, {
266
- where: newSubmissionsWhere(readable.ids),
267
- sort: "-submittedAt",
268
- limit: read.limit,
269
- select: SUBMISSION_FIELDS,
270
- status: "all",
271
- total: true,
272
- depth: 0,
273
- ...signal
274
- });
275
- const forms = await formsOf(services, collections.forms, found.items, signal);
276
- return {
277
- items: found.items.map((item) => submissionRow(item, forms.get(formIdOf(item.form) ?? ""), allHref)),
278
- total: found.total ?? found.items.length,
279
- ...found.atLeast === true || !readable.complete ? { atLeast: true } : {},
280
- allHref,
281
- collection
282
- };
283
- }
284
- /** The forms of the submissions drawn, by id: one read, of those forms only. */
285
- async function formsOf(services, forms, submissions, signal) {
286
- const ids = [...new Set(submissions.flatMap((row) => formIdOf(row.form) ?? []))];
287
- if (ids.length === 0) return /* @__PURE__ */ new Map();
288
- const { items } = await services.find(forms, {
289
- where: { id: { in: ids } },
290
- limit: ids.length,
291
- select: FORM_FIELDS,
292
- status: "all",
293
- context: SCHEMA_ONLY_CONTEXT,
294
- ...signal
295
- });
296
- return new Map(items.flatMap((item) => typeof item.id === "string" ? [[item.id, item]] : []));
297
- }
298
- /**
299
- * One submission as its row: who sent it, which form and what it opens with,
300
- * and when. `form` is absent where the reader may not read it, and every value
301
- * comes from a field the read answered, so a field kept from the reader is a
302
- * part of the row left out, never guessed.
303
- */
304
- function submissionRow(submission, form, allHref) {
305
- const id = typeof submission.id === "string" ? submission.id : "";
306
- const answers = recordOf(submission.data);
307
- const fields = fieldsOf(form?.fields);
308
- const titleField = [fields.find(isNameField), fields.find((field) => field.type === "email")].find((field) => field !== void 0 && firstLine(answers[field.name]) !== void 0);
309
- const title = titleField === void 0 ? void 0 : firstLine(answers[titleField.name]);
310
- const excerpt = fields.filter((field) => TEXT_TYPES.has(field.type) && field !== titleField).map((field) => firstLine(answers[field.name])).find((line) => line !== void 0);
311
- const formName = firstLine(form?.name);
312
- const at = instantOf(submission.submittedAt) ?? instantOf(submission.createdAt);
313
- return {
314
- id,
315
- title: title ?? "Submission",
316
- ...formName === void 0 ? {} : { form: formName },
317
- ...excerpt === void 0 ? {} : { excerpt },
318
- ...at === void 0 ? {} : { at },
319
- href: `${allHref}/${encodeURIComponent(id)}`
320
- };
321
- }
322
- /** Whether a form field asks for the submitter's name, by its name or its label. */
323
- function isNameField(field) {
324
- return field.type === "text" && (NAME_KEYS.has(lettersOf(field.name)) || NAME_KEYS.has(lettersOf(field.label ?? "")));
325
- }
326
- /** A name or label as its letters alone, lower case: "Full name" is "fullname". */
327
- function lettersOf(text) {
328
- return text.toLowerCase().replace(/[^a-z]/g, "");
329
- }
330
- /**
331
- * The first line of a text answer, trimmed and held to `MAX_ROW_TEXT`
332
- * characters, or `undefined` for none.
333
- */
334
- function firstLine(value) {
335
- if (typeof value !== "string") return void 0;
336
- const line = value.split(/\r?\n/).map((part) => part.trim()).find((part) => part !== "");
337
- return line === void 0 ? void 0 : clipped(line);
338
- }
339
- /**
340
- * `text` cut to its first `MAX_ROW_TEXT` characters with an ellipsis, where it
341
- * is longer. Counted in whole characters, so an emoji is never split in two.
342
- */
343
- function clipped(text) {
344
- const characters = Array.from(text);
345
- if (characters.length <= MAX_ROW_TEXT) return text;
346
- return `${characters.slice(0, MAX_ROW_TEXT).join("").trimEnd()}…`;
347
- }
348
- /** A relationship's id, whether the read answered the id or the related row. */
349
- function formIdOf(value) {
350
- if (typeof value === "string") return value;
351
- if (typeof value === "object" && value !== null && "id" in value) {
352
- const { id } = value;
353
- return typeof id === "string" ? id : void 0;
354
- }
355
- }
356
- /** A JSON column's value, which some dialects answer as its text. */
357
- function parsedJson(value) {
358
- if (typeof value !== "string") return value;
359
- try {
360
- return JSON.parse(value);
361
- } catch {
362
- return;
363
- }
364
- }
365
- /** A submission's answers, by field name. */
366
- function recordOf(value) {
367
- const parsed = parsedJson(value);
368
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {};
369
- }
370
- /** A form's fields, as far as a row reads them; any other entry is skipped. */
371
- function fieldsOf(value) {
372
- const parsed = parsedJson(value);
373
- if (!Array.isArray(parsed)) return [];
374
- return parsed.flatMap((entry) => {
375
- if (typeof entry !== "object" || entry === null) return [];
376
- const { type, name, label } = entry;
377
- if (typeof type !== "string" || typeof name !== "string") return [];
378
- return [{
379
- type,
380
- name,
381
- ...typeof label === "string" ? { label } : {}
382
- }];
383
- });
384
- }
385
- /** A date as an ISO string, or `undefined` when it is not one. */
386
- function instantOf(value) {
387
- if (typeof value !== "string" && typeof value !== "number") return void 0;
388
- const at = new Date(value);
389
- return Number.isNaN(at.getTime()) ? void 0 : at.toISOString();
390
- }
391
-
392
- // src/types.ts
393
- /** Every built-in form field type, as a value list (the single source shared
394
- * by the field-type guard and the config validator). */
395
- const BUILT_IN_FORM_FIELD_TYPES = [
396
- "text",
397
- "email",
398
- "number",
399
- "phone",
400
- "url",
401
- "textarea",
402
- "select",
403
- "checkbox",
404
- "radio",
405
- "file",
406
- "date",
407
- "time",
408
- "hidden"
409
- ];
410
- const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
411
- /**
412
- * Narrow an `AnyFormField` to the closed built-in `FormField` union. Returns
413
- * `false` for a plugin-contributed field, so callers handle it generically
414
- * before switching on the built-in discriminant.
415
- */
416
- function isKnownFormField(field) {
417
- return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
418
- }
419
- /**
420
- * Check if a value is a FormField.
421
- */
422
- function isFormField(value) {
423
- return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
424
- }
425
- /**
426
- * Check if a FormField is a specific type.
427
- */
428
- function isFormFieldType(field, type) {
429
- return field.type === type;
430
- }
431
- /**
432
- * Type guard for text field.
433
- */
434
- function isTextFormField(field) {
435
- return field.type === "text";
436
- }
437
- /**
438
- * Type guard for email field.
439
- */
440
- function isEmailFormField(field) {
441
- return field.type === "email";
442
- }
443
- /**
444
- * Type guard for select field.
445
- */
446
- function isSelectFormField(field) {
447
- return field.type === "select";
448
- }
449
- /**
450
- * Type guard for checkbox field.
451
- */
452
- function isCheckboxFormField(field) {
453
- return field.type === "checkbox";
454
- }
455
- /**
456
- * Type guard for file field.
457
- */
458
- function isFileFormField(field) {
459
- return field.type === "file";
460
- }
461
-
462
- // src/utils/enforced-validation.ts
463
- /**
464
- * `message` appears on every row because it is honoured everywhere: each
465
- * generator passes `validation.errorMessage` to `applyRequired`, so even a type
466
- * with no bounds of its own uses it for the required failure.
467
- *
468
- * Typed as a total `Record` rather than a partial one, so adding a field type
469
- * without deciding what it enforces is a compile error instead of a type that
470
- * silently offers nothing.
471
- */
472
- const ENFORCED_VALIDATION_RULES = {
473
- text: [
474
- "minLength",
475
- "maxLength",
476
- "pattern",
477
- "message"
478
- ],
479
- email: ["pattern", "message"],
480
- number: [
481
- "min",
482
- "max",
483
- "message"
484
- ],
485
- phone: ["pattern", "message"],
486
- url: ["pattern", "message"],
487
- textarea: [
488
- "minLength",
489
- "maxLength",
490
- "message"
491
- ],
492
- select: ["message"],
493
- checkbox: ["message"],
494
- radio: ["message"],
495
- file: ["message"],
496
- date: ["message"],
497
- time: ["message"],
498
- hidden: ["message"]
499
- };
500
- /**
501
- * The rules a field of this type both accepts and has enforced.
502
- *
503
- * A plugin-contributed type has no row, and correctly gets nothing: this
504
- * runtime's generator has no clause for it, so every rule would be inert.
505
- */
506
- function enforcedValidationRules(type) {
507
- return ENFORCED_VALIDATION_RULES[type] ?? [];
508
- }
509
-
510
- // src/config/removed-options.ts
511
- /** Forms already told, so a config passing through both entry points hears it once. */
512
- const warned = /* @__PURE__ */ new Set();
513
- /**
514
- * Say so when a form config still sets `notifications.webhooks`.
515
- *
516
- * The plugin sends no webhooks of its own: a submission reaches other systems
517
- * through Nextly's `form.submission.created` webhook, so the option sends
518
- * nothing and the config is told what to use instead.
519
- */
520
- function warnIfWebhooksConfigured(config) {
521
- const notifications = config.notifications;
522
- if (typeof notifications !== "object" || notifications === null) return;
523
- if (!("webhooks" in notifications) || warned.has(config.slug)) return;
524
- warned.add(config.slug);
525
- 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`);
526
- }
527
-
528
- // src/config/defaults.ts
529
- /**
530
- * Default form settings.
531
- */
532
- const DEFAULT_FORM_SETTINGS = {
533
- submitButtonText: "Submit",
534
- confirmationType: "message",
535
- successMessage: "Thank you for your submission!",
536
- allowMultipleSubmissions: true
537
- };
538
- /**
539
- * Default notification settings.
540
- */
541
- const DEFAULT_NOTIFICATION_SETTINGS = {
542
- enabled: true,
543
- replyTo: true
544
- };
545
- /**
546
- * Converts a slug to title case.
547
- *
548
- * @param str - The slug string to convert
549
- * @returns Title case string
550
- *
551
- * @example
552
- * ```typescript
553
- * toTitleCase('contact-form'); // 'Contact Form'
554
- * toTitleCase('newsletter_signup'); // 'Newsletter Signup'
555
- * ```
556
- */
557
- function toTitleCase(str) {
558
- return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
559
- }
560
- /**
561
- * Generates a plural form from a singular label.
562
- * Handles common English pluralization rules.
563
- *
564
- * @param singular - The singular form
565
- * @returns The plural form
566
- *
567
- * @example
568
- * ```typescript
569
- * pluralize('Form'); // 'Forms'
570
- * pluralize('Category'); // 'Categories'
571
- * pluralize('Quiz'); // 'Quizzes'
572
- * ```
573
- */
574
- function pluralize(singular) {
575
- if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) return singular.slice(0, -1) + "ies";
576
- if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) return singular + "es";
577
- return singular + "s";
578
- }
579
- /**
580
- * Apply default values to form configuration.
581
- *
582
- * This function takes a partial form configuration and returns a complete
583
- * configuration with all default values applied. User-specified values
584
- * are preserved and take precedence over defaults.
585
- *
586
- * @param config - Form configuration (may be partial)
587
- * @returns Configuration with defaults applied
588
- *
589
- * @example
590
- * ```typescript
591
- * import { applyFormDefaults } from '@nextly/plugin-form-builder';
592
- *
593
- * const config = {
594
- * slug: 'contact-form',
595
- * fields: [
596
- * { type: 'text', name: 'name', label: 'Name' },
597
- * { type: 'email', name: 'email', label: 'Email' },
598
- * ],
599
- * };
600
- *
601
- * const result = applyFormDefaults(config);
602
- *
603
- * // Result:
604
- * // {
605
- * // slug: 'contact-form',
606
- * // labels: { singular: 'Contact Form', plural: 'Contact Forms' },
607
- * // fields: [...],
608
- * // settings: {
609
- * // submitButtonText: 'Submit',
610
- * // confirmationType: 'message',
611
- * // successMessage: 'Thank you for your submission!',
612
- * // allowMultipleSubmissions: true,
613
- * // },
614
- * // access: {
615
- * // submit: () => true,
616
- * // read: ({ req }) => !!req.user,
617
- * // },
618
- * // }
619
- * ```
620
- */
621
- function applyFormDefaults(config) {
622
- warnIfWebhooksConfigured(config);
623
- const singularLabel = config.labels?.singular || toTitleCase(config.slug);
624
- return {
625
- ...config,
626
- labels: {
627
- singular: singularLabel,
628
- plural: config.labels?.plural || pluralize(singularLabel)
629
- },
630
- settings: {
631
- submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
632
- confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
633
- successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
634
- allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
635
- redirectUrl: config.settings?.redirectUrl,
636
- redirectRelation: config.settings?.redirectRelation,
637
- honeypotEnabled: config.settings?.honeypotEnabled,
638
- captchaEnabled: config.settings?.captchaEnabled,
639
- captchaSiteKey: config.settings?.captchaSiteKey
640
- },
641
- notifications: config.notifications ? {
642
- enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
643
- recipients: config.notifications.recipients || [],
644
- subject: config.notifications.subject || `New submission for ${singularLabel}`,
645
- replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
646
- template: config.notifications.template
647
- } : void 0,
648
- access: {
649
- submit: config.access?.submit || (() => true),
650
- read: config.access?.read || (({ req }) => !!req.user)
651
- }
652
- };
653
- }
654
- /**
655
- * Apply default values to individual form field.
656
- *
657
- * This is a utility function for applying defaults to a single field,
658
- * useful when processing fields individually (e.g., in the Schema Builder).
659
- *
660
- * @param field - The field to apply defaults to
661
- * @returns Field with defaults applied
662
- *
663
- * @example
664
- * ```typescript
665
- * import { applyFieldDefaults } from '@nextly/plugin-form-builder';
666
- *
667
- * const field = { type: 'text', name: 'firstName' };
668
- * const fieldWithDefaults = applyFieldDefaults(field);
669
- * // { type: 'text', name: 'firstName', label: 'First Name', required: false }
670
- * ```
671
- */
672
- function applyFieldDefaults(field) {
673
- const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
674
- return {
675
- ...field,
676
- label,
677
- required: field.required ?? false
678
- };
679
- }
680
- /**
681
- * Creates a complete form config with minimal input.
682
- *
683
- * This is a convenience function for quickly creating form configs
684
- * with sensible defaults. Only the slug and fields are required.
685
- *
686
- * @param slug - The form slug
687
- * @param fields - Array of form fields
688
- * @param options - Optional additional configuration
689
- * @returns Complete form configuration
690
- *
691
- * @example
692
- * ```typescript
693
- * import { createFormConfig } from '@nextly/plugin-form-builder';
694
- *
695
- * const config = createFormConfig('contact', [
696
- * { type: 'text', name: 'name', label: 'Name', required: true },
697
- * { type: 'email', name: 'email', label: 'Email', required: true },
698
- * { type: 'textarea', name: 'message', label: 'Message' },
699
- * ]);
700
- * ```
701
- */
702
- function createFormConfig(slug, fields, options) {
703
- return applyFormDefaults({
704
- slug,
705
- fields,
706
- ...options
707
- });
708
- }
709
-
710
- // src/utils/form-settings.ts
711
- /**
712
- * The ONE reader for stored form settings. Settings were historically
713
- * written in two shapes (the builder's old context shape and the collection
714
- * group's declared shape), so every consumer — the builder UI and the
715
- * submit handler alike — normalizes through here instead of trusting the
716
- * stored keys.
717
- */
718
- function asOptionalBoolean(value) {
719
- return typeof value === "boolean" ? value : void 0;
720
- }
721
- function asOptionalString(value) {
722
- return typeof value === "string" && value !== "" ? value : void 0;
723
- }
724
- /**
725
- * Normalize a stored settings value into the canonical {@link FormSettings}.
726
- *
727
- * Legacy keys are migrated on read (no data is lost, nothing is rewritten
728
- * until the form is next saved):
729
- * - `confirmationMessage` → `successMessage` + `confirmationType: "message"`
730
- * - nested `captcha: { enabled, siteKey }` → flat `captchaEnabled`/`captchaSiteKey`
731
- *
732
- * Keys with no consumer (`showResetButton`, `resetButtonText`,
733
- * `storeSubmissions`, `submissionLimit`) are dropped: a setting that does
734
- * nothing must not survive into the UI.
735
- */
736
- function normalizeFormSettings(raw) {
737
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...DEFAULT_FORM_SETTINGS };
738
- const source = raw;
739
- const legacyMessage = asOptionalString(source.confirmationMessage);
740
- const legacyCaptcha = source.captcha && typeof source.captcha === "object" ? source.captcha : void 0;
741
- const confirmationType = source.confirmationType === "redirect" || source.confirmationType === "relationship" ? source.confirmationType : "message";
742
- return {
743
- submitButtonText: asOptionalString(source.submitButtonText) ?? DEFAULT_FORM_SETTINGS.submitButtonText,
744
- confirmationType,
745
- successMessage: asOptionalString(source.successMessage) ?? legacyMessage ?? DEFAULT_FORM_SETTINGS.successMessage,
746
- redirectUrl: asOptionalString(source.redirectUrl),
747
- redirectPage: source.redirectPage ?? void 0,
748
- redirectRelation: source.redirectRelation && typeof source.redirectRelation === "object" ? source.redirectRelation : void 0,
749
- allowMultipleSubmissions: asOptionalBoolean(source.allowMultipleSubmissions) ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
750
- honeypotEnabled: asOptionalBoolean(source.honeypotEnabled),
751
- captchaEnabled: asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled),
752
- captchaSiteKey: asOptionalString(source.captchaSiteKey) ?? asOptionalString(legacyCaptcha?.siteKey)
753
- };
754
- }
755
-
756
- // src/utils/evaluate-conditions.ts
757
- /**
758
- * Checks if a value is considered "empty".
759
- * Empty values: undefined, null, empty string, empty array.
760
- */
761
- function isEmptyValue(value) {
762
- if (value === void 0 || value === null) return true;
763
- if (typeof value === "string" && value.trim() === "") return true;
764
- if (Array.isArray(value) && value.length === 0) return true;
765
- return false;
766
- }
767
- /**
768
- * Converts a value to a number for numeric comparisons.
769
- * Returns NaN if conversion is not possible.
770
- */
771
- function toNumber(value) {
772
- if (typeof value === "number") return value;
773
- if (typeof value === "string") {
774
- const trimmed = value.trim();
775
- if (trimmed === "") return NaN;
776
- return Number(trimmed);
777
- }
778
- return NaN;
779
- }
780
- /**
781
- * Converts a value to a string for string comparisons.
782
- */
783
- function toString(value) {
784
- if (value === void 0 || value === null) return "";
785
- if (typeof value === "string") return value;
786
- if (Array.isArray(value)) return value.join(", ");
787
- if (typeof value === "object") return JSON.stringify(value);
788
- return String(value);
789
- }
790
- /**
791
- * Compares two values using the specified operator.
792
- *
793
- * @param operator - The comparison operator to use
794
- * @param fieldValue - The actual value from the form data
795
- * @param conditionValue - The expected value from the condition
796
- * @returns Whether the comparison is true
797
- */
798
- function compareValues(operator, fieldValue, conditionValue) {
799
- switch (operator) {
800
- case "equals":
801
- if (Array.isArray(fieldValue)) {
802
- if (Array.isArray(conditionValue)) {
803
- if (fieldValue.length !== conditionValue.length) return false;
804
- return fieldValue.every((v, i) => v === conditionValue[i]);
805
- }
806
- return fieldValue.includes(conditionValue);
807
- }
808
- return toString(fieldValue) === toString(conditionValue);
809
- case "notEquals":
810
- if (Array.isArray(fieldValue)) {
811
- if (Array.isArray(conditionValue)) {
812
- if (fieldValue.length !== conditionValue.length) return true;
813
- return !fieldValue.every((v, i) => v === conditionValue[i]);
814
- }
815
- return !fieldValue.includes(conditionValue);
816
- }
817
- return toString(fieldValue) !== toString(conditionValue);
818
- case "contains": {
819
- const fieldStr = toString(fieldValue).toLowerCase();
820
- const conditionStr = toString(conditionValue).toLowerCase();
821
- if (Array.isArray(fieldValue)) return fieldValue.some((v) => toString(v).toLowerCase().includes(conditionStr));
822
- return fieldStr.includes(conditionStr);
823
- }
824
- case "isEmpty": return isEmptyValue(fieldValue);
825
- case "isNotEmpty": return !isEmptyValue(fieldValue);
826
- case "greaterThan": {
827
- const fieldNum = toNumber(fieldValue);
828
- const conditionNum = toNumber(conditionValue);
829
- if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum > conditionNum;
830
- return toString(fieldValue) > toString(conditionValue);
831
- }
832
- case "lessThan": {
833
- const fieldNum = toNumber(fieldValue);
834
- const conditionNum = toNumber(conditionValue);
835
- if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum < conditionNum;
836
- return toString(fieldValue) < toString(conditionValue);
837
- }
838
- default:
839
- console.warn(`[evaluateConditions] Unknown comparison operator: ${String(operator)}`);
840
- return false;
841
- }
842
- }
843
- /**
844
- * Evaluates a single condition against the form data.
845
- *
846
- * Exported for consumers that gate on one condition rather than a full
847
- * conditional-logic block — notification send-conditions use exactly this.
848
- *
849
- * @param condition - The condition to evaluate
850
- * @param formData - The current form data
851
- * @returns Whether the condition is met
852
- */
853
- function evaluateSingleCondition(condition, formData) {
854
- const { field, comparison, value: conditionValue } = condition;
855
- return compareValues(comparison, getNestedValue(formData, field), conditionValue);
856
- }
857
- /**
858
- * Gets a nested value from an object using dot notation.
859
- *
860
- * @param obj - The object to get the value from
861
- * @param path - The path to the value (e.g., "address.city")
862
- * @returns The value at the path, or undefined if not found
863
- */
864
- function getNestedValue(obj, path) {
865
- if (!path) return;
866
- if (!path.includes(".")) return obj[path];
867
- const parts = path.split(".");
868
- let current = obj;
869
- for (const part of parts) {
870
- if (current === void 0 || current === null) return;
871
- if (typeof current !== "object") return;
872
- current = current[part];
873
- }
874
- return current;
875
- }
876
- /**
877
- * Evaluates conditional logic to determine field visibility.
878
- *
879
- * This is the main entry point for conditional logic evaluation.
880
- * It evaluates all conditions according to the logical operator (AND/OR)
881
- * and returns whether the field should be visible based on the action
882
- * (show/hide).
883
- *
884
- * @param logic - The conditional logic configuration
885
- * @param formData - The current form data (field values)
886
- * @returns Whether the field should be visible
887
- *
888
- * @example
889
- * ```typescript
890
- * // Show field when country is US AND age is over 18
891
- * const logic: ConditionalLogic = {
892
- * enabled: true,
893
- * action: 'show',
894
- * operator: 'AND',
895
- * conditions: [
896
- * { field: 'country', comparison: 'equals', value: 'US' },
897
- * { field: 'age', comparison: 'greaterThan', value: 18 },
898
- * ],
899
- * };
900
- *
901
- * evaluateConditions(logic, { country: 'US', age: 25 }); // true
902
- * evaluateConditions(logic, { country: 'US', age: 15 }); // false
903
- * evaluateConditions(logic, { country: 'CA', age: 25 }); // false
904
- *
905
- * // Hide field when status is "completed"
906
- * const hideLogic: ConditionalLogic = {
907
- * enabled: true,
908
- * action: 'hide',
909
- * operator: 'AND',
910
- * conditions: [
911
- * { field: 'status', comparison: 'equals', value: 'completed' },
912
- * ],
913
- * };
914
- *
915
- * evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
916
- * evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
917
- * ```
918
- *
919
- * @example
920
- * ```typescript
921
- * // Show field when either condition is met (OR)
922
- * const orLogic: ConditionalLogic = {
923
- * enabled: true,
924
- * action: 'show',
925
- * operator: 'OR',
926
- * conditions: [
927
- * { field: 'role', comparison: 'equals', value: 'admin' },
928
- * { field: 'role', comparison: 'equals', value: 'editor' },
929
- * ],
930
- * };
931
- *
932
- * evaluateConditions(orLogic, { role: 'admin' }); // true
933
- * evaluateConditions(orLogic, { role: 'editor' }); // true
934
- * evaluateConditions(orLogic, { role: 'viewer' }); // false
935
- * ```
936
- */
937
- function evaluateConditions(logic, formData) {
938
- if (!logic || !logic.enabled) return true;
939
- const { action, operator, conditions } = logic;
940
- if (!conditions || conditions.length === 0) return true;
941
- const results = conditions.map((condition) => evaluateSingleCondition(condition, formData));
942
- let conditionsMet;
943
- if (operator === "OR") conditionsMet = results.some((result) => result);
944
- else conditionsMet = results.every((result) => result);
945
- if (action === "hide") return !conditionsMet;
946
- return conditionsMet;
947
- }
948
- /**
949
- * Checks if a comparison operator is valid.
950
- *
951
- * @param operator - The operator to check
952
- * @returns Whether the operator is valid
953
- */
954
- function isValidComparisonOperator(operator) {
955
- return [
956
- "equals",
957
- "notEquals",
958
- "contains",
959
- "isEmpty",
960
- "isNotEmpty",
961
- "greaterThan",
962
- "lessThan"
963
- ].includes(operator);
964
- }
965
- /**
966
- * Gets the list of supported comparison operators.
967
- *
968
- * @returns Array of supported operator names
969
- */
970
- function getSupportedComparisonOperators() {
971
- return [
972
- "equals",
973
- "notEquals",
974
- "contains",
975
- "isEmpty",
976
- "isNotEmpty",
977
- "greaterThan",
978
- "lessThan"
979
- ];
980
- }
981
-
982
- // src/utils/export-formats.ts
983
- /** UTF-8 BOM character sequence */
984
- const UTF8_BOM = "";
985
- /**
986
- * Stringify a value safely, falling back to JSON for plain objects so we
987
- * never emit the default `[object Object]` representation.
988
- */
989
- function safeString(value) {
990
- if (typeof value === "object" && value !== null) return JSON.stringify(value);
991
- return String(value);
992
- }
993
- /**
994
- * Format a field value for export based on field type.
995
- *
996
- * @param value - The raw value from submission data
997
- * @param field - The field definition (optional, for type-specific formatting)
998
- * @returns Formatted string representation of the value
999
- *
1000
- * @example
1001
- * ```typescript
1002
- * formatExportValue(true, { type: 'checkbox' }); // "Yes"
1003
- * formatExportValue(['a', 'b'], { type: 'select' }); // "a, b"
1004
- * formatExportValue(new Date(), { type: 'date' }); // "2024-01-15"
1005
- * ```
1006
- */
1007
- function formatExportValue(value, field) {
1008
- if (value === null || value === void 0) return "";
1009
- if (field) switch (field.type) {
1010
- case "checkbox": return value ? "Yes" : "No";
1011
- case "select":
1012
- if (Array.isArray(value)) return value.join(", ");
1013
- return safeString(value);
1014
- case "file":
1015
- if (Array.isArray(value)) return value.map((v) => safeString(v)).join(", ");
1016
- return safeString(value);
1017
- case "date":
1018
- if (value instanceof Date) return value.toISOString().split("T")[0];
1019
- if (typeof value === "string") {
1020
- const parsed = new Date(value);
1021
- if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
1022
- }
1023
- return safeString(value);
1024
- case "time": return safeString(value);
1025
- case "hidden": return safeString(value);
1026
- }
1027
- if (Array.isArray(value)) return value.join(", ");
1028
- if (typeof value === "boolean") return value ? "Yes" : "No";
1029
- if (value instanceof Date) return value.toISOString();
1030
- return safeString(value);
1031
- }
1032
- /**
1033
- * Format a date for export.
1034
- *
1035
- * @param date - Date value (Date object or string)
1036
- * @param format - Output format ("iso" or "locale")
1037
- * @returns Formatted date string
1038
- */
1039
- function formatDate(date, format = "iso") {
1040
- if (!date) return "";
1041
- const d = typeof date === "string" ? new Date(date) : date;
1042
- if (isNaN(d.getTime())) return "";
1043
- return format === "iso" ? d.toISOString() : d.toLocaleString();
1044
- }
1045
- /**
1046
- * Escape a value for CSV format (RFC 4180 compliant).
1047
- *
1048
- * Rules:
1049
- * - Fields containing comma, double-quote, or newline must be quoted
1050
- * - Double-quotes within fields must be escaped with another double-quote
1051
- *
1052
- * @param value - The value to escape
1053
- * @param delimiter - The delimiter being used
1054
- * @returns Properly escaped CSV value
1055
- */
1056
- function escapeCSVValue(value, delimiter = ",") {
1057
- const escapedFormula = /^[=+\-@\t\r]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
1058
- if (!(escapedFormula.includes(delimiter) || escapedFormula.includes("\"") || escapedFormula.includes("\n") || escapedFormula.includes("\r"))) return escapedFormula;
1059
- return `"${escapedFormula.replace(/"/g, "\"\"")}"`;
1060
- }
1061
- /**
1062
- * Export submissions to CSV format.
1063
- *
1064
- * Generates an RFC 4180 compliant CSV string with:
1065
- * - Form field values as columns (in form definition order)
1066
- * - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
1067
- * - Proper escaping of special characters
1068
- * - Optional UTF-8 BOM for Excel compatibility
1069
- *
1070
- * @param submissions - Array of submission documents to export
1071
- * @param form - Form document containing field definitions
1072
- * @param options - Export options
1073
- * @returns CSV formatted string
1074
- *
1075
- * @example
1076
- * ```typescript
1077
- * const csv = exportToCSV(submissions, form, {
1078
- * includeMetadata: true,
1079
- * includeBOM: true,
1080
- * });
1081
- *
1082
- * // Download in browser
1083
- * downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
1084
- * ```
1085
- */
1086
- function exportToCSV(submissions, form, options = {}) {
1087
- const { includeMetadata = true, delimiter = ",", includeBOM = true, dateFormat = "iso" } = options;
1088
- const exportableFields = form.fields;
1089
- const headers = [];
1090
- for (const field of exportableFields) headers.push(field.label);
1091
- if (includeMetadata) headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
1092
- const rows = [];
1093
- for (const submission of submissions) {
1094
- const row = [];
1095
- const data = submission.data;
1096
- for (const field of exportableFields) {
1097
- const value = data[field.name];
1098
- const formatted = formatExportValue(value, field);
1099
- row.push(escapeCSVValue(formatted, delimiter));
1100
- }
1101
- if (includeMetadata) {
1102
- row.push(escapeCSVValue(submission.id, delimiter));
1103
- row.push(escapeCSVValue(submission.status, delimiter));
1104
- row.push(escapeCSVValue(formatDate(submission.submittedAt, dateFormat), delimiter));
1105
- row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
1106
- row.push(escapeCSVValue(submission.userAgent || "", delimiter));
1107
- }
1108
- rows.push(row);
1109
- }
1110
- const csvContent = [headers.map((h) => escapeCSVValue(h, delimiter)).join(delimiter), ...rows.map((row) => row.join(delimiter))].join("\r\n");
1111
- return includeBOM ? UTF8_BOM + csvContent : csvContent;
1112
- }
1113
- /**
1114
- * Export submissions to JSON format.
1115
- *
1116
- * Generates a structured JSON export with:
1117
- * - Export metadata (timestamp)
1118
- * - Optional form definition
1119
- * - Array of submissions with field values
1120
- * - Optional submission metadata (_id, _status, etc.)
1121
- *
1122
- * @param submissions - Array of submission documents to export
1123
- * @param form - Form document containing field definitions
1124
- * @param options - Export options
1125
- * @returns JSON formatted string
1126
- *
1127
- * @example
1128
- * ```typescript
1129
- * const json = exportToJSON(submissions, form, {
1130
- * includeMetadata: true,
1131
- * includeFormDefinition: true,
1132
- * indent: 2,
1133
- * });
1134
- *
1135
- * // Download in browser
1136
- * downloadFile(json, 'submissions.json', 'application/json');
1137
- * ```
1138
- */
1139
- function exportToJSON(submissions, form, options = {}) {
1140
- const { includeMetadata = true, includeFormDefinition = true, indent = 2 } = options;
1141
- const exportableFields = form.fields;
1142
- const formDefinition = includeFormDefinition ? {
1143
- id: form.id,
1144
- name: form.name,
1145
- slug: form.slug,
1146
- fields: exportableFields.map((f) => ({
1147
- name: f.name,
1148
- label: f.label,
1149
- type: f.type
1150
- }))
1151
- } : void 0;
1152
- const exportedSubmissions = submissions.map((submission) => {
1153
- const data = submission.data;
1154
- const fieldData = {};
1155
- for (const field of exportableFields) {
1156
- const value = data[field.name];
1157
- fieldData[field.name] = value;
1158
- }
1159
- if (includeMetadata) return {
1160
- ...fieldData,
1161
- _metadata: {
1162
- id: submission.id,
1163
- status: submission.status,
1164
- submittedAt: submission.submittedAt,
1165
- ipAddress: submission.ipAddress || null,
1166
- userAgent: submission.userAgent || null,
1167
- createdAt: submission.createdAt,
1168
- updatedAt: submission.updatedAt
1169
- }
1170
- };
1171
- return fieldData;
1172
- });
1173
- const exportData = {
1174
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
1175
- totalSubmissions: submissions.length,
1176
- submissions: exportedSubmissions
1177
- };
1178
- if (formDefinition) exportData.form = formDefinition;
1179
- return JSON.stringify(exportData, null, indent);
1180
- }
1181
- /**
1182
- * Trigger a file download in the browser.
1183
- *
1184
- * Creates a Blob from the content and triggers a download using a temporary
1185
- * anchor element. This function is browser-only and will throw in Node.js.
1186
- *
1187
- * @param content - File content as string
1188
- * @param filename - Name for the downloaded file
1189
- * @param mimeType - MIME type of the file
1190
- *
1191
- * @example
1192
- * ```typescript
1193
- * const csv = exportToCSV(submissions, form);
1194
- * downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
1195
- *
1196
- * const json = exportToJSON(submissions, form);
1197
- * downloadFile(json, 'submissions.json', 'application/json');
1198
- * ```
1199
- */
1200
- function downloadFile(content, filename, mimeType) {
1201
- if (typeof window === "undefined" || typeof document === "undefined") throw new Error("downloadFile is only available in browser environments");
1202
- const blob = new Blob([content], { type: mimeType });
1203
- const url = URL.createObjectURL(blob);
1204
- const link = document.createElement("a");
1205
- link.href = url;
1206
- link.download = filename;
1207
- document.body.appendChild(link);
1208
- link.click();
1209
- document.body.removeChild(link);
1210
- URL.revokeObjectURL(url);
1211
- }
1212
- /**
1213
- * Generate a filename for export.
1214
- *
1215
- * Creates a filename with form slug and current timestamp.
1216
- *
1217
- * @param formSlug - The form's slug
1218
- * @param format - Export format ('csv' or 'json')
1219
- * @returns Generated filename
1220
- *
1221
- * @example
1222
- * ```typescript
1223
- * generateExportFilename('contact-form', 'csv');
1224
- * // Returns: "contact-form-submissions-2024-01-15.csv"
1225
- * ```
1226
- */
1227
- function generateExportFilename(formSlug, format) {
1228
- return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
1229
- }
1230
- /**
1231
- * Export submissions and trigger browser download.
1232
- *
1233
- * Convenience function that combines export and download in one call.
1234
- * Browser-only.
1235
- *
1236
- * @param submissions - Array of submission documents
1237
- * @param form - Form document
1238
- * @param format - Export format ('csv' or 'json')
1239
- * @param options - Format-specific options
1240
- *
1241
- * @example
1242
- * ```typescript
1243
- * // In a React component
1244
- * const handleExport = (format: 'csv' | 'json') => {
1245
- * exportAndDownload(submissions, form, format);
1246
- * };
1247
- * ```
1248
- */
1249
- function exportAndDownload(submissions, form, format, options) {
1250
- const filename = generateExportFilename(form.slug, format);
1251
- if (format === "csv") downloadFile(exportToCSV(submissions, form, options), filename, "text/csv;charset=utf-8");
1252
- else downloadFile(exportToJSON(submissions, form, options), filename, "application/json");
1253
- }
1254
-
1255
- // src/utils/field-references.ts
1256
- /**
1257
- * The one spelling of a field reference this plugin recognises: `{{fieldName}}`
1258
- * with no spaces, and `\w` names.
1259
- *
1260
- * Exact, because the SEND path is exact — a value the admin accepts as a
1261
- * reference and the send path does not is delivered with its braces intact,
1262
- * straight to the mail provider. Both sides read this constant so the question
1263
- * has one answer.
1264
- */
1265
- const FIELD_REF_PATTERN = /^\{\{(\w+)\}\}$/;
1266
- /** The field named by a reference, or `null` when the value is not one. */
1267
- function parseFieldRef(value) {
1268
- if (!value) return null;
1269
- const match = value.match(FIELD_REF_PATTERN);
1270
- return match ? match[1] : null;
1271
- }
1272
- /**
1273
- * Match `{{ fieldName }}` interpolations (whitespace-tolerant), the syntax the
1274
- * notification send path resolves recipients and reply-to values with.
1275
- */
1276
- function referencesInTemplate(template, fieldName) {
1277
- return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`).test(template);
1278
- }
1279
- /** All references to `fieldName` across the form's fields and notifications. */
1280
- function findFieldReferences(fieldName, fields, notifications) {
1281
- const references = [];
1282
- for (const field of fields) {
1283
- if (field.name === fieldName) continue;
1284
- if (!field.conditionalLogic?.enabled) continue;
1285
- if ((field.conditionalLogic.conditions ?? []).some((condition) => condition.field === fieldName)) references.push({
1286
- kind: "condition",
1287
- label: field.label || field.name
1288
- });
1289
- }
1290
- for (const notification of notifications) {
1291
- if (!notification.enabled) continue;
1292
- const referencedByTemplate = [
1293
- notification.to,
1294
- ...notification.cc ?? [],
1295
- ...notification.bcc ?? [],
1296
- notification.replyTo
1297
- ].filter((value) => typeof value === "string").some((template) => referencesInTemplate(template, fieldName));
1298
- const referencedByCondition = notification.condition?.field === fieldName;
1299
- if (referencedByTemplate || referencedByCondition) references.push({
1300
- kind: "notification",
1301
- label: notification.name || "Notification"
1302
- });
1303
- }
1304
- return references;
1305
- }
1306
- /**
1307
- * Reference lists for every field in one pass, keyed by field name, so a
1308
- * per-card lookup is O(1) instead of re-walking fields and notifications for
1309
- * each card on every render.
1310
- */
1311
- function buildFieldReferenceMap(fields, notifications) {
1312
- const map = /* @__PURE__ */ new Map();
1313
- for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications));
1314
- return map;
1315
- }
1316
-
1317
- export { isKnownFormField as A, enforcedValidationRules as C, isFileFormField as D, isEmailFormField as E, SCHEMA_ONLY_CONTEXT as F, SCHEMA_ONLY_READ as I, documentReachability as L, isTextFormField as M, NEW_SUBMISSIONS_WIDGET_ID as N, isFormField as O, readNewSubmissions as P, parseRedirectReference as R, ENFORCED_VALIDATION_RULES as S, isCheckboxFormField as T, applyFormDefaults as _, exportToCSV as a, toTitleCase as b, generateExportFilename as c, getSupportedComparisonOperators as d, isValidComparisonOperator as f, applyFieldDefaults as g, DEFAULT_NOTIFICATION_SETTINGS as h, exportAndDownload as i, isSelectFormField as j, isFormFieldType as k, evaluateConditions as l, DEFAULT_FORM_SETTINGS as m, parseFieldRef as n, exportToJSON as o, normalizeFormSettings as p, downloadFile as r, formatExportValue as s, buildFieldReferenceMap as t, evaluateSingleCondition as u, createFormConfig as v, BUILT_IN_FORM_FIELD_TYPES as w, warnIfWebhooksConfigured as x, pluralize as y, pickedDocumentField as z };
1318
- //# sourceMappingURL=field-references-CtzGg51W.js.map