@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.
- package/dist/admin/index.d.ts +38 -24
- package/dist/admin/index.js +9155 -6116
- package/dist/admin/index.js.map +1 -1
- package/dist/index.d.ts +35 -5
- package/dist/index.js +2790 -263
- package/dist/index.js.map +1 -1
- package/dist/{field-references-oCuFEwkr.js → submission-title-CVixn15Z.js} +715 -66
- package/dist/submission-title-CVixn15Z.js.map +1 -0
- package/dist/{new-submissions-DGiIhdBF.d.ts → types-XdBrUfi1.d.ts} +108 -41
- package/package.json +10 -10
- package/dist/field-references-oCuFEwkr.js.map +0 -1
|
@@ -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
|
|
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/
|
|
1311
|
-
/** The
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
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
|
-
*
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
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
|
|
1334
|
-
const
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
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
|
|
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
|
|
1987
|
-
|
|
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
|
|
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
|
-
|
|
2050
|
-
|
|
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
|