@nextlyhq/plugin-form-builder 0.0.2-alpha.74 → 0.0.2-alpha.75

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,3 +1,65 @@
1
+ import { z } from "zod";
2
+
3
+ // src/form-pages.ts
4
+ /**
5
+ * The form editor's pages, read by the plugin's declaration (which gives each
6
+ * page its address) and by the editor (which draws a tab for each). One list,
7
+ * so a tab cannot lead to an address nobody declared, nor an address go
8
+ * without its tab.
9
+ *
10
+ * No React and no admin imports here: the server reads this module too.
11
+ *
12
+ * @module form-pages
13
+ */
14
+ /**
15
+ * The form editor, the forms collection's own edit view. The admin draws it at
16
+ * the form's edit address and at each page's address below, so it stays
17
+ * mounted, unsaved work and all, while a person moves between the tabs.
18
+ */
19
+ const FORM_EDITOR_VIEW = "@nextlyhq/plugin-form-builder/admin#FormBuilderView";
20
+ /** Every page, in the order the tabs show them. */
21
+ const FORM_PAGES = [
22
+ {
23
+ label: "Builder",
24
+ path: void 0
25
+ },
26
+ {
27
+ label: "Preview",
28
+ path: "preview"
29
+ },
30
+ {
31
+ label: "Settings",
32
+ path: "settings"
33
+ },
34
+ {
35
+ label: "Notifications",
36
+ path: "notifications"
37
+ },
38
+ {
39
+ label: "Submissions",
40
+ path: "submissions"
41
+ }
42
+ ];
43
+ /** A page's id. */
44
+ function formPageId(page) {
45
+ return page.path ?? "builder";
46
+ }
47
+ /**
48
+ * The page an address segment names: Builder for none, or for a segment that
49
+ * is not one of the editor's pages.
50
+ */
51
+ function formPageOf(view) {
52
+ const page = FORM_PAGES.find((each) => each.path !== void 0 && each.path === view);
53
+ return page === void 0 ? "builder" : formPageId(page);
54
+ }
55
+ /** The pages with an address of their own, for the plugin's declaration. */
56
+ function addressedFormPages() {
57
+ return FORM_PAGES.flatMap((page) => page.path === void 0 ? [] : [{
58
+ path: page.path,
59
+ label: page.label
60
+ }]);
61
+ }
62
+
1
63
  // src/utils/redirect-reference.ts
2
64
  /**
3
65
  * The reference a `redirectPage` value holds, in any shape a read produces.
@@ -389,122 +451,560 @@ function instantOf(value) {
389
451
  return Number.isNaN(at.getTime()) ? void 0 : at.toISOString();
390
452
  }
391
453
 
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);
454
+ // src/utils/evaluate-conditions.ts
411
455
  /**
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.
456
+ * Checks if a value is considered "empty".
457
+ * Empty values: undefined, null, empty string, empty array.
415
458
  */
