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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -677,6 +677,238 @@ function getSupportedComparisonOperators() {
677
677
  ];
678
678
  }
679
679
 
680
+ // src/utils/condition-visibility.ts
681
+ /** The names a field's enabled condition reads; none when it has none. */
682
+ function namesRead(field) {
683
+ const logic = field.conditionalLogic;
684
+ return logic?.enabled === true ? logic.conditions.map((condition) => condition.field) : [];
685
+ }
686
+ /**
687
+ * Which of a form's fields its conditions hide, for a given set of answers.
688
+ *
689
+ * A condition reads the answers of fields that are themselves shown, so a
690
+ * hidden field's leftover answer never decides another field, and it may read
691
+ * a field before or after its own. Fields are decided in the order their
692
+ * conditions depend on each other, whatever their order on the form.
693
+ *
694
+ * Conditions can read each other in a loop (A shown when B is answered, B
695
+ * shown when A is), and no order settles a loop. The rule for one: fields in
696
+ * the same loop read each other's answers as given, and only the loop's
697
+ * fields decide it, so an unrelated field elsewhere on the form never changes
698
+ * the outcome.
699
+ *
700
+ * A field of type `hidden` is not one of them: it is sent with every
701
+ * submission, unseen.
702
+ */
703
+ function fieldsHiddenByCondition(fields, answers) {
704
+ const byName = new Map(fields.map((field) => [field.name, field]));
705
+ const reach = /* @__PURE__ */ new Map();
706
+ /** Every field `name`'s condition depends on, directly or through others. */
707
+ const dependsOn = (name) => {
708
+ const known = reach.get(name);
709
+ if (known) return known;
710
+ const found = /* @__PURE__ */ new Set();
711
+ reach.set(name, found);
712
+ const pending = [name];
713
+ for (let next = pending.pop(); next !== void 0; next = pending.pop()) {
714
+ const field = byName.get(next);
715
+ for (const read of field ? namesRead(field) : []) if (!found.has(read)) {
716
+ found.add(read);
717
+ pending.push(read);
718
+ }
719
+ }
720
+ return found;
721
+ };
722
+ const shown = /* @__PURE__ */ new Map();
723
+ /** Whether field `name` is on the page, deciding what it reads first. */
724
+ const isShown = (name) => {
725
+ const decided = shown.get(name);
726
+ if (decided !== void 0) return decided;
727
+ const field = byName.get(name);
728
+ const logic = field?.conditionalLogic;
729
+ if (!field || logic?.enabled !== true) return true;
730
+ const read = Object.fromEntries(Object.entries(answers).filter(([other]) => !byName.has(other) || !namesRead(field).includes(other) || dependsOn(other).has(name) || isShown(other)));
731
+ const result = evaluateConditions(logic, read);
732
+ shown.set(name, result);
733
+ return result;
734
+ };
735
+ return new Set(fields.filter((field) => !isShown(field.name)).map((field) => field.name));
736
+ }
737
+
738
+ // src/utils/asked-fields.ts
739
+ /** A stored option value or label as text: a string or a number. */
740
+ function textOf(value) {
741
+ if (typeof value === "string") return value;
742
+ return typeof value === "number" ? String(value) : "";
743
+ }
744
+ /** One form field as a submission keeps it. */
745
+ function askedField(field) {
746
+ const options = field.options;
747
+ return {
748
+ name: field.name,
749
+ label: field.label || field.name,
750
+ type: field.type,
751
+ ...Array.isArray(options) ? { options: options.map((option) => {
752
+ const { value, label } = option;
753
+ const text = textOf(value);
754
+ return {
755
+ value: text,
756
+ label: textOf(label) || text
757
+ };
758
+ }) } : {}
759
+ };
760
+ }
761
+ /** What a new submission with `data` was asked, from its form's fields. */
762
+ function askedSnapshotOf(fields, data) {
763
+ const hidden = fieldsHiddenByCondition([...fields], data);
764
+ return {
765
+ fields: fields.map(askedField),
766
+ hidden: fields.map((field) => field.name).filter((name) => hidden.has(name))
767
+ };
768
+ }
769
+ /** A stored snapshot, read: as written, or a JSON string of it. */
770
+ function readAskedSnapshot(value) {
771
+ let parsed = value;
772
+ if (typeof parsed === "string") try {
773
+ parsed = JSON.parse(parsed);
774
+ } catch {
775
+ return;
776
+ }
777
+ if (!parsed || typeof parsed !== "object") return void 0;
778
+ const { fields, hidden } = parsed;
779
+ if (!Array.isArray(fields)) return void 0;
780
+ return {
781
+ fields: fields.filter((field) => !!field && typeof field.name === "string" && typeof field.label === "string" && typeof field.type === "string"),
782
+ hidden: Array.isArray(hidden) ? hidden.filter((name) => typeof name === "string") : []
783
+ };
784
+ }
785
+ /**
786
+ * The snapshot after staff edit answers. An answer that changed is read with
787
+ * the field as it was when edited to, so an edit to an option added later
788
+ * keeps that option's label; every other field stays as the visitor was
789
+ * asked it. A submission with no snapshot keeps none.
790
+ */
791
+ function askedAfterEdit(stored, fields, before, after) {
792
+ if (stored === void 0) return void 0;
793
+ const changed = new Set(fields.map((field) => field.name).filter((name) => JSON.stringify(before[name]) !== JSON.stringify(after[name])));
794
+ if (changed.size === 0) return stored;
795
+ const current = new Map(fields.map((field) => [field.name, field]));
796
+ const kept = stored.fields.map((field) => changed.has(field.name) && current.has(field.name) ? askedField(current.get(field.name)) : field);
797
+ for (const name of changed) if (!kept.some((field) => field.name === name) && current.has(name)) kept.push(askedField(current.get(name)));
798
+ return {
799
+ fields: kept,
800
+ hidden: stored.hidden.filter((name) => !changed.has(name))
801
+ };
802
+ }
803
+ /**
804
+ * The fields a submission's page reads its answers with: as it was asked, or
805
+ * the form as it is now for one stored before submissions kept that.
806
+ */
807
+ function askedFieldsOf(stored, formFields, data) {
808
+ return readAskedSnapshot(stored) ?? askedSnapshotOf(formFields, data);
809
+ }
810
+
811
+ // src/utils/consent.ts
812
+ /** Said when an edit of a submission would change a consent. */
813
+ const CONSENT_EDIT_REFUSED = "A consent cannot be edited: it keeps what the visitor agreed to";
814
+ /** Whether a value is a stored consent answer. */
815
+ function isConsentAnswer(value) {
816
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
817
+ const answer = value;
818
+ return typeof answer.agreed === "boolean" && typeof answer.text === "string";
819
+ }
820
+ /**
821
+ * Whether the visitor agreed. Only an explicit yes counts: a box sends `on`,
822
+ * a script `true`; anything else, a stray string included, is not consent.
823
+ */
824
+ function agreedIn(value) {
825
+ if (value === true) return true;
826
+ if (typeof value === "string") return value === "true" || value === "1" || value === "on";
827
+ if (isConsentAnswer(value)) return value.agreed;
828
+ return false;
829
+ }
830
+ /**
831
+ * The answer to store for what the visitor sent. The text is the form's, so a
832
+ * visitor who posts an answer object with a text of their own still stores
833
+ * the text the form shows.
834
+ */
835
+ function consentAnswerOf(value, field) {
836
+ return {
837
+ agreed: agreedIn(value),
838
+ text: field.agreementText
839
+ };
840
+ }
841
+ function sameConsent(before, after) {
842
+ if (before === void 0) return after === void 0;
843
+ if (isConsentAnswer(before)) return isConsentAnswer(after) && before.agreed === after.agreed && before.text === after.text;
844
+ return JSON.stringify(before) === JSON.stringify(after);
845
+ }
846
+ /**
847
+ * The answers of a stored submission that are consents: each consent field
848
+ * of the form, and any answer stored as a consent, whatever field now has
849
+ * its name. Read from the submission as well as the form, since a form can
850
+ * lose its consent field, or give its name to another field, while its
851
+ * submissions keep what was agreed to.
852
+ */
853
+ function storedConsentNames(fields, stored) {
854
+ const names = new Set(fields.filter((field) => field.type === "consent").map((field) => field.name));
855
+ for (const [name, value] of Object.entries(stored)) if (isConsentAnswer(value)) names.add(name);
856
+ return [...names];
857
+ }
858
+ /**
859
+ * The consents whose stored answer an edit would change: the edit's answers
860
+ * against the stored ones, for each consent the submission holds. One left
861
+ * out of the edit is a change too, since it would drop the answer.
862
+ */
863
+ function editedConsents(fields, stored, edited) {
864
+ return storedConsentNames(fields, stored).filter((name) => !sameConsent(stored[name], edited[name]));
865
+ }
866
+ /** Said when the builder is asked to make a consent optional. */
867
+ const CONSENT_REQUIRED_REASON = "A consent is always required: an answer nobody has to give records nothing";
868
+ /**
869
+ * A field as the builder keeps it after an edit, or why the edit is refused.
870
+ * A consent stays required, so turning Required off is refused and said; a
871
+ * field just made a consent becomes required, with a text to agree to.
872
+ */
873
+ function consentFieldEdit(before, after) {
874
+ if (after.type !== "consent") return { field: after };
875
+ if (before.type === "consent" && after.required !== true) return { refused: CONSENT_REQUIRED_REASON };
876
+ const text = after.agreementText;
877
+ return { field: {
878
+ ...after,
879
+ required: true,
880
+ agreementText: typeof text === "string" ? text : "I agree to the privacy policy"
881
+ } };
882
+ }
883
+ /** Said when a consent has no text to agree to. */
884
+ const CONSENT_TEXT_REQUIRED = "A consent field needs the text the visitor agrees to.";
885
+ /**
886
+ * The consent fields with no text to agree to. A consent with a blank text
887
+ * would record agreement to nothing, so a form holding one is not saved, by
888
+ * the builder or any other door.
889
+ */
890
+ function blankConsentFields(fields) {
891
+ return fields.filter((field) => field.type === "consent" && (typeof field.agreementText !== "string" || field.agreementText.trim() === ""));
892
+ }
893
+ /** Said when a visitor agreed to a text the form no longer shows. */
894
+ const CONSENT_TEXT_CHANGED = "The text to agree to has changed since the form was loaded. Review it and agree again.";
895
+ /**
896
+ * The consent fields a new submission agreed to under a text the form no
897
+ * longer shows. A visitor who sends the text they were shown (an answer of
898
+ * `{ agreed, text }`) is held to it: if the author changed it while the form
899
+ * was open, the submission is refused rather than recorded as agreement to
900
+ * terms the visitor never saw. A bare yes carries no text, so it is taken
901
+ * as agreement to the text the form shows now.
902
+ */
903
+ function staleConsentNames(fields, data) {
904
+ return fields.filter((field) => field.type === "consent").filter((field) => {
905
+ const sent = data[field.name];
906
+ if (!sent || typeof sent !== "object" || Array.isArray(sent)) return false;
907
+ const text = sent.text;
908
+ return typeof text === "string" && text !== field.agreementText;
909
+ }).map((field) => field.name);
910
+ }
911
+
680
912
  // src/utils/export-formats.ts
