@nextlyhq/plugin-form-builder 0.0.2-alpha.68 → 0.0.2-alpha.69

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.
@@ -0,0 +1,1048 @@
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/types.ts
123
+ /** Every built-in form field type, as a value list (the single source shared
124
+ * by the field-type guard and the config validator). */
125
+ const BUILT_IN_FORM_FIELD_TYPES = [
126
+ "text",
127
+ "email",
128
+ "number",
129
+ "phone",
130
+ "url",
131
+ "textarea",
132
+ "select",
133
+ "checkbox",
134
+ "radio",
135
+ "file",
136
+ "date",
137
+ "time",
138
+ "hidden"
139
+ ];
140
+ const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
141
+ /**
142
+ * Narrow an `AnyFormField` to the closed built-in `FormField` union. Returns
143
+ * `false` for a plugin-contributed field, so callers handle it generically
144
+ * before switching on the built-in discriminant.
145
+ */
146
+ function isKnownFormField(field) {
147
+ return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
148
+ }
149
+ /**
150
+ * Check if a value is a FormField.
151
+ */
152
+ function isFormField(value) {
153
+ return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
154
+ }
155
+ /**
156
+ * Check if a FormField is a specific type.
157
+ */
158
+ function isFormFieldType(field, type) {
159
+ return field.type === type;
160
+ }
161
+ /**
162
+ * Type guard for text field.
163
+ */
164
+ function isTextFormField(field) {
165
+ return field.type === "text";
166
+ }
167
+ /**
168
+ * Type guard for email field.
169
+ */
170
+ function isEmailFormField(field) {
171
+ return field.type === "email";
172
+ }
173
+ /**
174
+ * Type guard for select field.
175
+ */
176
+ function isSelectFormField(field) {
177
+ return field.type === "select";
178
+ }
179
+ /**
180
+ * Type guard for checkbox field.
181
+ */
182
+ function isCheckboxFormField(field) {
183
+ return field.type === "checkbox";
184
+ }
185
+ /**
186
+ * Type guard for file field.
187
+ */
188
+ function isFileFormField(field) {
189
+ return field.type === "file";
190
+ }
191
+
192
+ // src/utils/enforced-validation.ts
193
+ /**
194
+ * `message` appears on every row because it is honoured everywhere: each
195
+ * generator passes `validation.errorMessage` to `applyRequired`, so even a type
196
+ * with no bounds of its own uses it for the required failure.
197
+ *
198
+ * Typed as a total `Record` rather than a partial one, so adding a field type
199
+ * without deciding what it enforces is a compile error instead of a type that
200
+ * silently offers nothing.
201
+ */
202
+ const ENFORCED_VALIDATION_RULES = {
203
+ text: [
204
+ "minLength",
205
+ "maxLength",
206
+ "pattern",
207
+ "message"
208
+ ],
209
+ email: ["pattern", "message"],
210
+ number: [
211
+ "min",
212
+ "max",
213
+ "message"
214
+ ],
215
+ phone: ["pattern", "message"],
216
+ url: ["pattern", "message"],
217
+ textarea: [
218
+ "minLength",
219
+ "maxLength",
220
+ "message"
221
+ ],
222
+ select: ["message"],
223
+ checkbox: ["message"],
224
+ radio: ["message"],
225
+ file: ["message"],
226
+ date: ["message"],
227
+ time: ["message"],
228
+ hidden: ["message"]
229
+ };
230
+ /**
231
+ * The rules a field of this type both accepts and has enforced.
232
+ *
233
+ * A plugin-contributed type has no row, and correctly gets nothing: this
234
+ * runtime's generator has no clause for it, so every rule would be inert.
235
+ */
236
+ function enforcedValidationRules(type) {
237
+ return ENFORCED_VALIDATION_RULES[type] ?? [];
238
+ }
239
+
240
+ // src/config/removed-options.ts
241
+ /** Forms already told, so a config passing through both entry points hears it once. */
242
+ const warned = /* @__PURE__ */ new Set();
243
+ /**
244
+ * Say so when a form config still sets `notifications.webhooks`.
245
+ *
246
+ * The plugin sends no webhooks of its own: a submission reaches other systems
247
+ * through Nextly's `form.submission.created` webhook, so the option sends
248
+ * nothing and the config is told what to use instead.
249
+ */
250
+ function warnIfWebhooksConfigured(config) {
251
+ const notifications = config.notifications;
252
+ if (typeof notifications !== "object" || notifications === null) return;
253
+ if (!("webhooks" in notifications) || warned.has(config.slug)) return;
254
+ warned.add(config.slug);
255
+ 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`);
256
+ }
257
+
258
+ // src/config/defaults.ts
259
+ /**
260
+ * Default form settings.
261
+ */
262
+ const DEFAULT_FORM_SETTINGS = {
263
+ submitButtonText: "Submit",
264
+ confirmationType: "message",
265
+ successMessage: "Thank you for your submission!",
266
+ allowMultipleSubmissions: true
267
+ };
268
+ /**
269
+ * Default notification settings.
270
+ */
271
+ const DEFAULT_NOTIFICATION_SETTINGS = {
272
+ enabled: true,
273
+ replyTo: true
274
+ };
275
+ /**
276
+ * Converts a slug to title case.
277
+ *
278
+ * @param str - The slug string to convert
279
+ * @returns Title case string
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * toTitleCase('contact-form'); // 'Contact Form'
284
+ * toTitleCase('newsletter_signup'); // 'Newsletter Signup'
285
+ * ```
286
+ */
287
+ function toTitleCase(str) {
288
+ return str.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
289
+ }
290
+ /**
291
+ * Generates a plural form from a singular label.
292
+ * Handles common English pluralization rules.
293
+ *
294
+ * @param singular - The singular form
295
+ * @returns The plural form
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * pluralize('Form'); // 'Forms'
300
+ * pluralize('Category'); // 'Categories'
301
+ * pluralize('Quiz'); // 'Quizzes'
302
+ * ```
303
+ */
304
+ function pluralize(singular) {
305
+ if (singular.endsWith("y") && !/[aeiou]y$/i.test(singular)) return singular.slice(0, -1) + "ies";
306
+ if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) return singular + "es";
307
+ return singular + "s";
308
+ }
309
+ /**
310
+ * Apply default values to form configuration.
311
+ *
312
+ * This function takes a partial form configuration and returns a complete
313
+ * configuration with all default values applied. User-specified values
314
+ * are preserved and take precedence over defaults.
315
+ *
316
+ * @param config - Form configuration (may be partial)
317
+ * @returns Configuration with defaults applied
318
+ *
319
+ * @example
320
+ * ```typescript
321
+ * import { applyFormDefaults } from '@nextly/plugin-form-builder';
322
+ *
323
+ * const config = {
324
+ * slug: 'contact-form',
325
+ * fields: [
326
+ * { type: 'text', name: 'name', label: 'Name' },
327
+ * { type: 'email', name: 'email', label: 'Email' },
328
+ * ],
329
+ * };
330
+ *
331
+ * const result = applyFormDefaults(config);
332
+ *
333
+ * // Result:
334
+ * // {
335
+ * // slug: 'contact-form',
336
+ * // labels: { singular: 'Contact Form', plural: 'Contact Forms' },
337
+ * // fields: [...],
338
+ * // settings: {
339
+ * // submitButtonText: 'Submit',
340
+ * // confirmationType: 'message',
341
+ * // successMessage: 'Thank you for your submission!',
342
+ * // allowMultipleSubmissions: true,
343
+ * // },
344
+ * // access: {
345
+ * // submit: () => true,
346
+ * // read: ({ req }) => !!req.user,
347
+ * // },
348
+ * // }
349
+ * ```
350
+ */
351
+ function applyFormDefaults(config) {
352
+ warnIfWebhooksConfigured(config);
353
+ const singularLabel = config.labels?.singular || toTitleCase(config.slug);
354
+ return {
355
+ ...config,
356
+ labels: {
357
+ singular: singularLabel,
358
+ plural: config.labels?.plural || pluralize(singularLabel)
359
+ },
360
+ settings: {
361
+ submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
362
+ confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
363
+ successMessage: config.settings?.successMessage || DEFAULT_FORM_SETTINGS.successMessage,
364
+ allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
365
+ redirectUrl: config.settings?.redirectUrl,
366
+ redirectRelation: config.settings?.redirectRelation,
367
+ honeypotEnabled: config.settings?.honeypotEnabled,
368
+ captchaEnabled: config.settings?.captchaEnabled,
369
+ captchaSiteKey: config.settings?.captchaSiteKey
370
+ },
371
+ notifications: config.notifications ? {
372
+ enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
373
+ recipients: config.notifications.recipients || [],
374
+ subject: config.notifications.subject || `New submission for ${singularLabel}`,
375
+ replyTo: config.notifications.replyTo ?? DEFAULT_NOTIFICATION_SETTINGS.replyTo,
376
+ template: config.notifications.template
377
+ } : void 0,
378
+ access: {
379
+ submit: config.access?.submit || (() => true),
380
+ read: config.access?.read || (({ req }) => !!req.user)
381
+ }
382
+ };
383
+ }
384
+ /**
385
+ * Apply default values to individual form field.
386
+ *
387
+ * This is a utility function for applying defaults to a single field,
388
+ * useful when processing fields individually (e.g., in the Schema Builder).
389
+ *
390
+ * @param field - The field to apply defaults to
391
+ * @returns Field with defaults applied
392
+ *
393
+ * @example
394
+ * ```typescript
395
+ * import { applyFieldDefaults } from '@nextly/plugin-form-builder';
396
+ *
397
+ * const field = { type: 'text', name: 'firstName' };
398
+ * const fieldWithDefaults = applyFieldDefaults(field);
399
+ * // { type: 'text', name: 'firstName', label: 'First Name', required: false }
400
+ * ```
401
+ */
402
+ function applyFieldDefaults(field) {
403
+ const label = field.label || (field.name ? toTitleCase(field.name.replace(/([A-Z])/g, " $1").trim()) : "");
404
+ return {
405
+ ...field,
406
+ label,
407
+ required: field.required ?? false
408
+ };
409
+ }
410
+ /**
411
+ * Creates a complete form config with minimal input.
412
+ *
413
+ * This is a convenience function for quickly creating form configs
414
+ * with sensible defaults. Only the slug and fields are required.
415
+ *
416
+ * @param slug - The form slug
417
+ * @param fields - Array of form fields
418
+ * @param options - Optional additional configuration
419
+ * @returns Complete form configuration
420
+ *
421
+ * @example
422
+ * ```typescript
423
+ * import { createFormConfig } from '@nextly/plugin-form-builder';
424
+ *
425
+ * const config = createFormConfig('contact', [
426
+ * { type: 'text', name: 'name', label: 'Name', required: true },
427
+ * { type: 'email', name: 'email', label: 'Email', required: true },
428
+ * { type: 'textarea', name: 'message', label: 'Message' },
429
+ * ]);
430
+ * ```
431
+ */
432
+ function createFormConfig(slug, fields, options) {
433
+ return applyFormDefaults({
434
+ slug,
435
+ fields,
436
+ ...options
437
+ });
438
+ }
439
+
440
+ // src/utils/form-settings.ts
441
+ /**
442
+ * The ONE reader for stored form settings. Settings were historically
443
+ * written in two shapes (the builder's old context shape and the collection
444
+ * group's declared shape), so every consumer — the builder UI and the
445
+ * submit handler alike — normalizes through here instead of trusting the
446
+ * stored keys.
447
+ */
448
+ function asOptionalBoolean(value) {
449
+ return typeof value === "boolean" ? value : void 0;
450
+ }
451
+ function asOptionalString(value) {
452
+ return typeof value === "string" && value !== "" ? value : void 0;
453
+ }
454
+ /**
455
+ * Normalize a stored settings value into the canonical {@link FormSettings}.
456
+ *
457
+ * Legacy keys are migrated on read (no data is lost, nothing is rewritten
458
+ * until the form is next saved):
459
+ * - `confirmationMessage` → `successMessage` + `confirmationType: "message"`
460
+ * - nested `captcha: { enabled, siteKey }` → flat `captchaEnabled`/`captchaSiteKey`
461
+ *
462
+ * Keys with no consumer (`showResetButton`, `resetButtonText`,
463
+ * `storeSubmissions`, `submissionLimit`) are dropped: a setting that does
464
+ * nothing must not survive into the UI.
465
+ */
466
+ function normalizeFormSettings(raw) {
467
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...DEFAULT_FORM_SETTINGS };
468
+ const source = raw;
469
+ const legacyMessage = asOptionalString(source.confirmationMessage);
470
+ const legacyCaptcha = source.captcha && typeof source.captcha === "object" ? source.captcha : void 0;
471
+ const confirmationType = source.confirmationType === "redirect" || source.confirmationType === "relationship" ? source.confirmationType : "message";
472
+ return {
473
+ submitButtonText: asOptionalString(source.submitButtonText) ?? DEFAULT_FORM_SETTINGS.submitButtonText,
474
+ confirmationType,
475
+ successMessage: asOptionalString(source.successMessage) ?? legacyMessage ?? DEFAULT_FORM_SETTINGS.successMessage,
476
+ redirectUrl: asOptionalString(source.redirectUrl),
477
+ redirectPage: source.redirectPage ?? void 0,
478
+ redirectRelation: source.redirectRelation && typeof source.redirectRelation === "object" ? source.redirectRelation : void 0,
479
+ allowMultipleSubmissions: asOptionalBoolean(source.allowMultipleSubmissions) ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
480
+ honeypotEnabled: asOptionalBoolean(source.honeypotEnabled),
481
+ captchaEnabled: asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled),
482
+ captchaSiteKey: asOptionalString(source.captchaSiteKey) ?? asOptionalString(legacyCaptcha?.siteKey)
483
+ };
484
+ }
485
+
486
+ // src/utils/evaluate-conditions.ts
487
+ /**
488
+ * Checks if a value is considered "empty".
489
+ * Empty values: undefined, null, empty string, empty array.
490
+ */
491
+ function isEmptyValue(value) {
492
+ if (value === void 0 || value === null) return true;
493
+ if (typeof value === "string" && value.trim() === "") return true;
494
+ if (Array.isArray(value) && value.length === 0) return true;
495
+ return false;
496
+ }
497
+ /**
498
+ * Converts a value to a number for numeric comparisons.
499
+ * Returns NaN if conversion is not possible.
500
+ */
501
+ function toNumber(value) {
502
+ if (typeof value === "number") return value;
503
+ if (typeof value === "string") {
504
+ const trimmed = value.trim();
505
+ if (trimmed === "") return NaN;
506
+ return Number(trimmed);
507
+ }
508
+ return NaN;
509
+ }
510
+ /**
511
+ * Converts a value to a string for string comparisons.
512
+ */
513
+ function toString(value) {
514
+ if (value === void 0 || value === null) return "";
515
+ if (typeof value === "string") return value;
516
+ if (Array.isArray(value)) return value.join(", ");
517
+ if (typeof value === "object") return JSON.stringify(value);
518
+ return String(value);
519
+ }
520
+ /**
521
+ * Compares two values using the specified operator.
522
+ *
523
+ * @param operator - The comparison operator to use
524
+ * @param fieldValue - The actual value from the form data
525
+ * @param conditionValue - The expected value from the condition
526
+ * @returns Whether the comparison is true
527
+ */
528
+ function compareValues(operator, fieldValue, conditionValue) {
529
+ switch (operator) {
530
+ case "equals":
531
+ if (Array.isArray(fieldValue)) {
532
+ if (Array.isArray(conditionValue)) {
533
+ if (fieldValue.length !== conditionValue.length) return false;
534
+ return fieldValue.every((v, i) => v === conditionValue[i]);
535
+ }
536
+ return fieldValue.includes(conditionValue);
537
+ }
538
+ return toString(fieldValue) === toString(conditionValue);
539
+ case "notEquals":
540
+ if (Array.isArray(fieldValue)) {
541
+ if (Array.isArray(conditionValue)) {
542
+ if (fieldValue.length !== conditionValue.length) return true;
543
+ return !fieldValue.every((v, i) => v === conditionValue[i]);
544
+ }
545
+ return !fieldValue.includes(conditionValue);
546
+ }
547
+ return toString(fieldValue) !== toString(conditionValue);
548
+ case "contains": {
549
+ const fieldStr = toString(fieldValue).toLowerCase();
550
+ const conditionStr = toString(conditionValue).toLowerCase();
551
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => toString(v).toLowerCase().includes(conditionStr));
552
+ return fieldStr.includes(conditionStr);
553
+ }
554
+ case "isEmpty": return isEmptyValue(fieldValue);
555
+ case "isNotEmpty": return !isEmptyValue(fieldValue);
556
+ case "greaterThan": {
557
+ const fieldNum = toNumber(fieldValue);
558
+ const conditionNum = toNumber(conditionValue);
559
+ if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum > conditionNum;
560
+ return toString(fieldValue) > toString(conditionValue);
561
+ }
562
+ case "lessThan": {
563
+ const fieldNum = toNumber(fieldValue);
564
+ const conditionNum = toNumber(conditionValue);
565
+ if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum < conditionNum;
566
+ return toString(fieldValue) < toString(conditionValue);
567
+ }
568
+ default:
569
+ console.warn(`[evaluateConditions] Unknown comparison operator: ${String(operator)}`);
570
+ return false;
571
+ }
572
+ }
573
+ /**
574
+ * Evaluates a single condition against the form data.
575
+ *
576
+ * Exported for consumers that gate on one condition rather than a full
577
+ * conditional-logic block — notification send-conditions use exactly this.
578
+ *
579
+ * @param condition - The condition to evaluate
580
+ * @param formData - The current form data
581
+ * @returns Whether the condition is met
582
+ */
583
+ function evaluateSingleCondition(condition, formData) {
584
+ const { field, comparison, value: conditionValue } = condition;
585
+ return compareValues(comparison, getNestedValue(formData, field), conditionValue);
586
+ }
587
+ /**
588
+ * Gets a nested value from an object using dot notation.
589
+ *
590
+ * @param obj - The object to get the value from
591
+ * @param path - The path to the value (e.g., "address.city")
592
+ * @returns The value at the path, or undefined if not found
593
+ */
594
+ function getNestedValue(obj, path) {
595
+ if (!path) return;
596
+ if (!path.includes(".")) return obj[path];
597
+ const parts = path.split(".");
598
+ let current = obj;
599
+ for (const part of parts) {
600
+ if (current === void 0 || current === null) return;
601
+ if (typeof current !== "object") return;
602
+ current = current[part];
603
+ }
604
+ return current;
605
+ }
606
+ /**
607
+ * Evaluates conditional logic to determine field visibility.
608
+ *
609
+ * This is the main entry point for conditional logic evaluation.
610
+ * It evaluates all conditions according to the logical operator (AND/OR)
611
+ * and returns whether the field should be visible based on the action
612
+ * (show/hide).
613
+ *
614
+ * @param logic - The conditional logic configuration
615
+ * @param formData - The current form data (field values)
616
+ * @returns Whether the field should be visible
617
+ *
618
+ * @example
619
+ * ```typescript
620
+ * // Show field when country is US AND age is over 18
621
+ * const logic: ConditionalLogic = {
622
+ * enabled: true,
623
+ * action: 'show',
624
+ * operator: 'AND',
625
+ * conditions: [
626
+ * { field: 'country', comparison: 'equals', value: 'US' },
627
+ * { field: 'age', comparison: 'greaterThan', value: 18 },
628
+ * ],
629
+ * };
630
+ *
631
+ * evaluateConditions(logic, { country: 'US', age: 25 }); // true
632
+ * evaluateConditions(logic, { country: 'US', age: 15 }); // false
633
+ * evaluateConditions(logic, { country: 'CA', age: 25 }); // false
634
+ *
635
+ * // Hide field when status is "completed"
636
+ * const hideLogic: ConditionalLogic = {
637
+ * enabled: true,
638
+ * action: 'hide',
639
+ * operator: 'AND',
640
+ * conditions: [
641
+ * { field: 'status', comparison: 'equals', value: 'completed' },
642
+ * ],
643
+ * };
644
+ *
645
+ * evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
646
+ * evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
647
+ * ```
648
+ *
649
+ * @example
650
+ * ```typescript
651
+ * // Show field when either condition is met (OR)
652
+ * const orLogic: ConditionalLogic = {
653
+ * enabled: true,
654
+ * action: 'show',
655
+ * operator: 'OR',
656
+ * conditions: [
657
+ * { field: 'role', comparison: 'equals', value: 'admin' },
658
+ * { field: 'role', comparison: 'equals', value: 'editor' },
659
+ * ],
660
+ * };
661
+ *
662
+ * evaluateConditions(orLogic, { role: 'admin' }); // true
663
+ * evaluateConditions(orLogic, { role: 'editor' }); // true
664
+ * evaluateConditions(orLogic, { role: 'viewer' }); // false
665
+ * ```
666
+ */
667
+ function evaluateConditions(logic, formData) {
668
+ if (!logic || !logic.enabled) return true;
669
+ const { action, operator, conditions } = logic;
670
+ if (!conditions || conditions.length === 0) return true;
671
+ const results = conditions.map((condition) => evaluateSingleCondition(condition, formData));
672
+ let conditionsMet;
673
+ if (operator === "OR") conditionsMet = results.some((result) => result);
674
+ else conditionsMet = results.every((result) => result);
675
+ if (action === "hide") return !conditionsMet;
676
+ return conditionsMet;
677
+ }
678
+ /**
679
+ * Checks if a comparison operator is valid.
680
+ *
681
+ * @param operator - The operator to check
682
+ * @returns Whether the operator is valid
683
+ */
684
+ function isValidComparisonOperator(operator) {
685
+ return [
686
+ "equals",
687
+ "notEquals",
688
+ "contains",
689
+ "isEmpty",
690
+ "isNotEmpty",
691
+ "greaterThan",
692
+ "lessThan"
693
+ ].includes(operator);
694
+ }
695
+ /**
696
+ * Gets the list of supported comparison operators.
697
+ *
698
+ * @returns Array of supported operator names
699
+ */
700
+ function getSupportedComparisonOperators() {
701
+ return [
702
+ "equals",
703
+ "notEquals",
704
+ "contains",
705
+ "isEmpty",
706
+ "isNotEmpty",
707
+ "greaterThan",
708
+ "lessThan"
709
+ ];
710
+ }
711
+
712
+ // src/utils/export-formats.ts
713
+ /** UTF-8 BOM character sequence */
714
+ const UTF8_BOM = "";
715
+ /**
716
+ * Stringify a value safely, falling back to JSON for plain objects so we
717
+ * never emit the default `[object Object]` representation.
718
+ */
719
+ function safeString(value) {
720
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
721
+ return String(value);
722
+ }
723
+ /**
724
+ * Format a field value for export based on field type.
725
+ *
726
+ * @param value - The raw value from submission data
727
+ * @param field - The field definition (optional, for type-specific formatting)
728
+ * @returns Formatted string representation of the value
729
+ *
730
+ * @example
731
+ * ```typescript
732
+ * formatExportValue(true, { type: 'checkbox' }); // "Yes"
733
+ * formatExportValue(['a', 'b'], { type: 'select' }); // "a, b"
734
+ * formatExportValue(new Date(), { type: 'date' }); // "2024-01-15"
735
+ * ```
736
+ */
737
+ function formatExportValue(value, field) {
738
+ if (value === null || value === void 0) return "";
739
+ if (field) switch (field.type) {
740
+ case "checkbox": return value ? "Yes" : "No";
741
+ case "select":
742
+ if (Array.isArray(value)) return value.join(", ");
743
+ return safeString(value);
744
+ case "file":
745
+ if (Array.isArray(value)) return value.map((v) => safeString(v)).join(", ");
746
+ return safeString(value);
747
+ case "date":
748
+ if (value instanceof Date) return value.toISOString().split("T")[0];
749
+ if (typeof value === "string") {
750
+ const parsed = new Date(value);
751
+ if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
752
+ }
753
+ return safeString(value);
754
+ case "time": return safeString(value);
755
+ case "hidden": return safeString(value);
756
+ }
757
+ if (Array.isArray(value)) return value.join(", ");
758
+ if (typeof value === "boolean") return value ? "Yes" : "No";
759
+ if (value instanceof Date) return value.toISOString();
760
+ return safeString(value);
761
+ }
762
+ /**
763
+ * Format a date for export.
764
+ *
765
+ * @param date - Date value (Date object or string)
766
+ * @param format - Output format ("iso" or "locale")
767
+ * @returns Formatted date string
768
+ */
769
+ function formatDate(date, format = "iso") {
770
+ if (!date) return "";
771
+ const d = typeof date === "string" ? new Date(date) : date;
772
+ if (isNaN(d.getTime())) return "";
773
+ return format === "iso" ? d.toISOString() : d.toLocaleString();
774
+ }
775
+ /**
776
+ * Escape a value for CSV format (RFC 4180 compliant).
777
+ *
778
+ * Rules:
779
+ * - Fields containing comma, double-quote, or newline must be quoted
780
+ * - Double-quotes within fields must be escaped with another double-quote
781
+ *
782
+ * @param value - The value to escape
783
+ * @param delimiter - The delimiter being used
784
+ * @returns Properly escaped CSV value
785
+ */
786
+ function escapeCSVValue(value, delimiter = ",") {
787
+ const escapedFormula = /^[=+\-@\t\r]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
788
+ if (!(escapedFormula.includes(delimiter) || escapedFormula.includes("\"") || escapedFormula.includes("\n") || escapedFormula.includes("\r"))) return escapedFormula;
789
+ return `"${escapedFormula.replace(/"/g, "\"\"")}"`;
790
+ }
791
+ /**
792
+ * Export submissions to CSV format.
793
+ *
794
+ * Generates an RFC 4180 compliant CSV string with:
795
+ * - Form field values as columns (in form definition order)
796
+ * - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
797
+ * - Proper escaping of special characters
798
+ * - Optional UTF-8 BOM for Excel compatibility
799
+ *
800
+ * @param submissions - Array of submission documents to export
801
+ * @param form - Form document containing field definitions
802
+ * @param options - Export options
803
+ * @returns CSV formatted string
804
+ *
805
+ * @example
806
+ * ```typescript
807
+ * const csv = exportToCSV(submissions, form, {
808
+ * includeMetadata: true,
809
+ * includeBOM: true,
810
+ * });
811
+ *
812
+ * // Download in browser
813
+ * downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
814
+ * ```
815
+ */
816
+ function exportToCSV(submissions, form, options = {}) {
817
+ const { includeMetadata = true, delimiter = ",", includeBOM = true, dateFormat = "iso" } = options;
818
+ const exportableFields = form.fields;
819
+ const headers = [];
820
+ for (const field of exportableFields) headers.push(field.label);
821
+ if (includeMetadata) headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
822
+ const rows = [];
823
+ for (const submission of submissions) {
824
+ const row = [];
825
+ const data = submission.data;
826
+ for (const field of exportableFields) {
827
+ const value = data[field.name];
828
+ const formatted = formatExportValue(value, field);
829
+ row.push(escapeCSVValue(formatted, delimiter));
830
+ }
831
+ if (includeMetadata) {
832
+ row.push(escapeCSVValue(submission.id, delimiter));
833
+ row.push(escapeCSVValue(submission.status, delimiter));
834
+ row.push(escapeCSVValue(formatDate(submission.submittedAt, dateFormat), delimiter));
835
+ row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
836
+ row.push(escapeCSVValue(submission.userAgent || "", delimiter));
837
+ }
838
+ rows.push(row);
839
+ }
840
+ const csvContent = [headers.map((h) => escapeCSVValue(h, delimiter)).join(delimiter), ...rows.map((row) => row.join(delimiter))].join("\r\n");
841
+ return includeBOM ? UTF8_BOM + csvContent : csvContent;
842
+ }
843
+ /**
844
+ * Export submissions to JSON format.
845
+ *
846
+ * Generates a structured JSON export with:
847
+ * - Export metadata (timestamp)
848
+ * - Optional form definition
849
+ * - Array of submissions with field values
850
+ * - Optional submission metadata (_id, _status, etc.)
851
+ *
852
+ * @param submissions - Array of submission documents to export
853
+ * @param form - Form document containing field definitions
854
+ * @param options - Export options
855
+ * @returns JSON formatted string
856
+ *
857
+ * @example
858
+ * ```typescript
859
+ * const json = exportToJSON(submissions, form, {
860
+ * includeMetadata: true,
861
+ * includeFormDefinition: true,
862
+ * indent: 2,
863
+ * });
864
+ *
865
+ * // Download in browser
866
+ * downloadFile(json, 'submissions.json', 'application/json');
867
+ * ```
868
+ */
869
+ function exportToJSON(submissions, form, options = {}) {
870
+ const { includeMetadata = true, includeFormDefinition = true, indent = 2 } = options;
871
+ const exportableFields = form.fields;
872
+ const formDefinition = includeFormDefinition ? {
873
+ id: form.id,
874
+ name: form.name,
875
+ slug: form.slug,
876
+ fields: exportableFields.map((f) => ({
877
+ name: f.name,
878
+ label: f.label,
879
+ type: f.type
880
+ }))
881
+ } : void 0;
882
+ const exportedSubmissions = submissions.map((submission) => {
883
+ const data = submission.data;
884
+ const fieldData = {};
885
+ for (const field of exportableFields) {
886
+ const value = data[field.name];
887
+ fieldData[field.name] = value;
888
+ }
889
+ if (includeMetadata) return {
890
+ ...fieldData,
891
+ _metadata: {
892
+ id: submission.id,
893
+ status: submission.status,
894
+ submittedAt: submission.submittedAt,
895
+ ipAddress: submission.ipAddress || null,
896
+ userAgent: submission.userAgent || null,
897
+ createdAt: submission.createdAt,
898
+ updatedAt: submission.updatedAt
899
+ }
900
+ };
901
+ return fieldData;
902
+ });
903
+ const exportData = {
904
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
905
+ totalSubmissions: submissions.length,
906
+ submissions: exportedSubmissions
907
+ };
908
+ if (formDefinition) exportData.form = formDefinition;
909
+ return JSON.stringify(exportData, null, indent);
910
+ }
911
+ /**
912
+ * Trigger a file download in the browser.
913
+ *
914
+ * Creates a Blob from the content and triggers a download using a temporary
915
+ * anchor element. This function is browser-only and will throw in Node.js.
916
+ *
917
+ * @param content - File content as string
918
+ * @param filename - Name for the downloaded file
919
+ * @param mimeType - MIME type of the file
920
+ *
921
+ * @example
922
+ * ```typescript
923
+ * const csv = exportToCSV(submissions, form);
924
+ * downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
925
+ *
926
+ * const json = exportToJSON(submissions, form);
927
+ * downloadFile(json, 'submissions.json', 'application/json');
928
+ * ```
929
+ */
930
+ function downloadFile(content, filename, mimeType) {
931
+ if (typeof window === "undefined" || typeof document === "undefined") throw new Error("downloadFile is only available in browser environments");
932
+ const blob = new Blob([content], { type: mimeType });
933
+ const url = URL.createObjectURL(blob);
934
+ const link = document.createElement("a");
935
+ link.href = url;
936
+ link.download = filename;
937
+ document.body.appendChild(link);
938
+ link.click();
939
+ document.body.removeChild(link);
940
+ URL.revokeObjectURL(url);
941
+ }
942
+ /**
943
+ * Generate a filename for export.
944
+ *
945
+ * Creates a filename with form slug and current timestamp.
946
+ *
947
+ * @param formSlug - The form's slug
948
+ * @param format - Export format ('csv' or 'json')
949
+ * @returns Generated filename
950
+ *
951
+ * @example
952
+ * ```typescript
953
+ * generateExportFilename('contact-form', 'csv');
954
+ * // Returns: "contact-form-submissions-2024-01-15.csv"
955
+ * ```
956
+ */
957
+ function generateExportFilename(formSlug, format) {
958
+ return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
959
+ }
960
+ /**
961
+ * Export submissions and trigger browser download.
962
+ *
963
+ * Convenience function that combines export and download in one call.
964
+ * Browser-only.
965
+ *
966
+ * @param submissions - Array of submission documents
967
+ * @param form - Form document
968
+ * @param format - Export format ('csv' or 'json')
969
+ * @param options - Format-specific options
970
+ *
971
+ * @example
972
+ * ```typescript
973
+ * // In a React component
974
+ * const handleExport = (format: 'csv' | 'json') => {
975
+ * exportAndDownload(submissions, form, format);
976
+ * };
977
+ * ```
978
+ */
979
+ function exportAndDownload(submissions, form, format, options) {
980
+ const filename = generateExportFilename(form.slug, format);
981
+ if (format === "csv") downloadFile(exportToCSV(submissions, form, options), filename, "text/csv;charset=utf-8");
982
+ else downloadFile(exportToJSON(submissions, form, options), filename, "application/json");
983
+ }
984
+
985
+ // src/utils/field-references.ts
986
+ /**
987
+ * The one spelling of a field reference this plugin recognises: `{{fieldName}}`
988
+ * with no spaces, and `\w` names.
989
+ *
990
+ * Exact, because the SEND path is exact — a value the admin accepts as a
991
+ * reference and the send path does not is delivered with its braces intact,
992
+ * straight to the mail provider. Both sides read this constant so the question
993
+ * has one answer.
994
+ */
995
+ const FIELD_REF_PATTERN = /^\{\{(\w+)\}\}$/;
996
+ /** The field named by a reference, or `null` when the value is not one. */
997
+ function parseFieldRef(value) {
998
+ if (!value) return null;
999
+ const match = value.match(FIELD_REF_PATTERN);
1000
+ return match ? match[1] : null;
1001
+ }
1002
+ /**
1003
+ * Match `{{ fieldName }}` interpolations (whitespace-tolerant), the syntax the
1004
+ * notification send path resolves recipients and reply-to values with.
1005
+ */
1006
+ function referencesInTemplate(template, fieldName) {
1007
+ return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`).test(template);
1008
+ }
1009
+ /** All references to `fieldName` across the form's fields and notifications. */
1010
+ function findFieldReferences(fieldName, fields, notifications) {
1011
+ const references = [];
1012
+ for (const field of fields) {
1013
+ if (field.name === fieldName) continue;
1014
+ if (!field.conditionalLogic?.enabled) continue;
1015
+ if ((field.conditionalLogic.conditions ?? []).some((condition) => condition.field === fieldName)) references.push({
1016
+ kind: "condition",
1017
+ label: field.label || field.name
1018
+ });
1019
+ }
1020
+ for (const notification of notifications) {
1021
+ if (!notification.enabled) continue;
1022
+ const referencedByTemplate = [
1023
+ notification.to,
1024
+ ...notification.cc ?? [],
1025
+ ...notification.bcc ?? [],
1026
+ notification.replyTo
1027
+ ].filter((value) => typeof value === "string").some((template) => referencesInTemplate(template, fieldName));
1028
+ const referencedByCondition = notification.condition?.field === fieldName;
1029
+ if (referencedByTemplate || referencedByCondition) references.push({
1030
+ kind: "notification",
1031
+ label: notification.name || "Notification"
1032
+ });
1033
+ }
1034
+ return references;
1035
+ }
1036
+ /**
1037
+ * Reference lists for every field in one pass, keyed by field name, so a
1038
+ * per-card lookup is O(1) instead of re-walking fields and notifications for
1039
+ * each card on every render.
1040
+ */
1041
+ function buildFieldReferenceMap(fields, notifications) {
1042
+ const map = /* @__PURE__ */ new Map();
1043
+ for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications));
1044
+ return map;
1045
+ }
1046
+
1047
+ export { isKnownFormField as A, enforcedValidationRules as C, isFileFormField as D, isEmailFormField as E, pickedDocumentField as F, isTextFormField as M, documentReachability as N, isFormField as O, parseRedirectReference as P, 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 };
1048
+ //# sourceMappingURL=field-references-BshlgMcn.js.map