416
- function isKnownFormField(field) {
417
- return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
459
+ function isEmptyValue(value) {
460
+ if (value === void 0 || value === null) return true;
461
+ if (typeof value === "string" && value.trim() === "") return true;
462
+ if (Array.isArray(value) && value.length === 0) return true;
463
+ return false;
418
464
  }
419
465
  /**
420
- * Check if a value is a FormField.
466
+ * Converts a value to a number for numeric comparisons.
467
+ * Returns NaN if conversion is not possible.
421
468
  */
422
- function isFormField(value) {
423
- return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
469
+ function toNumber(value) {
470
+ if (typeof value === "number") return value;
471
+ if (typeof value === "string") {
472
+ const trimmed = value.trim();
473
+ if (trimmed === "") return NaN;
474
+ return Number(trimmed);
475
+ }
476
+ return NaN;
424
477
  }
425
478
  /**
426
- * Check if a FormField is a specific type.
479
+ * Converts a value to a string for string comparisons.
427
480
  */
428
- function isFormFieldType(field, type) {
429
- return field.type === type;
481
+ function toString(value) {
482
+ if (value === void 0 || value === null) return "";
483
+ if (typeof value === "string") return value;
484
+ if (Array.isArray(value)) return value.join(", ");
485
+ if (typeof value === "object") return JSON.stringify(value);
486
+ return String(value);
430
487
  }
431
488
  /**
432
- * Type guard for text field.
489
+ * Compares two values using the specified operator.
490
+ *
491
+ * @param operator - The comparison operator to use
492
+ * @param fieldValue - The actual value from the form data
493
+ * @param conditionValue - The expected value from the condition
494
+ * @returns Whether the comparison is true
433
495
  */
434
- function isTextFormField(field) {
435
- return field.type === "text";
496
+ function compareValues(operator, fieldValue, conditionValue) {
497
+ switch (operator) {
498
+ case "equals":
499
+ if (Array.isArray(fieldValue)) {
500
+ if (Array.isArray(conditionValue)) {
501
+ if (fieldValue.length !== conditionValue.length) return false;
502
+ return fieldValue.every((v, i) => v === conditionValue[i]);
503
+ }
504
+ return fieldValue.includes(conditionValue);
505
+ }
506
+ return toString(fieldValue) === toString(conditionValue);
507
+ case "notEquals":
508
+ if (Array.isArray(fieldValue)) {
509
+ if (Array.isArray(conditionValue)) {
510
+ if (fieldValue.length !== conditionValue.length) return true;
511
+ return !fieldValue.every((v, i) => v === conditionValue[i]);
512
+ }
513
+ return !fieldValue.includes(conditionValue);
514
+ }
515
+ return toString(fieldValue) !== toString(conditionValue);
516
+ case "contains": {
517
+ const fieldStr = toString(fieldValue).toLowerCase();
518
+ const conditionStr = toString(conditionValue).toLowerCase();
519
+ if (Array.isArray(fieldValue)) return fieldValue.some((v) => toString(v).toLowerCase().includes(conditionStr));
520
+ return fieldStr.includes(conditionStr);
521
+ }
522
+ case "isEmpty": return isEmptyValue(fieldValue);
523
+ case "isNotEmpty": return !isEmptyValue(fieldValue);
524
+ case "greaterThan": {
525
+ const fieldNum = toNumber(fieldValue);
526
+ const conditionNum = toNumber(conditionValue);
527
+ if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum > conditionNum;
528
+ return toString(fieldValue) > toString(conditionValue);
529
+ }
530
+ case "lessThan": {
531
+ const fieldNum = toNumber(fieldValue);
532
+ const conditionNum = toNumber(conditionValue);
533
+ if (!Number.isNaN(fieldNum) && !Number.isNaN(conditionNum)) return fieldNum < conditionNum;
534
+ return toString(fieldValue) < toString(conditionValue);
535
+ }
536
+ default:
537
+ console.warn(`[evaluateConditions] Unknown comparison operator: ${String(operator)}`);
538
+ return false;
539
+ }
436
540
  }
437
541
  /**
438
- * Type guard for email field.
542
+ * Evaluates a single condition against the form data.
543
+ *
544
+ * Exported for consumers that gate on one condition rather than a full
545
+ * conditional-logic block — notification send-conditions use exactly this.
546
+ *
547
+ * @param condition - The condition to evaluate
548
+ * @param formData - The current form data
549
+ * @returns Whether the condition is met
439
550
  */
440
- function isEmailFormField(field) {
441
- return field.type === "email";
551
+ function evaluateSingleCondition(condition, formData) {
552
+ const { field, comparison, value: conditionValue } = condition;
553
+ return compareValues(comparison, getNestedValue(formData, field), conditionValue);
442
554
  }
443
555
  /**
444
- * Type guard for select field.
556
+ * Gets a nested value from an object using dot notation.
557
+ *
558
+ * @param obj - The object to get the value from
559
+ * @param path - The path to the value (e.g., "address.city")
560
+ * @returns The value at the path, or undefined if not found
445
561
  */
446
- function isSelectFormField(field) {
447
- return field.type === "select";
562
+ function getNestedValue(obj, path) {
563
+ if (!path) return;
564
+ if (!path.includes(".")) return obj[path];
565
+ const parts = path.split(".");
566
+ let current = obj;
567
+ for (const part of parts) {
568
+ if (current === void 0 || current === null) return;
569
+ if (typeof current !== "object") return;
570
+ current = current[part];
571
+ }
572
+ return current;
448
573
  }
449
574
  /**
450
- * Type guard for checkbox field.
575
+ * Evaluates conditional logic to determine field visibility.
576
+ *
577
+ * This is the main entry point for conditional logic evaluation.
578
+ * It evaluates all conditions according to the logical operator (AND/OR)
579
+ * and returns whether the field should be visible based on the action
580
+ * (show/hide).
581
+ *
582
+ * @param logic - The conditional logic configuration
583
+ * @param formData - The current form data (field values)
584
+ * @returns Whether the field should be visible
585
+ *
586
+ * @example
587
+ * ```typescript
588
+ * // Show field when country is US AND age is over 18
589
+ * const logic: ConditionalLogic = {
590
+ * enabled: true,
591
+ * action: 'show',
592
+ * operator: 'AND',
593
+ * conditions: [
594
+ * { field: 'country', comparison: 'equals', value: 'US' },
595
+ * { field: 'age', comparison: 'greaterThan', value: 18 },
596
+ * ],
597
+ * };
598
+ *
599
+ * evaluateConditions(logic, { country: 'US', age: 25 }); // true
600
+ * evaluateConditions(logic, { country: 'US', age: 15 }); // false
601
+ * evaluateConditions(logic, { country: 'CA', age: 25 }); // false
602
+ *
603
+ * // Hide field when status is "completed"
604
+ * const hideLogic: ConditionalLogic = {
605
+ * enabled: true,
606
+ * action: 'hide',
607
+ * operator: 'AND',
608
+ * conditions: [
609
+ * { field: 'status', comparison: 'equals', value: 'completed' },
610
+ * ],
611
+ * };
612
+ *
613
+ * evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
614
+ * evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
615
+ * ```
616
+ *
617
+ * @example
618
+ * ```typescript
619
+ * // Show field when either condition is met (OR)
620
+ * const orLogic: ConditionalLogic = {
621
+ * enabled: true,
622
+ * action: 'show',
623
+ * operator: 'OR',
624
+ * conditions: [
625
+ * { field: 'role', comparison: 'equals', value: 'admin' },
626
+ * { field: 'role', comparison: 'equals', value: 'editor' },
627
+ * ],
628
+ * };
629
+ *
630
+ * evaluateConditions(orLogic, { role: 'admin' }); // true
631
+ * evaluateConditions(orLogic, { role: 'editor' }); // true
632
+ * evaluateConditions(orLogic, { role: 'viewer' }); // false
633
+ * ```
451
634
  */
452
- function isCheckboxFormField(field) {
453
- return field.type === "checkbox";
635
+ function evaluateConditions(logic, formData) {
636
+ if (!logic || !logic.enabled) return true;
637
+ const { action, operator, conditions } = logic;
638
+ if (!conditions || conditions.length === 0) return true;
639
+ const results = conditions.map((condition) => evaluateSingleCondition(condition, formData));
640
+ let conditionsMet;
641
+ if (operator === "OR") conditionsMet = results.some((result) => result);
642
+ else conditionsMet = results.every((result) => result);
643
+ if (action === "hide") return !conditionsMet;
644
+ return conditionsMet;
454
645
  }
455
646
  /**
456
- * Type guard for file field.
647
+ * Checks if a comparison operator is valid.
648
+ *
649
+ * @param operator - The operator to check
650
+ * @returns Whether the operator is valid
457
651
  */
458
- function isFileFormField(field) {
459
- return field.type === "file";
652
+ function isValidComparisonOperator(operator) {
653
+ return [
654
+ "equals",
655
+ "notEquals",
656
+ "contains",
657
+ "isEmpty",
658
+ "isNotEmpty",
659
+ "greaterThan",
660
+ "lessThan"
661
+ ].includes(operator);
460
662
  }
461
-
462
- // src/utils/enforced-validation.ts
463
663
  /**
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.
664
+ * Gets the list of supported comparison operators.
467
665
  *
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.
666
+ * @returns Array of supported operator names
471
667
  */
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
- };
668
+ function getSupportedComparisonOperators() {
669
+ return [
670
+ "equals",
671
+ "notEquals",
672
+ "contains",
673
+ "isEmpty",
674
+ "isNotEmpty",
675
+ "greaterThan",
676
+ "lessThan"
677
+ ];
678
+ }
679
+
680
+ // src/utils/export-formats.ts
681
+ /** UTF-8 BOM character sequence */
682
+ const UTF8_BOM = "";
500
683
  /**
501
- * The rules a field of this type both accepts and has enforced.
684
+ * Stringify a value safely, falling back to JSON for plain objects so we
685
+ * never emit the default `[object Object]` representation.
686
+ */
687
+ function safeString(value) {
688
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
689
+ return String(value);
690
+ }
691
+ /**
692
+ * Format a field value for export based on field type.
502
693
  *
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.
694
+ * @param value - The raw value from submission data
695
+ * @param field - The field definition (optional, for type-specific formatting)
696
+ * @returns Formatted string representation of the value
697
+ *
698
+ * @example
699
+ * ```typescript
700
+ * formatExportValue(true, { type: 'checkbox' }); // "Yes"
701
+ * formatExportValue(['a', 'b'], { type: 'select' }); // "a, b"
702
+ * formatExportValue(new Date(), { type: 'date' }); // "2024-01-15"
703
+ * ```
505
704
  */
506
- function enforcedValidationRules(type) {
507
- return ENFORCED_VALIDATION_RULES[type] ?? [];
705
+ function formatExportValue(value, field) {
706
+ if (value === null || value === void 0) return "";
707
+ if (field) switch (field.type) {
708
+ case "checkbox": return value ? "Yes" : "No";
709
+ case "select":
710
+ if (Array.isArray(value)) return value.join(", ");
711
+ return safeString(value);
712
+ case "file":
713
+ if (Array.isArray(value)) return value.map((v) => safeString(v)).join(", ");
714
+ return safeString(value);
715
+ case "date":
716
+ if (value instanceof Date) return value.toISOString().split("T")[0];
717
+ if (typeof value === "string") {
718
+ const parsed = new Date(value);
719
+ if (!isNaN(parsed.getTime())) return parsed.toISOString().split("T")[0];
720
+ }
721
+ return safeString(value);
722
+ case "time": return safeString(value);
723
+ case "hidden": return safeString(value);
724
+ }
725
+ if (Array.isArray(value)) return value.join(", ");
726
+ if (typeof value === "boolean") return value ? "Yes" : "No";
727
+ if (value instanceof Date) return value.toISOString();
728
+ return safeString(value);
729
+ }
730
+ /**
731
+ * Format a date for export.
732
+ *
733
+ * @param date - Date value (Date object or string)
734
+ * @param format - Output format ("iso" or "locale")
735
+ * @returns Formatted date string
736
+ */
737
+ function formatDate(date, format = "iso") {
738
+ if (!date) return "";
739
+ const d = typeof date === "string" ? new Date(date) : date;
740
+ if (isNaN(d.getTime())) return "";
741
+ return format === "iso" ? d.toISOString() : d.toLocaleString();
742
+ }
743
+ /**
744
+ * Escape a value for CSV format (RFC 4180 compliant).
745
+ *
746
+ * Rules:
747
+ * - Fields containing comma, double-quote, or newline must be quoted
748
+ * - Double-quotes within fields must be escaped with another double-quote
749
+ *
750
+ * @param value - The value to escape
751
+ * @param delimiter - The delimiter being used
752
+ * @returns Properly escaped CSV value
753
+ */
754
+ function escapeCSVValue(value, delimiter = ",") {
755
+ const escapedFormula = /^[=+\-@\t\r]/.test(value) && !/^-?\d+(\.\d+)?$/.test(value) ? `'${value}` : value;
756
+ if (!(escapedFormula.includes(delimiter) || escapedFormula.includes("\"") || escapedFormula.includes("\n") || escapedFormula.includes("\r"))) return escapedFormula;
757
+ return `"${escapedFormula.replace(/"/g, "\"\"")}"`;
758
+ }
759
+ /**
760
+ * Export submissions to CSV format.
761
+ *
762
+ * Generates an RFC 4180 compliant CSV string with:
763
+ * - Form field values as columns (in form definition order)
764
+ * - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
765
+ * - Proper escaping of special characters
766
+ * - Optional UTF-8 BOM for Excel compatibility
767
+ *
768
+ * @param submissions - Array of submission documents to export
769
+ * @param form - Form document containing field definitions
770
+ * @param options - Export options
771
+ * @returns CSV formatted string
772
+ *
773
+ * @example
774
+ * ```typescript
775
+ * const csv = exportToCSV(submissions, form, {
776
+ * includeMetadata: true,
777
+ * includeBOM: true,
778
+ * });
779
+ *
780
+ * // Download in browser
781
+ * downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
782
+ * ```
783
+ */
784
+ function exportToCSV(submissions, form, options = {}) {
785
+ const { includeMetadata = true, delimiter = ",", includeBOM = true, dateFormat = "iso" } = options;
786
+ const exportableFields = form.fields;
787
+ const headers = [];
788
+ for (const field of exportableFields) headers.push(field.label);
789
+ if (includeMetadata) headers.push("ID", "Status", "Submitted At", "IP Address", "User Agent");
790
+ const rows = [];
791
+ for (const submission of submissions) {
792
+ const row = [];
793
+ const data = submission.data;
794
+ for (const field of exportableFields) {
795
+ const value = data[field.name];
796
+ const formatted = formatExportValue(value, field);
797
+ row.push(escapeCSVValue(formatted, delimiter));
798
+ }
799
+ if (includeMetadata) {
800
+ row.push(escapeCSVValue(submission.id, delimiter));
801
+ row.push(escapeCSVValue(submission.status, delimiter));
802
+ row.push(escapeCSVValue(formatDate(submission.submittedAt, dateFormat), delimiter));
803
+ row.push(escapeCSVValue(submission.ipAddress || "", delimiter));
804
+ row.push(escapeCSVValue(submission.userAgent || "", delimiter));
805
+ }
806
+ rows.push(row);
807
+ }
808
+ const csvContent = [headers.map((h) => escapeCSVValue(h, delimiter)).join(delimiter), ...rows.map((row) => row.join(delimiter))].join("\r\n");
809
+ return includeBOM ? UTF8_BOM + csvContent : csvContent;
810
+ }
811
+ /**
812
+ * Export submissions to JSON format.
813
+ *
814
+ * Generates a structured JSON export with:
815
+ * - Export metadata (timestamp)
816
+ * - Optional form definition
817
+ * - Array of submissions with field values
818
+ * - Optional submission metadata (_id, _status, etc.)
819
+ *
820
+ * @param submissions - Array of submission documents to export
821
+ * @param form - Form document containing field definitions
822
+ * @param options - Export options
823
+ * @returns JSON formatted string
824
+ *
825
+ * @example
826
+ * ```typescript
827
+ * const json = exportToJSON(submissions, form, {
828
+ * includeMetadata: true,
829
+ * includeFormDefinition: true,
830
+ * indent: 2,
831
+ * });
832
+ *
833
+ * // Download in browser
834
+ * downloadFile(json, 'submissions.json', 'application/json');
835
+ * ```
836
+ */
837
+ function exportToJSON(submissions, form, options = {}) {
838
+ const { includeMetadata = true, includeFormDefinition = true, indent = 2 } = options;
839
+ const exportableFields = form.fields;
840
+ const formDefinition = includeFormDefinition ? {
841
+ id: form.id,
842
+ name: form.name,
843
+ slug: form.slug,
844
+ fields: exportableFields.map((f) => ({
845
+ name: f.name,
846
+ label: f.label,
847
+ type: f.type
848
+ }))
849
+ } : void 0;
850
+ const exportedSubmissions = submissions.map((submission) => {
851
+ const data = submission.data;
852
+ const fieldData = {};
853
+ for (const field of exportableFields) {
854
+ const value = data[field.name];
855
+ fieldData[field.name] = value;
856
+ }
857
+ if (includeMetadata) return {
858
+ ...fieldData,
859
+ _metadata: {
860
+ id: submission.id,
861
+ status: submission.status,
862
+ submittedAt: submission.submittedAt,
863
+ ipAddress: submission.ipAddress || null,
864
+ userAgent: submission.userAgent || null,
865
+ createdAt: submission.createdAt,
866
+ updatedAt: submission.updatedAt
867
+ }
868
+ };
869
+ return fieldData;
870
+ });
871
+ const exportData = {
872
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
873
+ totalSubmissions: submissions.length,
874
+ submissions: exportedSubmissions
875
+ };
876
+ if (formDefinition) exportData.form = formDefinition;
877
+ return JSON.stringify(exportData, null, indent);
878
+ }
879
+ /**
880
+ * Trigger a file download in the browser.
881
+ *
882
+ * Creates a Blob from the content and triggers a download using a temporary
883
+ * anchor element. This function is browser-only and will throw in Node.js.
884
+ *
885
+ * @param content - File content as string
886
+ * @param filename - Name for the downloaded file
887
+ * @param mimeType - MIME type of the file
888
+ *
889
+ * @example
890
+ * ```typescript
891
+ * const csv = exportToCSV(submissions, form);
892
+ * downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
893
+ *
894
+ * const json = exportToJSON(submissions, form);
895
+ * downloadFile(json, 'submissions.json', 'application/json');
896
+ * ```
897
+ */
898
+ function downloadFile(content, filename, mimeType) {
899
+ if (typeof window === "undefined" || typeof document === "undefined") throw new Error("downloadFile is only available in browser environments");
900
+ const blob = new Blob([content], { type: mimeType });
901
+ const url = URL.createObjectURL(blob);
902
+ const link = document.createElement("a");
903
+ link.href = url;
904
+ link.download = filename;
905
+ document.body.appendChild(link);
906
+ link.click();
907
+ document.body.removeChild(link);
908
+ URL.revokeObjectURL(url);
909
+ }
910
+ /**
911
+ * Generate a filename for export.
912
+ *
913
+ * Creates a filename with form slug and current timestamp.
914
+ *
915
+ * @param formSlug - The form's slug
916
+ * @param format - Export format ('csv' or 'json')
917
+ * @returns Generated filename
918
+ *
919
+ * @example
920
+ * ```typescript
921
+ * generateExportFilename('contact-form', 'csv');
922
+ * // Returns: "contact-form-submissions-2024-01-15.csv"
923
+ * ```
924
+ */
925
+ function generateExportFilename(formSlug, format) {
926
+ return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
927
+ }
928
+ /**
929
+ * Export submissions and trigger browser download.
930
+ *
931
+ * Convenience function that combines export and download in one call.
932
+ * Browser-only.
933
+ *
934
+ * @param submissions - Array of submission documents
935
+ * @param form - Form document
936
+ * @param format - Export format ('csv' or 'json')
937
+ * @param options - Format-specific options
938
+ *
939
+ * @example
940
+ * ```typescript
941
+ * // In a React component
942
+ * const handleExport = (format: 'csv' | 'json') => {
943
+ * exportAndDownload(submissions, form, format);
944
+ * };
945
+ * ```
946
+ */
947
+ function exportAndDownload(submissions, form, format, options) {
948
+ const filename = generateExportFilename(form.slug, format);
949
+ if (format === "csv") downloadFile(exportToCSV(submissions, form, options), filename, "text/csv;charset=utf-8");
950
+ else downloadFile(exportToJSON(submissions, form, options), filename, "application/json");
951
+ }
952
+
953
+ // src/emails/starter-messages.ts
954
+ /** The team's email: every answer, so the inbox has the whole submission. */
955
+ const FORM_SUBMISSION_TEMPLATE = "forms-submission";
956
+ /** The visitor's email: confirms the submission arrived, with a copy. */
957
+ const FORM_CONFIRMATION_TEMPLATE = "forms-confirmation";
958
+ /** The answers as a two-column table, label then value, one row each. */
959
+ const ANSWERS_TABLE = "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" style=\"width: 100%; border-collapse: collapse;\">{% for answer in _submission.answers %}<tr><td style=\"width: 40%; padding: 8px 16px 8px 0; vertical-align: top; font-weight: 600; overflow-wrap: anywhere;\">{{ answer.label }}</td><td style=\"padding: 8px 0; vertical-align: top; overflow-wrap: anywhere;\">{{ answer.value }}</td></tr>{% endfor %}</table>";
960
+ /**
961
+ * Shown where the table would be when no answer list was sent: the Email hub's
962
+ * preview and Send test, whose sample data has a placeholder string for each
963
+ * variable. A real send always carries a list, even an empty one, which
964
+ * Liquid treats as present, so a sent email never shows this line.
965
+ */
966
+ const PREVIEW_NOTE = `{% unless _submission.answers %}<p><em>Each answer appears here, its question then what was answered.</em></p>{% endunless %}`;
967
+ /** The answers as plain text, one per line, for mail clients that show text. */
968
+ const ANSWERS_TEXT = `{% for answer in _submission.answers %}{{ answer.label }}: {{ answer.value }}\n{% endfor %}`;
969
+ /** The variables both messages are sent with, as the Email hub lists them. */
970
+ const VARIABLES = [{
971
+ name: "formName",
972
+ description: "The form's name",
973
+ required: true
974
+ }, {
975
+ name: "_submission",
976
+ description: "The submission: `_submission.answers` holds each answer as `label` and `value`, in the form's order, leaving out questions left blank or hidden by a condition; `_submission.id` is its id. It starts with an underscore, which no form field's name may, so it never meets a submitted field of the same name",
977
+ required: true
978
+ }];
979
+ /** The messages the plugin contributes to Settings › Email. */
980
+ function formsEmailTemplates() {
981
+ return [{
982
+ slug: FORM_SUBMISSION_TEMPLATE,
983
+ name: "Form submission",
984
+ description: "Sent to your team when someone submits a form, with every answer.",
985
+ subject: "New submission: {{ formName }}",
986
+ htmlContent: `<p>Someone submitted <strong>{{ formName }}</strong>.</p>${ANSWERS_TABLE}${PREVIEW_NOTE}`,
987
+ plainTextContent: `Someone submitted {{ formName }}.\n\n${ANSWERS_TEXT}`,
988
+ variables: VARIABLES,
989
+ engine: "liquid"
990
+ }, {
991
+ slug: FORM_CONFIRMATION_TEMPLATE,
992
+ name: "Form confirmation",
993
+ description: "Sent to the person who submitted a form, confirming it arrived, with a copy of their answers.",
994
+ subject: "We received your submission",
995
+ htmlContent: `<p>Thank you. We received your answers to <strong>{{ formName }}</strong> and will be in touch if we need anything else.</p><p>Here is a copy of what you sent:</p>${ANSWERS_TABLE}${PREVIEW_NOTE}`,
996
+ plainTextContent: `Thank you. We received your answers to {{ formName }} and will be in touch if we need anything else.\n\nHere is a copy of what you sent:\n\n${ANSWERS_TEXT}`,
997
+ variables: VARIABLES,
998
+ engine: "liquid"
999
+ }];
1000
+ }
1001
+ /**
1002
+ * The starter message for a notification's kind: the confirmation when it goes
1003
+ * to the visitor (the address in one of the form's fields), the team's email
1004
+ * otherwise.
1005
+ */
1006
+ function starterTemplateFor(recipientType) {
1007
+ return recipientType === "field" ? FORM_CONFIRMATION_TEMPLATE : FORM_SUBMISSION_TEMPLATE;
508
1008
  }
509
1009
 
510
1010
  // src/config/removed-options.ts
@@ -533,7 +1033,11 @@ const DEFAULT_FORM_SETTINGS = {
533
1033
  submitButtonText: "Submit",
534
1034
  confirmationType: "message",
535
1035
  successMessage: "Thank you for your submission!",
536
- allowMultipleSubmissions: true
1036
+ allowMultipleSubmissions: true,
1037
+ oneResponse: "off",
1038
+ oneResponseVersion: 0,
1039
+ botCheck: "default",
1040
+ storeIpAndBrowser: true
537
1041
  };
538
1042
  /**
539
1043
  * Default notification settings.
@@ -628,15 +1132,11 @@ function applyFormDefaults(config) {
628
1132
  plural: config.labels?.plural || pluralize(singularLabel)
629
1133
  },
630
1134
  settings: {
1135
+ ...config.settings,
631
1136
  submitButtonText: config.settings?.submitButtonText || DEFAULT_FORM_SETTINGS.submitButtonText,
632
1137
  confirmationType: config.settings?.confirmationType || DEFAULT_FORM_SETTINGS.confirmationType,
633
1138
  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
1139
+ allowMultipleSubmissions: config.settings?.allowMultipleSubmissions ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions
640
1140
  },
641
1141
  notifications: config.notifications ? {
642
1142
  enabled: config.notifications.enabled ?? DEFAULT_NOTIFICATION_SETTINGS.enabled,
@@ -721,17 +1221,63 @@ function asOptionalBoolean(value) {
721
1221
  function asOptionalString(value) {
722
1222
  return typeof value === "string" && value !== "" ? value : void 0;
723
1223
  }
1224
+ /** A whole number of zero or more, or `undefined`. */
1225
+ function asWholeNumber(value) {
1226
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
1227
+ }
1228
+ /** A whole number above zero, or `undefined`. */
1229
+ function asPositiveWholeNumber(value) {
1230
+ const whole = asWholeNumber(value);
1231
+ return whole === void 0 || whole === 0 ? void 0 : whole;
1232
+ }
1233
+ const ONE_RESPONSE_MODES = [
1234
+ "off",
1235
+ "email",
1236
+ "browser",
1237
+ "ip"
1238
+ ];
1239
+ const BOT_CHECK_MODES = [
1240
+ "default",
1241
+ "off",
1242
+ "recaptcha",
1243
+ "turnstile"
1244
+ ];
1245
+ /**
1246
+ * The form's one-response mode: as stored, or read from the setting it
1247
+ * replaced, so no form's behaviour changes. Several responses allowed reads
1248
+ * as `"off"`; one per network address as `"ip"`, the only check it had.
1249
+ */
1250
+ function oneResponseOf(source) {
1251
+ const stored = ONE_RESPONSE_MODES.find((mode) => mode === source.oneResponse);
1252
+ if (stored !== void 0) return stored;
1253
+ if (asOptionalBoolean(source.allowMultipleSubmissions) === false) return "ip";
1254
+ return DEFAULT_FORM_SETTINGS.oneResponse;
1255
+ }
1256
+ /**
1257
+ * The form's bot check: as stored, or read from the per-form reCAPTCHA
1258
+ * switch it replaced: unset follows the plugin, on is reCAPTCHA, off is off,
1259
+ * so no form's protection changes.
1260
+ */
1261
+ function botCheckOf(source, captchaEnabled) {
1262
+ const stored = BOT_CHECK_MODES.find((mode) => mode === source.botCheck);
1263
+ if (stored !== void 0) return stored;
1264
+ if (captchaEnabled === true) return "recaptcha";
1265
+ if (captchaEnabled === false) return "off";
1266
+ return DEFAULT_FORM_SETTINGS.botCheck;
1267
+ }
724
1268
  /**
725
1269
  * Normalize a stored settings value into the canonical {@link FormSettings}.
726
1270
  *
727
1271
  * Legacy keys are migrated on read (no data is lost, nothing is rewritten
728
1272
  * until the form is next saved):
729
1273
  * - `confirmationMessage` → `successMessage` + `confirmationType: "message"`
730
- * - nested `captcha: { enabled, siteKey }` → flat `captchaEnabled`/`captchaSiteKey`
1274
+ * - nested `captcha: { enabled, siteKey }` → flat `captchaSiteKey`, and its
1275
+ * `enabled` read as `botCheck`
1276
+ * - `allowMultipleSubmissions` → `oneResponse`; `captchaEnabled` → `botCheck`
731
1277
  *
732
1278
  * Keys with no consumer (`showResetButton`, `resetButtonText`,
733
- * `storeSubmissions`, `submissionLimit`) are dropped: a setting that does
734
- * nothing must not survive into the UI.
1279
+ * `storeSubmissions`) are dropped: a setting that does nothing must not
1280
+ * survive into the UI.
735
1281
  */
736
1282
  function normalizeFormSettings(raw) {
737
1283
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ...DEFAULT_FORM_SETTINGS };
@@ -746,510 +1292,633 @@ function normalizeFormSettings(raw) {
746
1292
  redirectUrl: asOptionalString(source.redirectUrl),
747
1293
  redirectPage: source.redirectPage ?? void 0,
748
1294
  redirectRelation: source.redirectRelation && typeof source.redirectRelation === "object" ? source.redirectRelation : void 0,
749
- allowMultipleSubmissions: asOptionalBoolean(source.allowMultipleSubmissions) ?? DEFAULT_FORM_SETTINGS.allowMultipleSubmissions,
1295
+ oneResponse: oneResponseOf(source),
1296
+ oneResponseField: asOptionalString(source.oneResponseField),
1297
+ oneResponseVersion: asWholeNumber(source.oneResponseVersion) ?? DEFAULT_FORM_SETTINGS.oneResponseVersion,
1298
+ oneResponseKeyedVersion: asWholeNumber(source.oneResponseKeyedVersion),
750
1299
  honeypotEnabled: asOptionalBoolean(source.honeypotEnabled),
751
- captchaEnabled: asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled),
1300
+ botCheck: botCheckOf(source, asOptionalBoolean(source.captchaEnabled) ?? asOptionalBoolean(legacyCaptcha?.enabled)),
1301
+ closesAt: asOptionalString(source.closesAt),
1302
+ submissionLimit: asPositiveWholeNumber(source.submissionLimit),
1303
+ storeIpAndBrowser: asOptionalBoolean(source.storeIpAndBrowser) ?? DEFAULT_FORM_SETTINGS.storeIpAndBrowser,
1304
+ keepDays: asPositiveWholeNumber(source.keepDays),
1305
+ internalNote: asOptionalString(source.internalNote),
752
1306
  captchaSiteKey: asOptionalString(source.captchaSiteKey) ?? asOptionalString(legacyCaptcha?.siteKey)
753
1307
  };
754
1308
  }
755
1309
 
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;
1310
+ // src/utils/condition-visibility.ts
1311
+ /** The names a field's enabled condition reads; none when it has none. */
1312
+ function namesRead(field) {
1313
+ const logic = field.conditionalLogic;
1314
+ return logic?.enabled === true ? logic.conditions.map((condition) => condition.field) : [];
766
1315
  }
767
1316
  /**
768
- * Converts a value to a number for numeric comparisons.
769
- * Returns NaN if conversion is not possible.
1317
+ * Which of a form's fields its conditions hide, for a given set of answers.
1318
+ *
1319
+ * A condition reads the answers of fields that are themselves shown, so a
1320
+ * hidden field's leftover answer never decides another field, and it may read
1321
+ * a field before or after its own. Fields are decided in the order their
1322
+ * conditions depend on each other, whatever their order on the form.
1323
+ *
1324
+ * Conditions can read each other in a loop (A shown when B is answered, B
1325
+ * shown when A is), and no order settles a loop. The rule for one: fields in
1326
+ * the same loop read each other's answers as given, and only the loop's
1327
+ * fields decide it, so an unrelated field elsewhere on the form never changes
1328
+ * the outcome.
1329
+ *
1330
+ * A field of type `hidden` is not one of them: it is sent with every
1331
+ * submission, unseen.
770
1332
  */
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;
1333
+ function fieldsHiddenByCondition(fields, answers) {
1334
+ const byName = new Map(fields.map((field) => [field.name, field]));
1335
+ const reach = /* @__PURE__ */ new Map();
1336
+ /** Every field `name`'s condition depends on, directly or through others. */
1337
+ const dependsOn = (name) => {
1338
+ const known = reach.get(name);
1339
+ if (known) return known;
1340
+ const found = /* @__PURE__ */ new Set();
1341
+ reach.set(name, found);
1342
+ const pending = [name];
1343
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
1344
+ const field = byName.get(next);
1345
+ for (const read of field ? namesRead(field) : []) if (!found.has(read)) {
1346
+ found.add(read);
1347
+ pending.push(read);
1348
+ }
1349
+ }
1350
+ return found;
1351
+ };
1352
+ const shown = /* @__PURE__ */ new Map();
1353
+ /** Whether field `name` is on the page, deciding what it reads first. */
1354
+ const isShown = (name) => {
1355
+ const decided = shown.get(name);
1356
+ if (decided !== void 0) return decided;
1357
+ const field = byName.get(name);
1358
+ const logic = field?.conditionalLogic;
1359
+ if (!field || logic?.enabled !== true) return true;
1360
+ const read = Object.fromEntries(Object.entries(answers).filter(([other]) => !byName.has(other) || !namesRead(field).includes(other) || dependsOn(other).has(name) || isShown(other)));
1361
+ const result = evaluateConditions(logic, read);
1362
+ shown.set(name, result);
1363
+ return result;
1364
+ };
1365
+ return new Set(fields.filter((field) => !isShown(field.name)).map((field) => field.name));
779
1366
  }
1367
+
1368
+ // src/types.ts
1369
+ /** Every built-in form field type, as a value list (the single source shared
1370
+ * by the field-type guard and the config validator). */
1371
+ const BUILT_IN_FORM_FIELD_TYPES = [
1372
+ "text",
1373
+ "email",
1374
+ "number",
1375
+ "phone",
1376
+ "url",
1377
+ "textarea",
1378
+ "select",
1379
+ "checkbox",
1380
+ "radio",
1381
+ "file",
1382
+ "date",
1383
+ "time",
1384
+ "hidden"
1385
+ ];
1386
+ const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
780
1387
  /**
781
- * Converts a value to a string for string comparisons.
1388
+ * Narrow an `AnyFormField` to the closed built-in `FormField` union. Returns
1389
+ * `false` for a plugin-contributed field, so callers handle it generically
1390
+ * before switching on the built-in discriminant.
782
1391
  */
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);
1392
+ function isKnownFormField(field) {
1393
+ return BUILT_IN_FORM_FIELD_TYPES_SET.has(field.type);
789
1394
  }
790
1395
  /**
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
1396
+ * Check if a value is a FormField.
797
1397
  */
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
- }
1398
+ function isFormField(value) {
1399
+ return typeof value === "object" && value !== null && "type" in value && "name" in value && "label" in value;
842
1400
  }
843
1401
  /**
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
1402
+ * Check if a FormField is a specific type.
852
1403
  */
853
- function evaluateSingleCondition(condition, formData) {
854
- const { field, comparison, value: conditionValue } = condition;
855
- return compareValues(comparison, getNestedValue(formData, field), conditionValue);
1404
+ function isFormFieldType(field, type) {
1405
+ return field.type === type;
856
1406
  }
857
1407
  /**
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
1408
+ * Type guard for text field.
863
1409
  */
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;
1410
+ function isTextFormField(field) {
1411
+ return field.type === "text";
875
1412
  }
876
1413
  /**
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
1414
+ * Type guard for email field.
1415
+ */
1416
+ function isEmailFormField(field) {
1417
+ return field.type === "email";
1418
+ }
1419
+ /**
1420
+ * Type guard for select field.
1421
+ */
1422
+ function isSelectFormField(field) {
1423
+ return field.type === "select";
1424
+ }
1425
+ /**
1426
+ * Type guard for checkbox field.
1427
+ */
1428
+ function isCheckboxFormField(field) {
1429
+ return field.type === "checkbox";
1430
+ }
1431
+ /**
1432
+ * Type guard for file field.
1433
+ */
1434
+ function isFileFormField(field) {
1435
+ return field.type === "file";
1436
+ }
1437
+
1438
+ // src/utils/enforced-validation.ts
1439
+ /**
1440
+ * `message` appears on every row because it is honoured everywhere: each
1441
+ * generator passes `validation.errorMessage` to `applyRequired`, so even a type
1442
+ * with no bounds of its own uses it for the required failure.
887
1443
  *
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
- * };
1444
+ * Typed as a total `Record` rather than a partial one, so adding a field type
1445
+ * without deciding what it enforces is a compile error instead of a type that
1446
+ * silently offers nothing.
1447
+ */
1448
+ const ENFORCED_VALIDATION_RULES = {
1449
+ text: [
1450
+ "minLength",
1451
+ "maxLength",
1452
+ "pattern",
1453
+ "message"
1454
+ ],
1455
+ email: ["pattern", "message"],
1456
+ number: [
1457
+ "min",
1458
+ "max",
1459
+ "message"
1460
+ ],
1461
+ phone: ["pattern", "message"],
1462
+ url: ["pattern", "message"],
1463
+ textarea: [
1464
+ "minLength",
1465
+ "maxLength",
1466
+ "message"
1467
+ ],
1468
+ select: ["message"],
1469
+ checkbox: ["message"],
1470
+ radio: ["message"],
1471
+ file: ["message"],
1472
+ date: ["message"],
1473
+ time: ["message"],
1474
+ hidden: ["message"]
1475
+ };
1476
+ /**
1477
+ * The rules a field of this type both accepts and has enforced.
900
1478
  *
901
- * evaluateConditions(logic, { country: 'US', age: 25 }); // true
902
- * evaluateConditions(logic, { country: 'US', age: 15 }); // false
903
- * evaluateConditions(logic, { country: 'CA', age: 25 }); // false
1479
+ * A plugin-contributed type has no row, and correctly gets nothing: this
1480
+ * runtime's generator has no clause for it, so every rule would be inert.
1481
+ */
1482
+ function enforcedValidationRules(type) {
1483
+ return ENFORCED_VALIDATION_RULES[type] ?? [];
1484
+ }
1485
+
1486
+ // src/utils/generate-schema.ts
1487
+ /**
1488
+ * Generate a Zod validation schema from form field configuration.
904
1489
  *
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
- * };
1490
+ * Creates a dynamic Zod schema based on the form fields array,
1491
+ * applying appropriate validation rules for each field type.
914
1492
  *
915
- * evaluateConditions(hideLogic, { status: 'completed' }); // false (hidden)
916
- * evaluateConditions(hideLogic, { status: 'pending' }); // true (visible)
917
- * ```
1493
+ * @param fields - Array of form field configurations
1494
+ * @returns Zod object schema for validating submission data
918
1495
  *
919
1496
  * @example
920
1497
  * ```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
- * };
1498
+ * const fields: FormField[] = [
1499
+ * { type: 'text', name: 'firstName', label: 'First Name', required: true },
1500
+ * { type: 'email', name: 'email', label: 'Email', required: true },
1501
+ * { type: 'number', name: 'age', label: 'Age', validation: { min: 18, max: 120 } },
1502
+ * ];
931
1503
  *
932
- * evaluateConditions(orLogic, { role: 'admin' }); // true
933
- * evaluateConditions(orLogic, { role: 'editor' }); // true
934
- * evaluateConditions(orLogic, { role: 'viewer' }); // false
1504
+ * const schema = generateZodSchema(fields);
1505
+ * const result = schema.safeParse({ firstName: 'John', email: 'john@example.com', age: 25 });
935
1506
  * ```
936
1507
  */
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;
1508
+ function generateZodSchema(fields) {
1509
+ const schemaShape = {};
1510
+ for (const field of fields) {
1511
+ if (!isKnownFormField(field)) {
1512
+ schemaShape[field.name] = field.required ? z.unknown().refine((value) => value !== void 0 && value !== null && value !== "", { message: `${field.label || field.name} is required` }) : z.unknown();
1513
+ continue;
1514
+ }
1515
+ if (field.type === "hidden" && !field.required) {
1516
+ schemaShape[field.name] = z.string().optional();
1517
+ continue;
1518
+ }
1519
+ const fieldSchema = generateFieldSchema(field);
1520
+ if (fieldSchema) schemaShape[field.name] = fieldSchema;
1521
+ }
1522
+ return z.object(schemaShape);
947
1523
  }
948
1524
  /**
949
- * Checks if a comparison operator is valid.
950
- *
951
- * @param operator - The operator to check
952
- * @returns Whether the operator is valid
1525
+ * Generate a Zod schema for a single field.
953
1526
  */
954
- function isValidComparisonOperator(operator) {
955
- return [
956
- "equals",
957
- "notEquals",
958
- "contains",
959
- "isEmpty",
960
- "isNotEmpty",
961
- "greaterThan",
962
- "lessThan"
963
- ].includes(operator);
1527
+ function generateFieldSchema(field) {
1528
+ switch (field.type) {
1529
+ case "text": return generateTextSchema(field);
1530
+ case "email": return generateEmailSchema(field);
1531
+ case "number": return generateNumberSchema(field);
1532
+ case "phone": return generatePhoneSchema(field);
1533
+ case "url": return generateUrlSchema(field);
1534
+ case "textarea": return generateTextareaSchema(field);
1535
+ case "select": return generateSelectSchema(field);
1536
+ case "checkbox": return generateCheckboxSchema(field);
1537
+ case "radio": return generateRadioSchema(field);
1538
+ case "file": return generateFileSchema(field);
1539
+ case "date": return generateDateSchema(field);
1540
+ case "time": return generateTimeSchema(field);
1541
+ case "hidden": return generateHiddenSchema(field);
1542
+ default: return z.unknown();
1543
+ }
964
1544
  }
965
1545
  /**
966
- * Gets the list of supported comparison operators.
1546
+ * Generate schema for text field.
1547
+ */
1548
+ /**
1549
+ * The author's own wording for a failure, or `undefined` when this field's type
1550
+ * does not enforce `message`.
1551
+ *
1552
+ * Asked of the row rather than read off `validation` directly, for the reason
1553
+ * the table exists at all: the editor decides whether to OFFER the control from
1554
+ * that row, so a generator honouring a stored message regardless would enforce
1555
+ * a value the author was never shown a box for — and, worse, the table would
1556
+ * describe a rule it did not govern.
1557
+ *
1558
+ * `message` was the one rule that stayed outside this. It agreed with the table
1559
+ * only because every row happens to list it, which is agreement by coincidence:
1560
+ * drop `message` from a row and the control disappears while the wording went
1561
+ * on being applied.
1562
+ */
1563
+ function customMessage(field) {
1564
+ if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("message")) return void 0;
1565
+ return field.validation?.errorMessage;
1566
+ }
1567
+ /**
1568
+ * A field's stored bounds, with the custom message already resolved by the row.
967
1569
  *
968
- * @returns Array of supported operator names
1570
+ * Handed to the appliers so they cannot read an ungated message: they take a
1571
+ * validation object, and giving them one whose `errorMessage` has been through
1572
+ * {@link customMessage} makes the rule hold for every applier without each of
1573
+ * them having to remember it.
969
1574
  */
970
- function getSupportedComparisonOperators() {
971
- return [
972
- "equals",
973
- "notEquals",
974
- "contains",
975
- "isEmpty",
976
- "isNotEmpty",
977
- "greaterThan",
978
- "lessThan"
979
- ];
1575
+ function enforcedValidation(field) {
1576
+ return {
1577
+ ...field.validation ?? {},
1578
+ errorMessage: customMessage(field)
1579
+ };
980
1580
  }
981
-
982
- // src/utils/export-formats.ts
983
- /** UTF-8 BOM character sequence */
984
- const UTF8_BOM = "";
985
1581
  /**
986
- * Stringify a value safely, falling back to JSON for plain objects so we
987
- * never emit the default `[object Object]` representation.
1582
+ * Which rules a type's schema applies, and how each one constrains a string.
1583
+ *
1584
+ * The WHICH comes from {@link ENFORCED_VALIDATION_RULES}, which the field
1585
+ * editor also reads to decide what to offer. One definition answers both
1586
+ * questions, so a rule cannot be offered by the editor and ignored here, or
1587
+ * honoured here and hidden there — the two used to state the same thing
1588
+ * separately and could only agree by being edited together.
1589
+ *
1590
+ * The HOW stays here, because it is about zod and the table is deliberately
1591
+ * free of it: the editor imports the table, and pulling the schema builder in
1592
+ * behind it would ship this file and its validation library to every admin
1593
+ * client before anyone opened a field.
988
1594
  */
989
- function safeString(value) {
990
- if (typeof value === "object" && value !== null) return JSON.stringify(value);
991
- return String(value);
1595
+ const STRING_RULE_APPLIERS = {
1596
+ minLength: (schema, validation) => validation.minLength === void 0 ? schema : schema.min(validation.minLength, validation.errorMessage || `Minimum ${validation.minLength} characters required`),
1597
+ maxLength: (schema, validation) => validation.maxLength === void 0 ? schema : schema.max(validation.maxLength, validation.errorMessage || `Maximum ${validation.maxLength} characters allowed`),
1598
+ pattern: (schema, validation, fallback) => validation.pattern ? schema.regex(new RegExp(validation.pattern), validation.errorMessage || fallback) : schema
1599
+ };
1600
+ /** The same, for the bounds a number carries. */
1601
+ const NUMBER_RULE_APPLIERS = {
1602
+ min: (schema, validation) => validation.min === void 0 ? schema : schema.min(validation.min, validation.errorMessage || `Minimum value is ${validation.min}`),
1603
+ max: (schema, validation) => validation.max === void 0 ? schema : schema.max(validation.max, validation.errorMessage || `Maximum value is ${validation.max}`)
1604
+ };
1605
+ /**
1606
+ * Whether an author-supplied pattern actually replaces a type's intrinsic one.
1607
+ *
1608
+ * Two conditions, and both are load-bearing: a pattern must be stored, AND the
1609
+ * type's row must enforce `pattern`. A type that keeps its own shape check —
1610
+ * a phone, a URL — stands it down only for an override that will really be
1611
+ * applied, because standing down for one that will not leaves nothing behind.
1612
+ */
1613
+ function overridesPattern(field) {
1614
+ if (!(ENFORCED_VALIDATION_RULES[field.type] ?? []).includes("pattern")) return false;
1615
+ const validation = field.validation ?? {};
1616
+ return Boolean(validation.pattern);
992
1617
  }
993
1618
  /**
994
- * Format a field value for export based on field type.
1619
+ * Constrain a string schema by the rules this field's TYPE enforces.
995
1620
  *
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
1621
+ * A rule the type does not list is not applied even when the field carries a
1622
+ * value for it, which is the point: a `pattern` stored on a textarea is a bound
1623
+ * the editor never offered, and honouring it here would enforce a restriction
1624
+ * its author could not see.
999
1625
  *
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
- * ```
1626
+ * `patternMessage` is the type's own wording for a failed pattern — "Invalid
1627
+ * URL format" reads differently from "Invalid format" — so the rule set decides
1628
+ * WHICH constraints apply while each type keeps its own voice.
1006
1629
  */
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);
1630
+ function applyStringRules(schema, field, patternMessage) {
1631
+ const validation = enforcedValidation(field);
1632
+ let constrained = schema;
1633
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1634
+ const apply = STRING_RULE_APPLIERS[rule];
1635
+ if (apply) constrained = apply(constrained, validation, patternMessage);
1636
+ }
1637
+ return constrained;
1638
+ }
1639
+ function generateTextSchema(field) {
1640
+ return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
1641
+ }
1642
+ /**
1643
+ * Generate schema for email field.
1644
+ */
1645
+ function generateEmailSchema(field) {
1646
+ return applyRequired(applyStringRules(z.string().email(customMessage(field) || "Invalid email address"), field, "Invalid email format"), field.required, customMessage(field));
1647
+ }
1648
+ /**
1649
+ * Generate schema for number field.
1650
+ */
1651
+ function generateNumberSchema(field) {
1652
+ let schema = z.number({ error: (issue) => issue.input === void 0 ? customMessage(field) || "This field is required" : "Must be a number" });
1653
+ const validation = enforcedValidation(field);
1654
+ for (const rule of ENFORCED_VALIDATION_RULES[field.type] ?? []) {
1655
+ const apply = NUMBER_RULE_APPLIERS[rule];
1656
+ if (apply) schema = apply(schema, validation);
1657
+ }
1658
+ if (field.required) return schema;
1659
+ return schema.optional().nullable();
1660
+ }
1661
+ /**
1662
+ * Generate schema for phone field.
1663
+ */
1664
+ function generatePhoneSchema(field) {
1665
+ return applyRequired(applyStringRules(overridesPattern(field) ? z.string() : z.string().regex(/^[\d\s\-+()]+$/, customMessage(field) || "Invalid phone number"), field, "Invalid phone number format"), field.required, customMessage(field));
1666
+ }
1667
+ /**
1668
+ * Generate schema for URL field.
1669
+ */
1670
+ function generateUrlSchema(field) {
1671
+ return applyRequired(applyStringRules(z.string().url(customMessage(field) || "Invalid URL"), field, "Invalid URL format"), field.required, customMessage(field));
1672
+ }
1673
+ /**
1674
+ * Generate schema for textarea field.
1675
+ */
1676
+ function generateTextareaSchema(field) {
1677
+ return applyRequired(applyStringRules(z.string(), field, "Invalid format"), field.required, customMessage(field));
1678
+ }
1679
+ /**
1680
+ * Generate schema for select field.
1681
+ */
1682
+ function generateSelectSchema(field) {
1683
+ const validValues = field.options.map((opt) => opt.value);
1684
+ if (field.allowMultiple) {
1685
+ const schema = z.array(z.enum(validValues));
1686
+ if (field.required) return schema.min(1, customMessage(field) || "Please select at least one option");
1687
+ return schema.optional();
1026
1688
  }
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);
1689
+ const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select a valid option" });
1690
+ if (field.required) return schema;
1691
+ return schema.optional();
1031
1692
  }
1032
1693
  /**
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
1694
+ * Generate schema for checkbox field.
1038
1695
  */
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();
1696
+ function generateCheckboxSchema(field) {
1697
+ const schema = z.boolean();
1698
+ if (field.required) return schema.refine((val) => val === true, { message: customMessage(field) || "This field is required" });
1699
+ return schema.optional();
1044
1700
  }
1045
1701
  /**
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
1702
+ * Generate schema for radio field.
1055
1703
  */
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, "\"\"")}"`;
1704
+ function generateRadioSchema(field) {
1705
+ const validValues = field.options.map((opt) => opt.value);
1706
+ const schema = z.enum(validValues, { error: () => customMessage(field) || "Please select an option" });
1707
+ if (field.required) return schema;
1708
+ return schema.optional();
1060
1709
  }
1061
1710
  /**
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
- * });
1711
+ * Generate schema for file field.
1081
1712
  *
1082
- * // Download in browser
1083
- * downloadFile(csv, 'submissions.csv', 'text/csv;charset=utf-8');
1084
- * ```
1713
+ * Note: File validation is typically handled server-side.
1714
+ * This schema validates file references (IDs or URLs).
1085
1715
  */
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);
1716
+ function generateFileSchema(field) {
1717
+ if (field.multiple) {
1718
+ const schema = z.array(z.string());
1719
+ if (field.required) return schema.min(1, customMessage(field) || "Please upload at least one file");
1720
+ return schema.optional();
1109
1721
  }
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;
1722
+ return applyRequired(z.string(), field.required, customMessage(field));
1112
1723
  }