681
913
  /** UTF-8 BOM character sequence */
682
914
  const UTF8_BOM = "";
@@ -721,6 +953,9 @@ function formatExportValue(value, field) {
721
953
  return safeString(value);
722
954
  case "time": return safeString(value);
723
955
  case "hidden": return safeString(value);
956
+ case "consent":
957
+ if (isConsentAnswer(value)) return `${value.agreed ? "Agreed" : "Not agreed"}: "${value.text}"`;
958
+ return safeString(value);
724
959
  }
725
960
  if (Array.isArray(value)) return value.join(", ");
726
961
  if (typeof value === "boolean") return value ? "Yes" : "No";
@@ -728,6 +963,40 @@ function formatExportValue(value, field) {
728
963
  return safeString(value);
729
964
  }
730
965
  /**
966
+ * A choice answer by its options' labels as asked, several joined. A value
967
+ * no option has (an option removed since) reads as itself.
968
+ */
969
+ function optionLabels(field, value) {
970
+ return (Array.isArray(value) ? value : [value]).map((each) => {
971
+ const text = typeof each === "string" ? each : formatExportValue(each);
972
+ return field.options?.find((option) => option.value === text)?.label ?? text;
973
+ }).join(", ");
974
+ }
975
+ /** The kinds of field answered by picking among options. */
976
+ const CHOICE_TYPES = /* @__PURE__ */ new Set(["select", "radio"]);
977
+ /**
978
+ * An answer as one line of text, read with the field as it was asked: a
979
+ * choice by its options' labels, so a list and an export read "Backend
980
+ * engineer" rather than the stored `backend-engineer`; anything else as
981
+ * `formatExportValue` reads it.
982
+ */
983
+ function answerText(value, field) {
984
+ if (value === null || value === void 0) return "";
985
+ return CHOICE_TYPES.has(field.type) ? optionLabels(field, value) : formatExportValue(value, field);
986
+ }
987
+ /**
988
+ * A submission's answers as text, each read with its field as the submission
989
+ * was asked it, or as the form has it now for a field it was not asked (one
990
+ * added since) or a submission stored before submissions kept that. The
991
+ * submissions list and the CSV export both read answers through this, so the
992
+ * two cannot show one answer differently.
993
+ */
994
+ function answerReader(submission, formFields) {
995
+ const data = submission.data ?? {};
996
+ const asked = new Map(askedFieldsOf(submission.asked, formFields, data).fields.map((each) => [each.name, each]));
997
+ return (field) => answerText(data[field.name], asked.get(field.name) ?? askedField(field));
998
+ }
999
+ /**
731
1000
  * Format a date for export.
732
1001
  *
733
1002
  * @param date - Date value (Date object or string)
@@ -760,7 +1029,8 @@ function escapeCSVValue(value, delimiter = ",") {
760
1029
  * Export submissions to CSV format.
761
1030
  *
762
1031
  * Generates an RFC 4180 compliant CSV string with:
763
- * - Form field values as columns (in form definition order)
1032
+ * - Form field values as columns (in form definition order), headed by the
1033
+ * fields' labels, a choice answer by its option's label
764
1034
  * - Optional metadata columns at the end (_id, _status, _submittedAt, etc.)
765
1035
  * - Proper escaping of special characters
766
1036
  * - Optional UTF-8 BOM for Excel compatibility
@@ -790,12 +1060,8 @@ function exportToCSV(submissions, form, options = {}) {
790
1060
  const rows = [];
791
1061
  for (const submission of submissions) {
792
1062
  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
- }
1063
+ const answer = answerReader(submission, exportableFields);
1064
+ for (const field of exportableFields) row.push(escapeCSVValue(answer(field), delimiter));
799
1065
  if (includeMetadata) {
800
1066
  row.push(escapeCSVValue(submission.id, delimiter));
801
1067
  row.push(escapeCSVValue(submission.status, delimiter));
@@ -1307,62 +1573,149 @@ function normalizeFormSettings(raw) {
1307
1573
  };
1308
1574
  }
1309
1575
 
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) : [];
1576
+ // src/utils/send-status.ts
1577
+ /** The states in which another attempt may still come. */
1578
+ const IN_PROGRESS = /* @__PURE__ */ new Set([
1579
+ "waiting",
1580
+ "retrying",
1581
+ "sending"
1582
+ ]);
1583
+ /** Where a send stands, an unrecorded one read as waiting. */
1584
+ function stateOf(send) {
1585
+ return send.state ?? "waiting";
1586
+ }
1587
+ /** Whether another attempt at `send` may still come. */
1588
+ function sendInProgress(send) {
1589
+ return IN_PROGRESS.has(stateOf(send));
1590
+ }
1591
+ /** The recipients a send lists: those it went to, else those it was for. */
1592
+ function listedOf(send) {
1593
+ const sent = send.recipients ?? [];
1594
+ return sent.length > 0 ? sent : send.configured ?? [];
1595
+ }
1596
+ /** A recipient's state in one send, from its outcome and the send's state. */
1597
+ function recipientState(send, recipient) {
1598
+ if (recipient.outcome === "accepted") return "sent";
1599
+ const state = stateOf(send);
1600
+ if (state === "sent") return "failed";
1601
+ return state;
1602
+ }
1603
+ /** One recipient's result in one send. */
1604
+ function statusIn(send, recipient) {
1605
+ const state = recipientState(send, recipient);
1606
+ return {
1607
+ digest: recipient.digest,
1608
+ hint: recipient.hint,
1609
+ kind: recipient.kind,
1610
+ source: recipient.source,
1611
+ state,
1612
+ retries: state === "sent" ? recipient.retries ?? 0 : 0,
1613
+ at: send.changedAt ?? send.createdAt ?? null,
1614
+ ...state === "cancelled" && send.cancel ? { cancel: send.cancel } : {}
1615
+ };
1315
1616
  }
