@nextlyhq/plugin-form-builder 0.0.2-alpha.73 → 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.
- package/README.md +2 -1
- package/dist/admin/index.d.ts +88 -65
- package/dist/admin/index.js +3512 -1458
- package/dist/admin/index.js.map +1 -1
- package/dist/{field-references-CtzGg51W.js → field-references-oCuFEwkr.js} +1256 -524
- package/dist/field-references-oCuFEwkr.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +1900 -408
- package/dist/index.js.map +1 -1
- package/dist/{new-submissions-CzlhVGhH.d.ts → new-submissions-DGiIhdBF.d.ts} +96 -5
- package/package.json +10 -10
- package/dist/field-references-CtzGg51W.js.map +0 -1
|
@@ -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/
|
|
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
|
-
*
|
|
413
|
-
*
|
|
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
|
|
417
|
-
|
|
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
|
-
*
|
|
466
|
+
* Converts a value to a number for numeric comparisons.
|
|
467
|
+
* Returns NaN if conversion is not possible.
|
|
421
468
|
*/
|
|
422
|
-
function
|
|
423
|
-
|
|
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
|
-
*
|
|
479
|
+
* Converts a value to a string for string comparisons.
|
|
427
480
|
*/
|
|
428
|
-
function
|
|
429
|
-
|
|
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
|
-
*
|
|
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
|
|
435
|
-
|
|
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
|
-
*
|
|
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
|
|
441
|
-
|
|
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
|
-
*
|
|
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
|
|
447
|
-
|
|
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
|
-
*
|
|
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
|
|
453
|
-
|
|
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
|
-
*
|
|
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
|
|
459
|
-
return
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
"
|
|
475
|
-
"
|
|
476
|
-
"
|
|
477
|
-
"
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
504
|
-
*
|
|
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
|
|
507
|
-
|
|
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 `
|
|
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
|
|
734
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
757
|
-
/**
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
*
|
|
769
|
-
*
|
|
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
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
*
|
|
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
|
|
784
|
-
|
|
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
|
-
*
|
|
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
|
|
799
|
-
|
|
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
|
-
*
|
|
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
|
|
854
|
-
|
|
855
|
-
return compareValues(comparison, getNestedValue(formData, field), conditionValue);
|
|
1404
|
+
function isFormFieldType(field, type) {
|
|
1405
|
+
return field.type === type;
|
|
856
1406
|
}
|
|
857
1407
|
/**
|
|
858
|
-
*
|
|
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
|
|
865
|
-
|
|
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
|
-
*
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
*
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
*
|
|
902
|
-
*
|
|
903
|
-
|
|
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
|
-
*
|
|
906
|
-
*
|
|
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
|
-
*
|
|
916
|
-
*
|
|
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
|
-
*
|
|
922
|
-
*
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
*
|
|
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
|
-
*
|
|
933
|
-
*
|
|
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
|
|
938
|
-
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
-
*
|
|
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
|
|
955
|
-
|
|
956
|
-
"
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
961
|
-
"
|
|
962
|
-
"
|
|
963
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
971
|
-
return
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
*
|
|
987
|
-
*
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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
|
-
*
|
|
1619
|
+
* Constrain a string schema by the rules this field's TYPE enforces.
|
|
995
1620
|
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
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
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
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
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1028
|
-
if (
|
|
1029
|
-
|
|
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
|
-
*
|
|
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
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
*
|
|
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
|
|
1057
|
-
const
|
|
1058
|
-
|
|
1059
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1083
|
-
*
|
|
1084
|
-
* ```
|
|
1713
|
+
* Note: File validation is typically handled server-side.
|
|
1714
|
+
* This schema validates file references (IDs or URLs).
|
|
1085
1715
|
*/
|
|
1086
|
-
function
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1111
|
-
return includeBOM ? UTF8_BOM + csvContent : csvContent;
|
|
1722
|
+
return applyRequired(z.string(), field.required, customMessage(field));
|
|
1112
1723
|
}
|
|
1113
1724
|
/**
|
|
1114
|
-
*
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
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
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1772
|
+
* @param data - Raw form data
|
|
1773
|
+
* @param fields - Form field configurations
|
|
1774
|
+
* @returns Transformed data ready for validation
|
|
1138
1775
|
*/
|
|
1139
|
-
function
|
|
1140
|
-
const
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
name:
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
*
|
|
1817
|
+
* Validate form data against a form's field configuration.
|
|
1183
1818
|
*
|
|
1184
|
-
*
|
|
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
|
|
1188
|
-
* @param
|
|
1189
|
-
* @
|
|
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
|
|
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
|
-
*
|
|
1197
|
-
*
|
|
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
|
|
1201
|
-
|
|
1202
|
-
|
|
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
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
* Creates a filename with form slug and current timestamp.
|
|
1841
|
+
* Whether the visitor supplied nothing at this field.
|
|
1216
1842
|
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
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
|
|
1228
|
-
return
|
|
1846
|
+
function isBlank(value) {
|
|
1847
|
+
return value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
1229
1848
|
}
|
|
1230
1849
|
/**
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1238
|
-
*
|
|
1239
|
-
*
|
|
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
|
-
* @
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
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
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
|
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
|
-
/**
|
|
1280
|
-
|
|
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 {
|
|
1318
|
-
//# sourceMappingURL=field-references-
|
|
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
|