1113
1724
  /**
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
1725
+ * Generate schema for date field.
1726
+ */
1727
+ function generateDateSchema(field) {
1728
+ let schema = z.string();
1729
+ schema = schema.refine((val) => {
1730
+ if (!val) return true;
1731
+ const date = new Date(val);
1732
+ return !isNaN(date.getTime());
1733
+ }, { message: customMessage(field) || "Invalid date" });
1734
+ if (field.min || field.max) schema = schema.refine((val) => {
1735
+ if (!val) return true;
1736
+ const date = new Date(val);
1737
+ if (field.min && date < new Date(field.min)) return false;
1738
+ if (field.max && date > new Date(field.max)) return false;
1739
+ return true;
1740
+ }, { message: customMessage(field) || `Date must be between ${field.min || "any"} and ${field.max || "any"}` });
1741
+ if (field.required) return schema.refine((val) => val !== void 0 && val !== null && val !== "", { message: customMessage(field) || "This field is required" });
1742
+ return schema.optional().or(z.literal(""));
1743
+ }
1744
+ /**
1745
+ * Generate schema for time field.
1746
+ */
1747
+ function generateTimeSchema(field) {
1748
+ return applyRequired(z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, customMessage(field) || "Invalid time format (HH:mm)"), field.required, customMessage(field));
1749
+ }
1750
+ /**
1751
+ * Generate schema for hidden field.
1752
+ */
1753
+ function generateHiddenSchema(field) {
1754
+ return applyRequired(z.string(), field.required, customMessage(field));
1755
+ }
1756
+ /**
1757
+ * Apply required/optional to a schema.
1758
+ */
1759
+ function applyRequired(schema, required, errorMessage) {
1760
+ if (required) return schema.min(1, errorMessage || "This field is required");
1761
+ return schema.optional().or(z.literal(""));
1762
+ }
1763
+ /**
1764
+ * Transform form data before validation.
1126
1765
  *
1127
- * @example
1128
- * ```typescript
1129
- * const json = exportToJSON(submissions, form, {
1130
- * includeMetadata: true,
1131
- * includeFormDefinition: true,
1132
- * indent: 2,
1133
- * });
1766
+ * Handles type coercion and normalization:
1767
+ * - Converts string numbers to actual numbers
1768
+ * - Converts string booleans to actual booleans
1769
+ * - Trims string values
1770
+ * - Handles empty strings
1134
1771
  *
1135
- * // Download in browser
1136
- * downloadFile(json, 'submissions.json', 'application/json');
1137
- * ```
1772
+ * @param data - Raw form data
1773
+ * @param fields - Form field configurations
1774
+ * @returns Transformed data ready for validation
1138
1775
  */
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
1776
+ function transformFormData(data, fields) {
1777
+ const transformed = {};
1778
+ for (const field of fields) {
1779
+ const value = data[field.name];
1780
+ if (value === void 0) continue;
1781
+ transformed[field.name] = isKnownFormField(field) ? transformFieldValue(value, field) : value;
1782
+ }
1783
+ return transformed;
1784
+ }
1785
+ /**
1786
+ * Transform a single field value based on its type.
1787
+ */
1788
+ function transformFieldValue(value, field) {
1789
+ if (value === null || value === void 0) return value;
1790
+ switch (field.type) {
1791
+ case "number":
1792
+ if (typeof value === "string") {
1793
+ const num = parseFloat(value);
1794
+ return isNaN(num) ? value : num;
1169
1795
  }
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);
1796
+ return value;
1797
+ case "checkbox":
1798
+ if (typeof value === "string") return value === "true" || value === "1" || value === "on";
1799
+ return Boolean(value);
1800
+ case "text":
1801
+ case "email":
1802
+ case "phone":
1803
+ case "url":
1804
+ case "textarea":
1805
+ case "hidden":
1806
+ if (typeof value === "string") return value.trim();
1807
+ return value;
1808
+ case "select":
1809
+ if (field.allowMultiple) {
1810
+ if (!Array.isArray(value)) return value ? [value] : [];
1811
+ }
1812
+ return value;
1813
+ default: return value;
1814
+ }
1180
1815
  }