1316
1617
  /**
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.
1618
+ * Every recipient a notification's sends had for a submission, each with its
1619
+ * latest result, oldest first. A recipient a send left out (one Resend found
1620
+ * already reached) keeps the result it had.
1332
1621
  */
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;
1622
+ function latestByRecipient(sends) {
1623
+ const latest = /* @__PURE__ */ new Map();
1624
+ for (const send of sends) for (const recipient of listedOf(send)) {
1625
+ if (recipient.outcome === "skipped") continue;
1626
+ latest.set(recipient.digest, statusIn(send, recipient));
1627
+ }
1628
+ return [...latest.values()];
1629
+ }
1630
+ /**
1631
+ * Who Resend can send a notification to now: each recipient the notification
1632
+ * addresses as it is now (`configured`), then each recipient a send filter
1633
+ * added that its sends have not reached, since the filters add it again to
1634
+ * the next send. Whether each has received it is read from `history`, the
1635
+ * latest result per recipient.
1636
+ */
1637
+ function addressableNow(configured, history) {
1638
+ const latest = new Map(history.map((status) => [status.digest, status]));
1639
+ const now = configured.map((recipient) => ({
1640
+ digest: recipient.digest,
1641
+ hint: recipient.hint,
1642
+ source: recipient.source,
1643
+ reached: latest.get(recipient.digest)?.state === "sent"
1644
+ }));
1645
+ const listed = new Set(now.map((recipient) => recipient.digest));
1646
+ const missedAdded = history.filter((status) => status.source === "added" && status.state !== "sent" && !listed.has(status.digest)).map((status) => ({
1647
+ digest: status.digest,
1648
+ hint: status.hint,
1649
+ source: status.source,
1650
+ reached: false
1651
+ }));
1652
+ return [...now, ...missedAdded];
1653
+ }
1654
+ /**
1655
+ * The recipients a resend leaves out: each one `history` shows has received
1656
+ * it, except those ticked to send it to again.
1657
+ */
1658
+ function reachedExcept(history, ticked) {
1659
+ const again = new Set(ticked);
1660
+ return history.filter((status) => status.state === "sent" && !again.has(status.digest)).map((status) => status.digest);
1661
+ }
1662
+ /** A recipient's status in words. */
1663
+ function recipientLook(status) {
1664
+ switch (status.state) {
1665
+ case "sent": return status.retries === 0 ? {
1666
+ label: "Sent",
1667
+ tone: "ok"
1668
+ } : {
1669
+ label: `Sent after ${status.retries} ${status.retries === 1 ? "retry" : "retries"}`,
1670
+ tone: "warn"
1671
+ };
1672
+ case "failed": return {
1673
+ label: "Failed",
1674
+ tone: "bad"
1675
+ };
1676
+ case "cancelled": return {
1677
+ label: "Cancelled",
1678
+ tone: "quiet"
1679
+ };
1680
+ case "sending": return {
1681
+ label: "Sending",
1682
+ tone: "quiet"
1683
+ };
1684
+ case "retrying": return {
1685
+ label: "Retrying",
1686
+ tone: "warn"
1687
+ };
1688
+ default: return {
1689
+ label: "Waiting",
1690
+ tone: "quiet"
1691
+ };
1692
+ }
1693
+ }
1694
+ /**
1695
+ * A notification's status in words: the one its recipients share, or Partly
1696
+ * sent when they differ. Recipients all sent read as the one that took the
1697
+ * most retries.
1698
+ */
1699
+ function notificationLook(statuses) {
1700
+ const [first] = statuses;
1701
+ if (!first) return {
1702
+ label: "Waiting",
1703
+ tone: "quiet"
1351
1704
  };
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;
1705
+ if (!statuses.every((status) => status.state === first.state)) return {
1706
+ label: "Partly sent",
1707
+ tone: "warn"
1364
1708
  };
1365
- return new Set(fields.filter((field) => !isShown(field.name)).map((field) => field.name));
1709
+ return recipientLook(statuses.reduce((latest, status) => status.retries > latest.retries ? status : latest, first));
1710
+ }
1711
+ /** Why a send was cancelled, in words. */
1712
+ function cancelText(cancel) {
1713
+ switch (cancel?.by) {
1714
+ case "person": return "Cancelled in Settings › Email";
1715
+ case "spam": return "Cancelled when the submission was marked Spam";
1716
+ case "erasure": return "Cancelled when the address was erased";
1717
+ default: return "Cancelled";
1718
+ }
1366
1719
  }