1181
1816
  /**
1182
- * Trigger a file download in the browser.
1817
+ * Validate form data against a form's field configuration.
1183
1818
  *
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.
1819
+ * Combines transformation and validation in one step.
1186
1820
  *
1187
- * @param content - File content as string
1188
- * @param filename - Name for the downloaded file
1189
- * @param mimeType - MIME type of the file
1821
+ * @param data - Raw form data
1822
+ * @param fields - Form field configurations
1823
+ * @returns Zod safe parse result
1190
1824
  *
1191
1825
  * @example
1192
1826
  * ```typescript
1193
- * const csv = exportToCSV(submissions, form);
1194
- * downloadFile(csv, 'contact-form-submissions.csv', 'text/csv;charset=utf-8');
1827
+ * const result = validateFormData({ name: 'John', email: 'john@example.com' }, fields);
1195
1828
  *
1196
- * const json = exportToJSON(submissions, form);
1197
- * downloadFile(json, 'submissions.json', 'application/json');
1829
+ * if (result.success) {
1830
+ * console.log('Valid data:', result.data);
1831
+ * } else {
1832
+ * console.log('Validation errors:', result.error.flatten());
1833
+ * }
1198
1834
  * ```
1199
1835
  */
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);
1836
+ function validateFormData(data, fields) {
1837
+ const transformed = transformFormData(data, fields);
1838
+ return generateZodSchema(fields).safeParse(transformed);
1211
1839
  }