1367
1720
 
1368
1721
  // src/types.ts
@@ -1381,7 +1734,8 @@ const BUILT_IN_FORM_FIELD_TYPES = [
1381
1734
  "file",
1382
1735
  "date",
1383
1736
  "time",
1384
- "hidden"
1737
+ "hidden",
1738
+ "consent"
1385
1739
  ];
1386
1740
  const BUILT_IN_FORM_FIELD_TYPES_SET = new Set(BUILT_IN_FORM_FIELD_TYPES);
1387
1741
  /**
@@ -1471,7 +1825,8 @@ const ENFORCED_VALIDATION_RULES = {
1471
1825
  file: ["message"],
1472
1826
  date: ["message"],
1473
1827
  time: ["message"],
1474
- hidden: ["message"]
1828
+ hidden: ["message"],
1829
+ consent: ["message"]
1475
1830
  };
1476
1831
  /**
1477
1832
  * The rules a field of this type both accepts and has enforced.
@@ -1517,10 +1872,24 @@ function generateZodSchema(fields) {
1517
1872
  continue;
1518
1873
  }
1519
1874
  const fieldSchema = generateFieldSchema(field);
1520
- if (fieldSchema) schemaShape[field.name] = fieldSchema;
1875
+ if (fieldSchema) schemaShape[field.name] = field.required ? answered(fieldSchema, requiredMessage(field)) : fieldSchema;
1521
1876
  }
1522
1877
  return z.object(schemaShape);
1523
1878
  }
1879
+ /** What a required field missing from a submission says. */
1880
+ function requiredMessage(field) {
1881
+ return customMessage(field) || `${field.label || field.name} is required`;
1882
+ }
1883
+ /**
1884
+ * `schema`, after a check that an answer was given at all. A required field
1885
+ * left out of a submission would otherwise fail on its type ("expected
1886
+ * string, received undefined"), which tells the visitor nothing; it is
1887
+ * refused as required instead, and the field's own checks run on an answer
1888
+ * that is there.
1889
+ */
1890
+ function answered(schema, message) {
1891
+ return z.custom((value) => value !== void 0 && value !== null, { message }).pipe(schema);
1892
+ }
1524
1893
  /**
1525
1894
  * Generate a Zod schema for a single field.
1526
1895
  */
@@ -1539,6 +1908,7 @@ function generateFieldSchema(field) {
1539
1908
  case "date": return generateDateSchema(field);
1540
1909
  case "time": return generateTimeSchema(field);
1541
1910
  case "hidden": return generateHiddenSchema(field);
1911
+ case "consent": return generateConsentSchema(field);
1542
1912
  default: return z.unknown();
1543
1913
  }
1544
1914
  }
@@ -1748,6 +2118,17 @@ function generateTimeSchema(field) {
1748
2118
  return applyRequired(z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, customMessage(field) || "Invalid time format (HH:mm)"), field.required, customMessage(field));
1749
2119
  }
1750
2120
  /**
2121
+ * Generate schema for a consent field: the stored answer, which must say the
2122
+ * visitor agreed. Always required, whatever the field's own flag says, since
2123
+ * a consent nobody has to give records nothing.
2124
+ */
2125
+ function generateConsentSchema(field) {
2126
+ return z.object({
2127
+ agreed: z.boolean(),
2128
+ text: z.string()
2129
+ }).refine((answer) => answer.agreed, { message: customMessage(field) || "This field is required" });
2130
+ }
2131
+ /**
1751
2132
  * Generate schema for hidden field.
1752
2133
  */
1753
2134
  function generateHiddenSchema(field) {
@@ -1805,6 +2186,7 @@ function transformFieldValue(value, field) {
1805
2186
  case "hidden":
1806
2187
  if (typeof value === "string") return value.trim();
1807
2188
  return value;
2189
+ case "consent": return consentAnswerOf(value, field);
1808
2190
  case "select":
1809
2191
  if (field.allowMultiple) {
1810
2192
  if (!Array.isArray(value)) return value ? [value] : [];
@@ -1921,6 +2303,225 @@ function oneResponseEditProblem(before, after, settings) {
1921
2303
  return comparesByEmail(after) ? void 0 : ONE_RESPONSE_FIELD_REASON;
1922
2304
  }
1923
2305
 
2306
+ // src/utils/sent-from.ts
2307
+ /**
2308
+ * The page a submission was sent from, as the submission keeps it: the web
2309
+ * address the visitor's browser reported in `Referer`, reduced to its origin
2310
+ * and path. Its query and fragment are dropped, since a page's address can
2311
+ * carry personal data there (a search, an email in a link, a token), and an
2312
+ * address that is not a web page (another scheme, or none) is not kept. The
2313
+ * browser reports it, so it is a hint for staff, not evidence.
2314
+ *
2315
+ * @module utils/sent-from
2316
+ */
2317
+ /** The longest address kept, in characters; a longer one is not kept. */
2318
+ const PAGE_MAX = 2048;
2319
+ /** The page a `Referer` names, or undefined when it names no web page. */
2320
+ function pageFromReferer(referer) {
2321
+ if (!referer) return void 0;
2322
+ let url;
2323
+ try {
2324
+ url = new URL(referer);
2325
+ } catch {
2326
+ return;
2327
+ }
2328
+ if (url.protocol !== "https:" && url.protocol !== "http:") return void 0;
2329
+ const page = `${url.origin}${url.pathname}`;
2330
+ return page.length <= PAGE_MAX ? page : void 0;
2331
+ }
2332
+
2333
+ // src/admin/components/submissions/submission-list-scope.ts
2334
+ const STATUS_TABS = [
2335
+ "all",
2336
+ "new",
2337
+ "read",
2338
+ "archived",
2339
+ "spam"
2340
+ ];
2341
+ /**
2342
+ * The scope's dates as a filter of `submittedAt`, or none for any date. The
2343
+ * upper bound is exclusive, so a range ending at the start of a day leaves
2344
+ * that day out, whatever the precision a dialect stores the time with.
2345
+ */
2346
+ function sentBetween(scope) {
2347
+ if (!scope.from && !scope.to) return void 0;
2348
+ return {
2349
+ ...scope.from ? { greater_than_equal: scope.from } : {},
2350
+ ...scope.to ? { less_than: scope.to } : {}
2351
+ };
2352
+ }
2353
+ /** The list's filter as the entries API takes it. */
2354
+ function listWhere(scope) {
2355
+ const where = {};
2356
+ if (scope.form) where.form = { equals: scope.form };
2357
+ where.status = scope.status === "all" ? { not_equals: "spam" } : { equals: scope.status };
2358
+ const sent = sentBetween(scope);
2359
+ if (sent) where.submittedAt = sent;
2360
+ return where;
2361
+ }
2362
+ /**
2363
+ * The list's order: newest first, and among submissions sent at the same
2364
+ * moment, by id. The id makes the order total, so a submission's newer and
2365
+ * older neighbours are always defined even when timestamps tie (imported
2366
+ * rows can share one). It is the tiebreak the entries API adds last anyway,
2367
+ * spelled out so the page's neighbour queries mirror it exactly.
2368
+ */
2369
+ const LIST_SORT = "-submittedAt,id";
2370
+ /**
2371
+ * The list a submission's page counts it in: the list's filter, and the
2372
+ * submission itself. Opened from the New tab, the submission is marked Read
2373
+ * and leaves that filter; archiving or recovering it can too. Counted with
2374
+ * it, the page's "3 of 3" stays true to the list it was opened from rather
2375
+ * than reading "3 of 2", and its newer neighbours still count its place.
2376
+ */
2377
+ function listWithWhere(scope, id) {
2378
+ return { or: [listWhere(scope), { id: { equals: id } }] };
2379
+ }
2380
+ /**
2381
+ * The queries that find the submissions just newer and just older than the
2382
+ * one sent at `at` with id `id`, in LIST_SORT's order: each side is a strict
2383
+ * comparison on the timestamp, or the same timestamp and the id on that side.
2384
+ * The newer side is read in reverse, so its first row is the nearest one, and
2385
+ * its total is how many rows come before this submission in the list.
2386
+ */
2387
+ function neighbourQueries(scope, at, id) {
2388
+ const base = listWhere(scope);
2389
+ const side = (time, tie) => ({
2390
+ ...base,
2391
+ or: [{ submittedAt: { [time]: at } }, { and: [{ submittedAt: { equals: at } }, { id: { [tie]: id } }] }]
2392
+ });
2393
+ return {
2394
+ newer: {
2395
+ where: side("greater_than", "less_than"),
2396
+ sort: "submittedAt,-id"
2397
+ },
2398
+ older: {
2399
+ where: side("less_than", "greater_than"),
2400
+ sort: LIST_SORT
2401
+ }
2402
+ };
2403
+ }
2404
+ /** The scope as an address carries it: a submission page's, or the export's. */
2405
+ function scopeParams(scope) {
2406
+ const params = new URLSearchParams({ status: scope.status });
2407
+ if (scope.form) params.set("form", scope.form);
2408
+ if (scope.from) params.set("from", scope.from);
2409
+ if (scope.to) params.set("to", scope.to);
2410
+ return params;
2411
+ }
2412
+ /** A moment an address names, as an ISO date and time; none for anything else. */
2413
+ function momentOf(text) {
2414
+ if (!text) return void 0;
2415
+ const at = Date.parse(text);
2416
+ return Number.isNaN(at) ? void 0 : new Date(at).toISOString();
2417
+ }
2418
+ /**
2419
+ * The scope an address carries, read defensively: an unknown status reads as
2420
+ * "all" and a date that is not one as no bound, so a hand-edited address
2421
+ * still lists something.
2422
+ */
2423
+ function scopeFromSearch(search) {
2424
+ const params = new URLSearchParams(search);
2425
+ const status = params.get("status");
2426
+ const form = params.get("form");
2427
+ const from = momentOf(params.get("from"));
2428
+ const to = momentOf(params.get("to"));
2429
+ return {
2430
+ status: STATUS_TABS.includes(status) ? status : "all",
2431
+ ...form ? { form } : {},
2432
+ ...from ? { from } : {},
2433
+ ...to ? { to } : {}
2434
+ };
2435
+ }
2436
+ /** A submission's page, opened from the list `scope`. */
2437
+ function submissionPageAddress(submissionsSlug, id, scope) {
2438
+ return `/admin/collections/${encodeURIComponent(submissionsSlug)}/${encodeURIComponent(id)}?${scopeParams(scope).toString()}`;
2439
+ }
2440
+ /** The export route, as the admin's API serves it. */
2441
+ const EXPORT_ROUTE = "/admin/api/plugins/@nextlyhq/plugin-form-builder/submissions/export";
2442
+ /**
2443
+ * The export of what a list shows, in `format`: every submission its scope
2444
+ * matches, or, given `ids`, exactly those (the rows ticked in it).
2445
+ */
2446
+ function exportAddress(scope, format, ids) {
2447
+ const params = scopeParams(scope);
2448
+ params.set("format", format);
2449
+ if (ids !== void 0) params.set("ids", ids.join(","));
2450
+ return `${EXPORT_ROUTE}?${params.toString()}`;
2451
+ }
2452
+
2453
+ // src/utils/notification-model.ts
2454
+ /**
2455
+ * Split typed addresses into a list: separated by commas, semicolons,
2456
+ * spaces or new lines, each trimmed, blanks and repeats dropped.
2457
+ */
2458
+ function parseAddressList(text) {
2459
+ const seen = /* @__PURE__ */ new Set();
2460
+ const list = [];
2461
+ for (const part of text.split(/[\s,;]+/)) {
2462
+ const address = part.trim();
2463
+ if (!address || seen.has(address.toLowerCase())) continue;
2464
+ seen.add(address.toLowerCase());
2465
+ list.push(address);
2466
+ }
2467
+ return list;
2468
+ }
2469
+ /**
2470
+ * A team notification's addresses: its list, or the single address it was
2471
+ * saved with. Trimmed, blanks dropped.
2472
+ */
2473
+ function teamAddressesOf(notification) {
2474
+ return (Array.isArray(notification.recipients) ? notification.recipients : typeof notification.to === "string" ? [notification.to] : []).map((address) => typeof address === "string" ? address.trim() : "").filter(Boolean);
2475
+ }
2476
+ /** A notification's conditions, as a match and a list. */
2477
+ function conditionsOf(notification) {
2478
+ const stored = notification.conditions;
2479
+ if (stored && Array.isArray(stored.list)) return {
2480
+ match: stored.match === "any" ? "any" : "all",
2481
+ list: stored.list.filter(isCondition)
2482
+ };
2483
+ return {
2484
+ match: "all",
2485
+ list: notification.condition ? [notification.condition] : []
2486
+ };
2487
+ }
2488
+ function isCondition(value) {
2489
+ return !!value && typeof value === "object" && typeof value.field === "string" && typeof value.comparison === "string";
2490
+ }
2491
+ /**
2492
+ * Whether a notification sends for these answers: every condition holds
2493
+ * (all) or one does (any). One with no conditions always sends.
2494
+ */
2495
+ function conditionsMet(conditions, data) {
2496
+ if (conditions.list.length === 0) return true;
2497
+ const holds = (condition) => evaluateSingleCondition(condition, data);
2498
+ return conditions.match === "any" ? conditions.list.some(holds) : conditions.list.every(holds);
2499
+ }
2500
+ /**
2501
+ * The file fields whose files go with a notification, by name: its own
2502
+ * choice, or for a rule saved before it, the fields marked to attach.
2503
+ */
2504
+ function attachFieldsOf(notification, fields) {
2505
+ if (Array.isArray(notification.attachFields)) return notification.attachFields.filter((name) => typeof name === "string");
2506
+ return fieldsMarkedToAttach(fields);
2507
+ }
2508
+ /**
2509
+ * The file fields marked to attach to notification emails, by name: what
2510
+ * every email carried before each notification chose its own.
2511
+ */
2512
+ function fieldsMarkedToAttach(fields) {
2513
+ return fields.filter((field) => field.type === "file" && field.attachToEmail === true && typeof field.name === "string").map((field) => field.name);
2514
+ }
2515
+ /** The media ids a submission holds under the named file fields. */
2516
+ function attachmentsFrom(fieldNames, data) {
2517
+ const attachments = [];
2518
+ for (const name of fieldNames) {
2519
+ const value = data[name];
2520
+ for (const item of Array.isArray(value) ? value : [value]) if (typeof item === "string" && item) attachments.push({ mediaId: item });
2521
+ }
2522
+ return attachments;
2523
+ }
2524
+
1924
2525
  // src/utils/field-references.ts
1925
2526
  /**
1926
2527
  * The one spelling of a field reference this plugin recognises: `{{fieldName}}`
@@ -1983,8 +2584,9 @@ function findFieldReferences(fieldName, fields, notifications, settings) {
1983
2584
  ...notification.bcc ?? [],
1984
2585
  notification.replyTo
1985
2586
  ].filter((value) => typeof value === "string").some((template) => referencesInTemplate(template, fieldName));
1986
- const referencedByCondition = notification.condition?.field === fieldName;
1987
- if (referencedByTemplate || referencedByCondition) references.push({
2587
+ const referencedByCondition = conditionsOf(notification).list.some((condition) => condition.field === fieldName);
2588
+ const referencedByAttachment = notification.attachFields?.includes(fieldName) ?? false;
2589
+ if (referencedByTemplate || referencedByCondition || referencedByAttachment) references.push({
1988
2590
  kind: "notification",
1989
2591
  label: notification.name || "Notification"
1990
2592
  });
@@ -2024,7 +2626,8 @@ function renameInFieldConditions(fields, from, to) {
2024
2626
  }
2025
2627
  /**
2026
2628
  * The notifications with every reference to `from` naming `to` instead: the
2027
- * addresses (`{{ from }}`) and the condition `findFieldReferences` reads.
2629
+ * addresses (`{{ from }}`), the conditions and the attached file fields
2630
+ * `findFieldReferences` reads.
2028
2631
  */
2029
2632
  function renameInNotifications(notifications, from, to) {
2030
2633
  const pattern = templatePattern(from, "g");
@@ -2042,9 +2645,55 @@ function renameInNotifications(notifications, from, to) {
2042
2645
  ...notification.condition,
2043
2646
  field: to
2044
2647
  };
2648
+ if (notification.conditions) next.conditions = {
2649
+ ...notification.conditions,
2650
+ list: notification.conditions.list.map((condition) => condition.field === from ? {
2651
+ ...condition,
2652
+ field: to
2653
+ } : condition)
2654
+ };
2655
+ if (notification.attachFields) next.attachFields = notification.attachFields.map((name) => name === from ? to : name);
2045
2656
  return next;
2046
2657
  });
2047
2658
  }
2048
2659
 
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
2660
+ // src/utils/submission-title.ts
2661
+ /**
2662
+ * Who sent a submission, as the admin names it: the first answer to a text
2663
+ * field named or labelled as a name, else the first email address answered,
2664
+ * else none. One rule, read by the write seam (which keeps it as the
2665
+ * submission's title, the admin's breadcrumb and list name) and by the
2666
+ * submission's page.
2667
+ *
2668
+ * @module utils/submission-title
2669
+ */
2670
+ /**
2671
+ * The longest title kept, in characters. The title is a display copy of an
2672
+ * answer, stored as a text column, which MySQL holds to 255 characters; an
2673
+ * answer has no such limit, so a longer one is shortened here rather than
2674
+ * refusing the submission it names.
2675
+ */
2676
+ const TITLE_MAX = 200;
2677
+ /**
2678
+ * The title a submission is stored with: who sent it, shortened to
2679
+ * TITLE_MAX characters with an ellipsis, or none.
2680
+ */
2681
+ function submissionTitleOf(fields, data) {
2682
+ const sender = senderOf(fields, data);
2683
+ if (sender === void 0) return void 0;
2684
+ const characters = Array.from(sender);
2685
+ return characters.length <= TITLE_MAX ? sender : `${characters.slice(0, 199).join("").trimEnd()}…`;
2686
+ }
2687
+ function senderOf(fields, data) {
2688
+ const answer = (name) => {
2689
+ const value = data[name];
2690
+ return typeof value === "string" ? value.trim() : "";
2691
+ };
2692
+ const named = fields.find((field) => field.type === "text" && /name/i.test(`${field.name} ${field.label ?? ""}`) && answer(field.name));
2693
+ if (named) return answer(named.name);
2694
+ const email = fields.find((field) => field.type === "email" && answer(field.name));
2695
+ return email ? answer(email.name) : void 0;
2696
+ }
2697
+
2698
+ export { applyFieldDefaults as $, getValidationIssues as A, fieldsHiddenByCondition as At, isKnownFormField as B, pickedDocumentField as Bt, ONE_RESPONSE_FIELD_REASON as C, isConsentAnswer as Ct, oneResponseFieldProblem as D, askedFieldsOf as Dt, oneResponseEditProblem as E, askedAfterEdit as Et, isCheckboxFormField as F, readNewSubmissions as Ft, latestByRecipient as G, formPageOf as Gt, isTextFormField as H, FORM_PAGES as Ht, isEmailFormField as I, SCHEMA_ONLY_CONTEXT as It, recipientLook as J, notificationLook as K, isFileFormField as L, SCHEMA_ONLY_READ as Lt, validateFormData as M, getSupportedComparisonOperators as Mt, enforcedValidationRules as N, isValidComparisonOperator as Nt, generateZodSchema as O, askedSnapshotOf as Ot, BUILT_IN_FORM_FIELD_TYPES as P, NEW_SUBMISSIONS_WIDGET_ID as Pt, DEFAULT_NOTIFICATION_SETTINGS as Q, isFormField as R, documentReachability as Rt, pageFromReferer as S, editedConsents as St, emailFieldsToCompare as T, storedConsentNames as Tt, addressableNow as U, addressedFormPages as Ut, isSelectFormField as V, FORM_EDITOR_VIEW as Vt, cancelText as W, formPageId as Wt, normalizeFormSettings as X, sendInProgress as Y, DEFAULT_FORM_SETTINGS as Z, listWhere as _, CONSENT_EDIT_REFUSED as _t, parseFieldRef as a, FORM_CONFIRMATION_TEMPLATE as at, scopeFromSearch as b, blankConsentFields as bt, attachFieldsOf as c, starterTemplateFor as ct, conditionsOf as d, exportAndDownload as dt, applyFormDefaults as et, fieldsMarkedToAttach as f, exportToCSV as ft, exportAddress as g, optionLabels as gt, LIST_SORT as h, generateExportFilename as ht, oneResponseEmailField as i, warnIfWebhooksConfigured as it, transformFormData as j, evaluateConditions as jt, getValidationErrors as k, readAskedSnapshot as kt, attachmentsFrom as l, answerReader as lt, teamAddressesOf as m, formatExportValue as mt, submissionTitleOf as n, pluralize as nt, renameInFieldConditions as o, FORM_SUBMISSION_TEMPLATE as ot, parseAddressList as p, exportToJSON as pt, reachedExcept as q, buildFieldReferenceMap as r, toTitleCase as rt, renameInNotifications as s, formsEmailTemplates as st, senderOf as t, createFormConfig as tt, conditionsMet as u, downloadFile as ut, listWithWhere as v, CONSENT_TEXT_CHANGED as vt, comparesByEmail as w, staleConsentNames as wt, submissionPageAddress as x, consentFieldEdit as xt, neighbourQueries as y, CONSENT_TEXT_REQUIRED as yt, isFormFieldType as z, parseRedirectReference as zt };
2699
+ //# sourceMappingURL=submission-title-CVixn15Z.js.map