1212
1840
  /**
1213
- * Generate a filename for export.
1214
- *
1215
- * Creates a filename with form slug and current timestamp.
1841
+ * Whether the visitor supplied nothing at this field.
1216
1842
  *
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
- * ```
1843
+ * The same four shapes the endpoint has always counted as blank. A `false`
1844
+ * checkbox and a `0` are answers, so neither is absent.
1226
1845
  */
1227
- function generateExportFilename(formSlug, format) {
1228
- return `${formSlug.toLowerCase().replace(/[^a-z0-9_-]+/g, "-") || "form"}-submissions-${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}.${format}`;
1846
+ function isBlank(value) {
1847
+ return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
1229
1848
  }
1230
1849
  /**
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
1850
+ * The failures with their codes, first one per field.
1851
+ *
1852
+ * A code as well as a sentence, because the public submission error has always
1853
+ * carried one and a client uses it to tell "you left this blank" from "this is
1854
+ * not an email" without parsing English. Flattening every failure to `INVALID`
1855
+ * silently stopped required-field detection working for those clients.
1856
+ *
1857
+ * The code is decided by looking at the SUBMITTED VALUE, not at the Zod issue.
1858
+ * Zod's codes do not divide the same way: `too_small` covers a blank answer and
1859
+ * a four-character password under a minimum of eight, and `invalid_type` covers
1860
+ * an absent key and a string sent to a number field. Reading the code alone
1861
+ * told a visitor to fill in a field they had already filled in.
1862
+ */
1863
+ function getValidationIssues(result, submitted = {}) {
1864
+ if (result.success) return [];
1865
+ const seen = /* @__PURE__ */ new Set();
1866
+ const issues = [];
1867
+ for (const issue of result.error.issues) {
1868
+ const path = issue.path.join(".");
1869
+ if (seen.has(path)) continue;
1870
+ seen.add(path);
1871
+ issues.push({
1872
+ path,
1873
+ code: isBlank(submitted[path]) ? "REQUIRED" : "INVALID",
1874
+ message: issue.message
1875
+ });
1876
+ }
1877
+ return issues;
1878
+ }
1879
+ /**
1880
+ * The same failures as a field-to-message map.
1240
1881
  *
1241
- * @example
1242
- * ```typescript
1243
- * // In a React component
1244
- * const handleExport = (format: 'csv' | 'json') => {
1245
- * exportAndDownload(submissions, form, format);
1246
- * };
1247
- * ```
1882
+ * Derived from {@link getValidationIssues} rather than walking the issues a
1883
+ * second time, so the two cannot disagree about which message belongs to a
1884
+ * field or which failure won when one field had several.
1248
1885
  */
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");
1886
+ function getValidationErrors(result) {
1887
+ return Object.fromEntries(getValidationIssues(result).map((issue) => [issue.path, issue.message]));
1888
+ }
1889
+
1890
+ // src/utils/one-response-field.ts
1891
+ /** Why the chosen field cannot be made optional, conditional, or deleted. */
1892
+ const ONE_RESPONSE_FIELD_REASON = "This field is used for one response per person, so it stays required and always shown. Choose another Email field in Settings first.";
1893
+ /** Whether `field` can be the Email field one response per email compares. */
1894
+ function comparesByEmail(field) {
1895
+ return field.type === "email" && field.required === true && field.conditionalLogic?.enabled !== true;
1896
+ }
1897
+ /** The fields one response per email may compare, in the form's order. */
1898
+ function emailFieldsToCompare(fields) {
1899
+ return fields.filter(comparesByEmail);
1900
+ }
1901
+ /**
1902
+ * Why a form's one response per email cannot stand as saved, or undefined
1903
+ * when it can (or the form does not compare by email).
1904
+ */
1905
+ function oneResponseFieldProblem(settings, fields) {
1906
+ if (settings.oneResponse !== "email") return void 0;
1907
+ const name = settings.oneResponseField;
1908
+ if (!name) return "One response per email address needs an Email field to compare. Choose one in Settings.";
1909
+ const field = fields.find((each) => each.name === name);
1910
+ if (!field || field.type !== "email") return `One response per email address compares "${name}", which is not an Email field of this form.`;
1911
+ return comparesByEmail(field) ? void 0 : `The Email field one response per person compares, "${field.label || name}", must be required and not hidden by a condition.`;
1912
+ }
1913
+ /**
1914
+ * Why an edit of `before` into `after` is refused, or undefined: the field
1915
+ * one response per email compares may be renamed but not made optional,
1916
+ * conditional or another type.
1917
+ */
1918
+ function oneResponseEditProblem(before, after, settings) {
1919
+ if (settings.oneResponse !== "email") return void 0;
1920
+ if (settings.oneResponseField !== before.name) return void 0;
1921
+ return comparesByEmail(after) ? void 0 : ONE_RESPONSE_FIELD_REASON;
1253
1922
  }
1254
1923
 
1255
1924
  // src/utils/field-references.ts
@@ -1273,12 +1942,31 @@ function parseFieldRef(value) {
1273
1942
  * Match `{{ fieldName }}` interpolations (whitespace-tolerant), the syntax the
1274
1943
  * notification send path resolves recipients and reply-to values with.
1275
1944
  */
1945
+ function templatePattern(fieldName, flags) {
1946
+ return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`, flags);
1947
+ }
1276
1948
  function referencesInTemplate(template, fieldName) {
1277
- return new RegExp(`\\{\\{\\s*${fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}\\}`).test(template);
1949
+ return templatePattern(fieldName).test(template);
1950
+ }
1951
+ /** The label a setting that refers to a field is named by. */
1952
+ const ONE_RESPONSE_SETTING = "One response per person";
1953
+ /**
1954
+ * The field one response per email compares, when the form takes one
1955
+ * response per email address; none otherwise.
1956
+ */
1957
+ function oneResponseEmailField(settings) {
1958
+ return settings?.oneResponse === "email" ? settings.oneResponseField : void 0;
1278
1959
  }
1279
- /** All references to `fieldName` across the form's fields and notifications. */
1280
- function findFieldReferences(fieldName, fields, notifications) {
1960
+ /**
1961
+ * All references to `fieldName` across the form's fields, notifications and
1962
+ * settings (the Email field one response per person compares).
1963
+ */
1964
+ function findFieldReferences(fieldName, fields, notifications, settings) {
1281
1965
  const references = [];
1966
+ if (oneResponseEmailField(settings) === fieldName) references.push({
1967
+ kind: "setting",
1968
+ label: ONE_RESPONSE_SETTING
1969
+ });
1282
1970
  for (const field of fields) {
1283
1971
  if (field.name === fieldName) continue;
1284
1972
  if (!field.conditionalLogic?.enabled) continue;
@@ -1308,11 +1996,55 @@ function findFieldReferences(fieldName, fields, notifications) {
1308
1996
  * per-card lookup is O(1) instead of re-walking fields and notifications for
1309
1997
  * each card on every render.
1310
1998
  */
1311
- function buildFieldReferenceMap(fields, notifications) {
1999
+ function buildFieldReferenceMap(fields, notifications, settings) {
1312
2000
  const map = /* @__PURE__ */ new Map();
1313
- for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications));
2001
+ for (const field of fields) map.set(field.name, findFieldReferences(field.name, fields, notifications, settings));
1314
2002
  return map;
1315
2003
  }
2004
+ /**
2005
+ * The fields with every condition on `from` naming `to` instead: the
2006
+ * conditions `findFieldReferences` reads, disabled ones too, so a rename never
2007
+ * leaves one pointing at a field that no longer has that name.
2008
+ */
2009
+ function renameInFieldConditions(fields, from, to) {
2010
+ return fields.map((field) => {
2011
+ const logic = field.conditionalLogic;
2012
+ if (!logic?.conditions?.some((condition) => condition.field === from)) return field;
2013
+ return {
2014
+ ...field,
2015
+ conditionalLogic: {
2016
+ ...logic,
2017
+ conditions: logic.conditions.map((condition) => condition.field === from ? {
2018
+ ...condition,
2019
+ field: to
2020
+ } : condition)
2021
+ }
2022
+ };
2023
+ });
2024
+ }
2025
+ /**
2026
+ * The notifications with every reference to `from` naming `to` instead: the
2027
+ * addresses (`{{ from }}`) and the condition `findFieldReferences` reads.
2028
+ */
2029
+ function renameInNotifications(notifications, from, to) {
2030
+ const pattern = templatePattern(from, "g");
2031
+ const rename = (template) => template.replace(pattern, `{{${to}}}`);
2032
+ return notifications.map((notification) => {
2033
+ const { cc, bcc } = notification;
2034
+ const next = {
2035
+ ...notification,
2036
+ to: rename(notification.to)
2037
+ };
2038
+ if (cc) next.cc = cc.map(rename);
2039
+ if (bcc) next.bcc = bcc.map(rename);
2040
+ if (notification.replyTo !== void 0) next.replyTo = rename(notification.replyTo);
2041
+ if (notification.condition?.field === from) next.condition = {
2042
+ ...notification.condition,
2043
+ field: to
2044
+ };
2045
+ return next;
2046
+ });
2047
+ }
1316
2048
 
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
2049
+ export { SCHEMA_ONLY_READ as $, applyFieldDefaults as A, downloadFile as B, isKnownFormField as C, normalizeFormSettings as D, fieldsHiddenByCondition as E, warnIfWebhooksConfigured as F, generateExportFilename as G, exportToCSV as H, FORM_CONFIRMATION_TEMPLATE as I, getSupportedComparisonOperators as J, evaluateConditions as K, FORM_SUBMISSION_TEMPLATE as L, createFormConfig as M, pluralize as N, DEFAULT_FORM_SETTINGS as O, toTitleCase as P, SCHEMA_ONLY_CONTEXT as Q, formsEmailTemplates as R, isFormFieldType as S, isTextFormField as T, exportToJSON as U, exportAndDownload as V, formatExportValue as W, NEW_SUBMISSIONS_WIDGET_ID as X, isValidComparisonOperator as Y, readNewSubmissions as Z, BUILT_IN_FORM_FIELD_TYPES as _, renameInNotifications as a, addressedFormPages as at, isFileFormField as b, emailFieldsToCompare as c, generateZodSchema as d, documentReachability as et, getValidationErrors as f, enforcedValidationRules as g, validateFormData as h, renameInFieldConditions as i, FORM_PAGES as it, applyFormDefaults as j, DEFAULT_NOTIFICATION_SETTINGS as k, oneResponseEditProblem as l, transformFormData as m, oneResponseEmailField as n, pickedDocumentField as nt, ONE_RESPONSE_FIELD_REASON as o, formPageId as ot, getValidationIssues as p, evaluateSingleCondition as q, parseFieldRef as r, FORM_EDITOR_VIEW as rt, comparesByEmail as s, formPageOf as st, buildFieldReferenceMap as t, parseRedirectReference as tt, oneResponseFieldProblem as u, isCheckboxFormField as v, isSelectFormField as w, isFormField as x, isEmailFormField as y, starterTemplateFor as z };
2050
+ //# sourceMappingURL=field-references-oCuFEwkr.js.map