@usefillo/core 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.d.ts +433 -39
- package/dist/index.js +896 -126
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21,6 +21,12 @@ function valueEquals(value, target) {
|
|
|
21
21
|
return Number(value) === target;
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
|
+
function conditionNumber(value) {
|
|
25
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
26
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
27
|
+
const parsed = Number(value);
|
|
28
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
29
|
+
}
|
|
24
30
|
function evalCondition(cond, resolve) {
|
|
25
31
|
const value = resolve(cond.fieldId);
|
|
26
32
|
switch (cond.op) {
|
|
@@ -41,9 +47,11 @@ function evalCondition(cond, resolve) {
|
|
|
41
47
|
if (Array.isArray(value)) return value.some((v) => v === cond.value);
|
|
42
48
|
return false;
|
|
43
49
|
case "gt":
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
case "lt": {
|
|
51
|
+
const left = conditionNumber(value);
|
|
52
|
+
const right = conditionNumber(cond.value);
|
|
53
|
+
return left !== null && right !== null && (cond.op === "gt" ? left > right : left < right);
|
|
54
|
+
}
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
function blockVisibleWith(block, resolve) {
|
|
@@ -74,9 +82,27 @@ function visibleBlocks(page, data) {
|
|
|
74
82
|
const resolve = makeResolver(data, scoped, visibleFieldIds(fields, data));
|
|
75
83
|
return page.blocks.filter((b) => blockVisibleWith(b, resolve));
|
|
76
84
|
}
|
|
85
|
+
function visiblePageBlocks(form, page, data) {
|
|
86
|
+
const fields = allFields(form);
|
|
87
|
+
const scoped = new Set(fields.map((f) => f.id));
|
|
88
|
+
const formVisible = visibleFieldIds(fields, data);
|
|
89
|
+
const resolve = makeResolver(data, scoped, formVisible);
|
|
90
|
+
return page.blocks.filter(
|
|
91
|
+
(b) => isField(b) ? formVisible.has(b.id) : blockVisibleWith(b, resolve)
|
|
92
|
+
);
|
|
93
|
+
}
|
|
77
94
|
function allFields(form) {
|
|
78
95
|
return form.pages.flatMap((p) => p.blocks.filter(isField));
|
|
79
96
|
}
|
|
97
|
+
function responseScopeValue(settings, data) {
|
|
98
|
+
const field2 = settings.responseLimit?.scopeField;
|
|
99
|
+
if (!field2) return null;
|
|
100
|
+
const value = data[field2];
|
|
101
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
|
|
102
|
+
if (typeof value !== "string") return null;
|
|
103
|
+
const normalized = value.trim();
|
|
104
|
+
return normalized || null;
|
|
105
|
+
}
|
|
80
106
|
function visibleFields(form, data) {
|
|
81
107
|
const fields = allFields(form);
|
|
82
108
|
const visible = visibleFieldIds(fields, data);
|
|
@@ -364,6 +390,75 @@ function isPossiblePhone(value) {
|
|
|
364
390
|
if (country?.lengths.length) return country.lengths.includes(national.length);
|
|
365
391
|
return national.length >= 4;
|
|
366
392
|
}
|
|
393
|
+
var PHONE_POPOVER_VIEWPORT_GAP = 8;
|
|
394
|
+
function positionPhonePopover(anchor, popover) {
|
|
395
|
+
if (!anchor || !popover || typeof window === "undefined") return "below";
|
|
396
|
+
const gap = PHONE_POPOVER_VIEWPORT_GAP;
|
|
397
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
398
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
399
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
400
|
+
const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
|
|
401
|
+
const spaceAbove = Math.max(0, anchorRect.top - gap);
|
|
402
|
+
const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
|
|
403
|
+
const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
|
|
404
|
+
const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
|
|
405
|
+
const maxWidth = Math.max(0, viewportWidth - gap * 2);
|
|
406
|
+
popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
|
|
407
|
+
popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
|
|
408
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
|
|
409
|
+
const width = Math.min(popover.offsetWidth, maxWidth);
|
|
410
|
+
let offsetX = 0;
|
|
411
|
+
const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
|
|
412
|
+
if (rightOverflow > 0) offsetX -= rightOverflow;
|
|
413
|
+
const leftOverflow = gap - (anchorRect.left + offsetX);
|
|
414
|
+
if (leftOverflow > 0) offsetX += leftOverflow;
|
|
415
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
|
|
416
|
+
return placement;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/strings.ts
|
|
420
|
+
var REQUIRED_FIELD_MESSAGE = "This field is required";
|
|
421
|
+
var DEFAULT_STRINGS = {
|
|
422
|
+
back: "Back",
|
|
423
|
+
next: "Next",
|
|
424
|
+
submit: "Submit",
|
|
425
|
+
submitting: "Submitting\u2026",
|
|
426
|
+
uploading: "Uploading\u2026",
|
|
427
|
+
optional: " (optional)",
|
|
428
|
+
other: "Other",
|
|
429
|
+
otherPrompt: "Other \u2014 please specify",
|
|
430
|
+
otherPlaceholder: "Your answer",
|
|
431
|
+
choosePlaceholder: "Choose\u2026",
|
|
432
|
+
successTitle: "Thanks!",
|
|
433
|
+
successMessage: "Your response has been recorded.",
|
|
434
|
+
closed: "This form is no longer accepting responses.",
|
|
435
|
+
notLive: "This form isn't live yet.",
|
|
436
|
+
submitFailed: "This form can't submit right now. Please try again in a moment.",
|
|
437
|
+
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
438
|
+
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
439
|
+
loadFailed: "This form could not be loaded.",
|
|
440
|
+
renderFailed: "This form could not be rendered.",
|
|
441
|
+
resumeNotice: "Picked up where you left off.",
|
|
442
|
+
editNotice: "You're updating your earlier response.",
|
|
443
|
+
resumeStartOver: "Start over"
|
|
444
|
+
};
|
|
445
|
+
var DEFAULT_FIELD_STRINGS = {
|
|
446
|
+
required: REQUIRED_FIELD_MESSAGE,
|
|
447
|
+
resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
|
|
448
|
+
alreadyAnswered: "You've already answered this form.",
|
|
449
|
+
uploadRetry: "Retry",
|
|
450
|
+
uploadsDisabled: "Uploads are disabled in preview",
|
|
451
|
+
uploadFailed: "Upload failed \u2014 try again",
|
|
452
|
+
dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
|
|
453
|
+
dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
|
|
454
|
+
fileTooLarge: (maxMb) => `Larger than ${maxMb} MB limit`,
|
|
455
|
+
filesUploading: (count) => `Uploading ${count} ${count === 1 ? "file" : "files"}, please wait\u2026`,
|
|
456
|
+
uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
|
|
457
|
+
filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`
|
|
458
|
+
};
|
|
459
|
+
function resolveStrings(overrides) {
|
|
460
|
+
return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
|
|
461
|
+
}
|
|
367
462
|
|
|
368
463
|
// src/validation.ts
|
|
369
464
|
var fileValueSchema = z.object({
|
|
@@ -383,7 +478,7 @@ function isEmpty(value) {
|
|
|
383
478
|
}
|
|
384
479
|
function validateField(field2, value) {
|
|
385
480
|
if (isEmpty(value)) {
|
|
386
|
-
return field2.required ?
|
|
481
|
+
return field2.required ? REQUIRED_FIELD_MESSAGE : null;
|
|
387
482
|
}
|
|
388
483
|
switch (field2.kind) {
|
|
389
484
|
case "short_text":
|
|
@@ -396,11 +491,16 @@ function validateField(field2, value) {
|
|
|
396
491
|
return null;
|
|
397
492
|
}
|
|
398
493
|
case "email": {
|
|
494
|
+
if (typeof value !== "string") return "Enter a valid email address";
|
|
495
|
+
if (field2.maxLength && value.length > field2.maxLength)
|
|
496
|
+
return `Must be at most ${field2.maxLength} characters`;
|
|
399
497
|
const r = z.email().safeParse(value);
|
|
400
498
|
return r.success ? null : "Enter a valid email address";
|
|
401
499
|
}
|
|
402
500
|
case "url": {
|
|
403
501
|
if (typeof value !== "string") return "Enter a valid URL (including https://)";
|
|
502
|
+
if (field2.maxLength && value.length > field2.maxLength)
|
|
503
|
+
return `Must be at most ${field2.maxLength} characters`;
|
|
404
504
|
try {
|
|
405
505
|
const url = new URL(value);
|
|
406
506
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -435,9 +535,13 @@ function validateField(field2, value) {
|
|
|
435
535
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
436
536
|
const arr = value;
|
|
437
537
|
let others = 0;
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
438
539
|
for (const v of arr) {
|
|
540
|
+
if (typeof v !== "string") return "Pick valid options";
|
|
541
|
+
if (seen.has(v)) return "Pick each option only once";
|
|
542
|
+
seen.add(v);
|
|
439
543
|
if (ids.has(v)) continue;
|
|
440
|
-
if (field2.allowOther !== true ||
|
|
544
|
+
if (field2.allowOther !== true || v.length === 0 || v.length > 500)
|
|
441
545
|
return "Pick valid options";
|
|
442
546
|
if (++others > 1) return "Only one 'Other' answer is allowed";
|
|
443
547
|
}
|
|
@@ -529,6 +633,7 @@ function validateResponse(form, data) {
|
|
|
529
633
|
}
|
|
530
634
|
function normalizeValue(field2, value) {
|
|
531
635
|
if (value === void 0 || value === null) return value;
|
|
636
|
+
if (field2.kind === "custom") return value;
|
|
532
637
|
if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
|
|
533
638
|
const n = Number(value);
|
|
534
639
|
return Number.isNaN(n) ? value : n;
|
|
@@ -594,7 +699,8 @@ var schemaShape = z2.object({
|
|
|
594
699
|
});
|
|
595
700
|
var MAX_SCHEMA_VERSION = 1;
|
|
596
701
|
var FILLO_SCHEMA_VERSION = 1;
|
|
597
|
-
var FILLO_SDK_VERSION = true ? "0.
|
|
702
|
+
var FILLO_SDK_VERSION = true ? "0.8.0" : "0.0.0-dev";
|
|
703
|
+
var FILLO_MIN_SDK_VERSION = "0.4.0";
|
|
598
704
|
function str(value, max, fallback = "") {
|
|
599
705
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
600
706
|
}
|
|
@@ -624,6 +730,14 @@ function options(value, max = 200) {
|
|
|
624
730
|
return id && label ? [cleanObject({ id, label, icon })] : [];
|
|
625
731
|
});
|
|
626
732
|
}
|
|
733
|
+
function duplicateOptionId(items) {
|
|
734
|
+
const ids = /* @__PURE__ */ new Set();
|
|
735
|
+
for (const { id } of items) {
|
|
736
|
+
if (ids.has(id)) return id;
|
|
737
|
+
ids.add(id);
|
|
738
|
+
}
|
|
739
|
+
return void 0;
|
|
740
|
+
}
|
|
627
741
|
function conditions(value) {
|
|
628
742
|
if (!Array.isArray(value)) return void 0;
|
|
629
743
|
const normalized = value.slice(0, 20).flatMap((item) => {
|
|
@@ -668,10 +782,25 @@ function normalizeUrl(value) {
|
|
|
668
782
|
return void 0;
|
|
669
783
|
}
|
|
670
784
|
}
|
|
785
|
+
function normalizeResponseLimit(value) {
|
|
786
|
+
if (!value || typeof value !== "object") return void 0;
|
|
787
|
+
const rec = value;
|
|
788
|
+
const by = rec.by === "browser" || rec.by === "field" || rec.by === "identify" ? rec.by : void 0;
|
|
789
|
+
if (!by) return void 0;
|
|
790
|
+
const field2 = by === "field" ? optionalStr(rec.field, 128) : void 0;
|
|
791
|
+
if (by === "field" && !field2) return void 0;
|
|
792
|
+
const scopeField = optionalStr(rec.scopeField, 128);
|
|
793
|
+
const onRepeat = rec.onRepeat === "update" && by === "identify" ? "update" : "keep";
|
|
794
|
+
return {
|
|
795
|
+
by,
|
|
796
|
+
...field2 ? { field: field2 } : {},
|
|
797
|
+
...scopeField ? { scopeField } : {},
|
|
798
|
+
onRepeat
|
|
799
|
+
};
|
|
800
|
+
}
|
|
671
801
|
function normalizeSettings(value) {
|
|
672
802
|
const rec = value && typeof value === "object" ? value : {};
|
|
673
803
|
const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
|
|
674
|
-
const submissionLimit = rec.submissionLimit === "once_per_visitor" || rec.submissionLimit === "multiple" ? rec.submissionLimit : void 0;
|
|
675
804
|
return {
|
|
676
805
|
submitMode,
|
|
677
806
|
submitLabel: optionalStr(rec.submitLabel, 100),
|
|
@@ -679,9 +808,14 @@ function normalizeSettings(value) {
|
|
|
679
808
|
successMessage: optionalStr(rec.successMessage, 5e3),
|
|
680
809
|
redirectUrl: normalizeUrl(rec.redirectUrl),
|
|
681
810
|
showProgress: bool(rec.showProgress),
|
|
682
|
-
|
|
811
|
+
responseLimit: normalizeResponseLimit(rec.responseLimit),
|
|
683
812
|
notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
|
|
684
|
-
sendReceipt: bool(rec.sendReceipt)
|
|
813
|
+
sendReceipt: bool(rec.sendReceipt),
|
|
814
|
+
saveProgress: bool(rec.saveProgress),
|
|
815
|
+
draftAnswersVisible: bool(rec.draftAnswersVisible),
|
|
816
|
+
resumeEmails: bool(rec.resumeEmails),
|
|
817
|
+
resumeUrl: normalizeUrl(rec.resumeUrl),
|
|
818
|
+
draftDigest: bool(rec.draftDigest)
|
|
685
819
|
};
|
|
686
820
|
}
|
|
687
821
|
function cleanObject(obj) {
|
|
@@ -743,17 +877,25 @@ function normalizeBlock(input) {
|
|
|
743
877
|
case "signature":
|
|
744
878
|
case "date":
|
|
745
879
|
return cleanObject(baseField(rec, kind));
|
|
746
|
-
case "rating":
|
|
747
|
-
|
|
880
|
+
case "rating": {
|
|
881
|
+
const max = int(rec.max, 1, 10, 5);
|
|
882
|
+
return cleanObject({
|
|
883
|
+
...baseField(rec, kind),
|
|
884
|
+
max,
|
|
885
|
+
insightsMetric: rec.insightsMetric === "csat" && max === 5 ? "csat" : void 0
|
|
886
|
+
});
|
|
887
|
+
}
|
|
748
888
|
case "linear_scale": {
|
|
749
889
|
const min = int(rec.min, 0, 9, 1);
|
|
750
890
|
const max = int(rec.max, min + 1, 10, 10);
|
|
891
|
+
const insightsMetric = rec.insightsMetric === "nps" && min === 0 && max === 10 ? "nps" : rec.insightsMetric === "csat" && min === 1 && max === 5 ? "csat" : void 0;
|
|
751
892
|
return cleanObject({
|
|
752
893
|
...baseField(rec, kind),
|
|
753
894
|
min,
|
|
754
895
|
max,
|
|
755
896
|
minLabel: optionalStr(rec.minLabel, 100),
|
|
756
|
-
maxLabel: optionalStr(rec.maxLabel, 100)
|
|
897
|
+
maxLabel: optionalStr(rec.maxLabel, 100),
|
|
898
|
+
insightsMetric
|
|
757
899
|
});
|
|
758
900
|
}
|
|
759
901
|
case "ranking": {
|
|
@@ -812,16 +954,41 @@ function normalizeFormSchema(input) {
|
|
|
812
954
|
return block ? [block] : [];
|
|
813
955
|
});
|
|
814
956
|
return cleanObject({
|
|
815
|
-
id: page.id,
|
|
957
|
+
id: str(page.id, 128),
|
|
816
958
|
title: optionalStr(page.title, 500),
|
|
817
959
|
blocks
|
|
818
960
|
});
|
|
819
961
|
});
|
|
962
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
820
963
|
const seen = /* @__PURE__ */ new Set();
|
|
821
964
|
for (const page of pages) {
|
|
965
|
+
if (!page.id) return { ok: false, error: "Page ids cannot be blank" };
|
|
966
|
+
if (pageIds.has(page.id)) return { ok: false, error: `Duplicate page id: ${page.id}` };
|
|
967
|
+
pageIds.add(page.id);
|
|
822
968
|
for (const block of page.blocks) {
|
|
823
969
|
if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
|
|
824
970
|
seen.add(block.id);
|
|
971
|
+
if (block.kind === "number" && block.min !== void 0 && block.max !== void 0 && block.min > block.max) {
|
|
972
|
+
return { ok: false, error: `Field ${block.id} has min greater than max` };
|
|
973
|
+
}
|
|
974
|
+
if (block.kind === "select" || block.kind === "multi_select" || block.kind === "dropdown" || block.kind === "ranking") {
|
|
975
|
+
const duplicate = duplicateOptionId(block.options);
|
|
976
|
+
if (duplicate) {
|
|
977
|
+
return { ok: false, error: `Duplicate option id in field ${block.id}: ${duplicate}` };
|
|
978
|
+
}
|
|
979
|
+
} else if (block.kind === "matrix") {
|
|
980
|
+
const duplicateRow = duplicateOptionId(block.rows);
|
|
981
|
+
if (duplicateRow) {
|
|
982
|
+
return { ok: false, error: `Duplicate matrix row id in field ${block.id}: ${duplicateRow}` };
|
|
983
|
+
}
|
|
984
|
+
const duplicateColumn = duplicateOptionId(block.columns);
|
|
985
|
+
if (duplicateColumn) {
|
|
986
|
+
return {
|
|
987
|
+
ok: false,
|
|
988
|
+
error: `Duplicate matrix column id in field ${block.id}: ${duplicateColumn}`
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
}
|
|
825
992
|
}
|
|
826
993
|
}
|
|
827
994
|
if (pages.every((page) => page.blocks.length === 0)) {
|
|
@@ -923,6 +1090,9 @@ function formatAnswer(field2, value) {
|
|
|
923
1090
|
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
924
1091
|
var MASK = 256 - 256 % ALPHABET.length;
|
|
925
1092
|
function createId(size = 12) {
|
|
1093
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1094
|
+
throw new RangeError("createId size must be a positive safe integer");
|
|
1095
|
+
}
|
|
926
1096
|
const csprng = globalThis.crypto;
|
|
927
1097
|
if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
|
|
928
1098
|
throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
|
|
@@ -952,12 +1122,12 @@ var BLOCK_KIND_META = {
|
|
|
952
1122
|
dropdown: { label: "Dropdown", hint: "Select from a list" },
|
|
953
1123
|
checkbox: { label: "Yes / No", hint: "Single checkbox" },
|
|
954
1124
|
rating: { label: "Rating", hint: "1\u20135 stars" },
|
|
955
|
-
linear_scale: { label: "Linear scale", hint: "
|
|
1125
|
+
linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
|
|
956
1126
|
ranking: { label: "Ranking", hint: "Order options by preference" },
|
|
957
1127
|
matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
|
|
958
1128
|
signature: { label: "Signature", hint: "Draw to sign" },
|
|
959
1129
|
date: { label: "Date", hint: "Date picker" },
|
|
960
|
-
file_upload: { label: "File upload", hint: "
|
|
1130
|
+
file_upload: { label: "File upload", hint: "Direct to connected storage" },
|
|
961
1131
|
hidden: { label: "Hidden field", hint: "Filled from the URL" },
|
|
962
1132
|
custom: { label: "Custom", hint: "Your own component (code only)" },
|
|
963
1133
|
heading: { label: "Heading", hint: "Section title" },
|
|
@@ -992,10 +1162,11 @@ function createBlock(kind) {
|
|
|
992
1162
|
id,
|
|
993
1163
|
kind,
|
|
994
1164
|
label: "How likely are you to recommend us?",
|
|
995
|
-
min:
|
|
1165
|
+
min: 0,
|
|
996
1166
|
max: 10,
|
|
997
|
-
minLabel: "Not likely",
|
|
998
|
-
maxLabel: "
|
|
1167
|
+
minLabel: "Not at all likely",
|
|
1168
|
+
maxLabel: "Extremely likely",
|
|
1169
|
+
insightsMetric: "nps"
|
|
999
1170
|
};
|
|
1000
1171
|
case "ranking":
|
|
1001
1172
|
return {
|
|
@@ -1049,8 +1220,7 @@ function createEmptyForm(title = "Untitled form") {
|
|
|
1049
1220
|
submitLabel: "Submit",
|
|
1050
1221
|
successTitle: "Thanks!",
|
|
1051
1222
|
successMessage: "Your response has been recorded.",
|
|
1052
|
-
showProgress: true
|
|
1053
|
-
submissionLimit: "multiple"
|
|
1223
|
+
showProgress: true
|
|
1054
1224
|
}
|
|
1055
1225
|
};
|
|
1056
1226
|
}
|
|
@@ -1112,12 +1282,21 @@ function assembleBlock(spec) {
|
|
|
1112
1282
|
break;
|
|
1113
1283
|
case "rating":
|
|
1114
1284
|
block.max = clampInt(spec.max, 3, 10, 5);
|
|
1285
|
+
if (spec.insightsMetric === "csat" && block.max === 5) {
|
|
1286
|
+
block.insightsMetric = "csat";
|
|
1287
|
+
}
|
|
1115
1288
|
break;
|
|
1116
1289
|
case "linear_scale":
|
|
1117
1290
|
block.min = spec.min === 0 ? 0 : 1;
|
|
1118
1291
|
block.max = clampInt(spec.max, 2, 10, 10);
|
|
1119
1292
|
if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
|
|
1120
1293
|
if (spec.maxLabel) block.maxLabel = str2(spec.maxLabel, 100);
|
|
1294
|
+
delete block.insightsMetric;
|
|
1295
|
+
if (spec.insightsMetric === "nps" && block.min === 0 && block.max === 10) {
|
|
1296
|
+
block.insightsMetric = "nps";
|
|
1297
|
+
} else if (spec.insightsMetric === "csat" && block.min === 1 && block.max === 5) {
|
|
1298
|
+
block.insightsMetric = "csat";
|
|
1299
|
+
}
|
|
1121
1300
|
break;
|
|
1122
1301
|
case "number":
|
|
1123
1302
|
if (typeof spec.min === "number") block.min = spec.min;
|
|
@@ -1255,25 +1434,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
|
|
|
1255
1434
|
|
|
1256
1435
|
// src/client.ts
|
|
1257
1436
|
var FilloError = class extends Error {
|
|
1258
|
-
constructor(message, status, retryAfterSec2) {
|
|
1437
|
+
constructor(message, status, retryAfterSec2, code) {
|
|
1259
1438
|
super(message);
|
|
1260
1439
|
this.status = status;
|
|
1261
1440
|
this.retryAfterSec = retryAfterSec2;
|
|
1441
|
+
this.code = code;
|
|
1262
1442
|
this.name = "FilloError";
|
|
1263
1443
|
}
|
|
1264
1444
|
status;
|
|
1265
1445
|
retryAfterSec;
|
|
1446
|
+
code;
|
|
1266
1447
|
};
|
|
1267
1448
|
function isFilloError(err) {
|
|
1268
1449
|
return err instanceof Error && err.name === "FilloError";
|
|
1269
1450
|
}
|
|
1270
|
-
async function
|
|
1451
|
+
async function errorDetails(res, fallback) {
|
|
1271
1452
|
try {
|
|
1272
1453
|
const body = await res.json();
|
|
1273
|
-
|
|
1454
|
+
return {
|
|
1455
|
+
message: typeof body.error === "string" && body.error ? body.error : fallback,
|
|
1456
|
+
code: typeof body.code === "string" && body.code ? body.code : void 0
|
|
1457
|
+
};
|
|
1274
1458
|
} catch {
|
|
1275
1459
|
}
|
|
1276
|
-
return fallback;
|
|
1460
|
+
return { message: fallback };
|
|
1277
1461
|
}
|
|
1278
1462
|
function retryAfterSec(res) {
|
|
1279
1463
|
const raw = Number(res.headers.get("retry-after"));
|
|
@@ -1281,11 +1465,31 @@ function retryAfterSec(res) {
|
|
|
1281
1465
|
}
|
|
1282
1466
|
var MAX_CHUNK_RETRIES = 3;
|
|
1283
1467
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1468
|
+
var S3_PART_TIMEOUT_MS = 5 * 6e4;
|
|
1469
|
+
var UPLOAD_CONTROL_TIMEOUT_MS = 10 * 6e4;
|
|
1470
|
+
function waitForRetry(ms, signal) {
|
|
1471
|
+
signal?.throwIfAborted();
|
|
1472
|
+
return new Promise((resolve, reject) => {
|
|
1473
|
+
const aborted = () => {
|
|
1474
|
+
clearTimeout(timer);
|
|
1475
|
+
reject(signal?.reason);
|
|
1476
|
+
};
|
|
1477
|
+
const timer = setTimeout(() => {
|
|
1478
|
+
signal?.removeEventListener("abort", aborted);
|
|
1479
|
+
resolve();
|
|
1480
|
+
}, ms);
|
|
1481
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
function isControlRetryable(error, includeBusy = false) {
|
|
1485
|
+
const status = isFilloError(error) ? error.status ?? 0 : 0;
|
|
1486
|
+
return status === 0 || status === 503 || status === 504 || includeBusy && status === 409 && isFilloError(error) && error.retryAfterSec !== void 0;
|
|
1487
|
+
}
|
|
1284
1488
|
function isRetryableStatus(status) {
|
|
1285
1489
|
return status >= 500 || status === 429 || status === 408;
|
|
1286
1490
|
}
|
|
1287
1491
|
function asRequestError(err) {
|
|
1288
|
-
if (err instanceof DOMException && err.name === "TimeoutError")
|
|
1492
|
+
if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
|
|
1289
1493
|
return new FilloError("Request timed out", 0);
|
|
1290
1494
|
return err;
|
|
1291
1495
|
}
|
|
@@ -1356,8 +1560,8 @@ var FilloClient = class {
|
|
|
1356
1560
|
throw asRequestError(err);
|
|
1357
1561
|
}
|
|
1358
1562
|
if (!res.ok) {
|
|
1359
|
-
const message = await
|
|
1360
|
-
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1563
|
+
const { message, code } = await errorDetails(res, `Request failed: ${res.status}`);
|
|
1564
|
+
throw new FilloError(message, res.status, retryAfterSec(res), code);
|
|
1361
1565
|
}
|
|
1362
1566
|
return await res.json();
|
|
1363
1567
|
}
|
|
@@ -1368,8 +1572,10 @@ var FilloClient = class {
|
|
|
1368
1572
|
return form;
|
|
1369
1573
|
}
|
|
1370
1574
|
/**
|
|
1371
|
-
*
|
|
1372
|
-
* publishable key.
|
|
1575
|
+
* Resolve a code-defined form through the workspace identified by the
|
|
1576
|
+
* client's publishable key. Depending on workspace policy, changed content
|
|
1577
|
+
* may be staged for review or resolved to the authoritative live snapshot.
|
|
1578
|
+
* Returns the canonical form id used for submissions.
|
|
1373
1579
|
*/
|
|
1374
1580
|
syncForm(handle, schema, theme) {
|
|
1375
1581
|
if (!this.key) {
|
|
@@ -1409,11 +1615,34 @@ var FilloClient = class {
|
|
|
1409
1615
|
return { ok: false, errors: body2.errors };
|
|
1410
1616
|
}
|
|
1411
1617
|
if (!res.ok) {
|
|
1412
|
-
const message = await
|
|
1413
|
-
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1618
|
+
const { message, code } = await errorDetails(res, `Submit failed: ${res.status}`);
|
|
1619
|
+
throw new FilloError(message, res.status, retryAfterSec(res), code);
|
|
1414
1620
|
}
|
|
1415
1621
|
const body = await res.json();
|
|
1416
|
-
return { ok: true, responseId: body.id, duplicate: body.duplicate };
|
|
1622
|
+
return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* The identified person's own living response on an upsert-mode form —
|
|
1626
|
+
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
1627
|
+
* ({id, hash}); anything else 404s (returned as null), because an
|
|
1628
|
+
* unverified read would hand anyone's answers to any page script.
|
|
1629
|
+
*
|
|
1630
|
+
* `scopeValue` scopes the lookup for forms whose response limit is keyed to
|
|
1631
|
+
* a field (responseLimit.scopeField) — e.g. one response per article. When
|
|
1632
|
+
* the form is scoped, the server returns 404 unless the matching scope value
|
|
1633
|
+
* is sent, so a page loaded for article B never prefills article A's answers.
|
|
1634
|
+
* Optional and back-compatible: omit it for unscoped forms.
|
|
1635
|
+
*/
|
|
1636
|
+
async fetchOwnResponse(formId, respondent, scopeValue) {
|
|
1637
|
+
try {
|
|
1638
|
+
return await this.json(
|
|
1639
|
+
`/forms/${encodeURIComponent(formId)}/respondent-response`,
|
|
1640
|
+
{ method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
|
|
1641
|
+
);
|
|
1642
|
+
} catch (err) {
|
|
1643
|
+
if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
|
|
1644
|
+
throw err;
|
|
1645
|
+
}
|
|
1417
1646
|
}
|
|
1418
1647
|
/**
|
|
1419
1648
|
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
@@ -1448,19 +1677,60 @@ var FilloClient = class {
|
|
|
1448
1677
|
} catch {
|
|
1449
1678
|
}
|
|
1450
1679
|
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Create a saved-progress draft (forms with settings.saveProgress). Returns
|
|
1682
|
+
* the draft id and its ownership token — the token is shown only once.
|
|
1683
|
+
*/
|
|
1684
|
+
createDraft(formId, body) {
|
|
1685
|
+
return this.json(`/forms/${encodeURIComponent(formId)}/drafts`, {
|
|
1686
|
+
method: "POST",
|
|
1687
|
+
body: JSON.stringify(body)
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
/** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
|
|
1691
|
+
* `adopt` is set (resume-link adoption), the server rotates the bearer and
|
|
1692
|
+
* returns the new `token` — the URL token becomes single-use. */
|
|
1693
|
+
getDraft(draftId, token, adopt) {
|
|
1694
|
+
return this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1695
|
+
headers: adopt ? { "X-Fillo-Draft-Token": token, "X-Fillo-Draft-Adopt": "1" } : { "X-Fillo-Draft-Token": token }
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
|
|
1700
|
+
* tab-close flush outlive the page; browsers reject keepalive bodies over
|
|
1701
|
+
* ~64KB, in which case this save is simply lost — the debounced autosaves
|
|
1702
|
+
* every few keystrokes are the real persistence, the flush is a bonus.
|
|
1703
|
+
*/
|
|
1704
|
+
async saveDraft(draftId, token, body, opts) {
|
|
1705
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1706
|
+
method: "PATCH",
|
|
1707
|
+
headers: { "X-Fillo-Draft-Token": token },
|
|
1708
|
+
body: JSON.stringify(body),
|
|
1709
|
+
...opts?.keepalive ? { keepalive: true } : {}
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
/** Discard a draft (the "Start over" path). */
|
|
1713
|
+
async deleteDraft(draftId, token) {
|
|
1714
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1715
|
+
method: "DELETE",
|
|
1716
|
+
headers: { "X-Fillo-Draft-Token": token }
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1451
1719
|
/** Current state of an upload session — used to resume after interruption. */
|
|
1452
|
-
getUploadSession(sessionId, token) {
|
|
1720
|
+
getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
|
|
1453
1721
|
return this.json(
|
|
1454
1722
|
`/uploads/${encodeURIComponent(sessionId)}`,
|
|
1455
|
-
|
|
1723
|
+
{
|
|
1724
|
+
...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
|
|
1725
|
+
signal
|
|
1726
|
+
}
|
|
1456
1727
|
);
|
|
1457
1728
|
}
|
|
1458
1729
|
/**
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
* is sent twice or skipped.
|
|
1730
|
+
* Provider-aware browser-direct upload. Creates a session, uses the storage
|
|
1731
|
+
* transport selected by the server, reports progress, and finalizes into a
|
|
1732
|
+
* FileValue. Resumable providers can continue an existing session; one-shot
|
|
1733
|
+
* transports are retried according to their own safe semantics.
|
|
1464
1734
|
*
|
|
1465
1735
|
* Depending on the form's storage settings the server picks a transport:
|
|
1466
1736
|
* direct-to-Google-Drive resumable
|
|
@@ -1468,28 +1738,68 @@ var FilloClient = class {
|
|
|
1468
1738
|
* form owner's Drive and never touch the Fillo server.
|
|
1469
1739
|
*/
|
|
1470
1740
|
async uploadFile(formId, file, options2) {
|
|
1471
|
-
const name = file instanceof File ? file.name : "upload.bin";
|
|
1472
|
-
const mime = file.type || "application/octet-stream";
|
|
1741
|
+
const name = typeof File !== "undefined" && file instanceof File ? file.name : "upload.bin";
|
|
1742
|
+
const mime = (file.type || "application/octet-stream").trim().toLowerCase();
|
|
1473
1743
|
let session;
|
|
1474
1744
|
if (options2.sessionId) {
|
|
1475
|
-
|
|
1745
|
+
const resumed = await this.getUploadSession(
|
|
1746
|
+
options2.sessionId,
|
|
1747
|
+
options2.uploadToken,
|
|
1748
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1749
|
+
);
|
|
1750
|
+
if (resumed.formId !== formId || resumed.fieldId !== options2.fieldId || resumed.fileName !== name || resumed.size !== file.size || resumed.mime.trim().toLowerCase() !== mime) {
|
|
1751
|
+
throw new FilloError("File does not match this upload session", 400);
|
|
1752
|
+
}
|
|
1753
|
+
session = resumed;
|
|
1754
|
+
session.token ||= options2.uploadToken;
|
|
1476
1755
|
} else {
|
|
1477
|
-
|
|
1756
|
+
const requestId = createId(24);
|
|
1757
|
+
const requestToken = createId(32);
|
|
1758
|
+
const createSession = () => this.json(
|
|
1478
1759
|
`/forms/${encodeURIComponent(formId)}/uploads`,
|
|
1479
1760
|
{
|
|
1480
1761
|
method: "POST",
|
|
1481
1762
|
body: JSON.stringify({
|
|
1763
|
+
requestId,
|
|
1764
|
+
uploadToken: requestToken,
|
|
1482
1765
|
fieldId: options2.fieldId,
|
|
1483
1766
|
fileName: name,
|
|
1484
1767
|
size: file.size,
|
|
1485
1768
|
mime
|
|
1486
|
-
})
|
|
1769
|
+
}),
|
|
1770
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1487
1771
|
}
|
|
1488
1772
|
);
|
|
1773
|
+
const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
|
|
1774
|
+
let backoffMs = 250;
|
|
1775
|
+
for (; ; ) {
|
|
1776
|
+
try {
|
|
1777
|
+
session = await createSession();
|
|
1778
|
+
break;
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
if (options2.signal?.aborted) throw error;
|
|
1781
|
+
if (!isControlRetryable(error, true)) throw error;
|
|
1782
|
+
const remaining = deadline - Date.now();
|
|
1783
|
+
if (remaining <= 0) throw error;
|
|
1784
|
+
await waitForRetry(
|
|
1785
|
+
Math.min(backoffMs, remaining),
|
|
1786
|
+
options2.signal
|
|
1787
|
+
);
|
|
1788
|
+
backoffMs = Math.min(backoffMs * 2, 5e3);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1489
1791
|
}
|
|
1792
|
+
options2.onSession?.({
|
|
1793
|
+
sessionId: session.id,
|
|
1794
|
+
uploadToken: session.token
|
|
1795
|
+
});
|
|
1796
|
+
if (session.status === "complete" && session.file) return session.file;
|
|
1797
|
+
if (session.status === "aborted") throw new FilloError("This upload session was cancelled", 409);
|
|
1490
1798
|
let completeBody;
|
|
1491
1799
|
if (session.transport?.type === "gdrive") {
|
|
1492
1800
|
await this.driveUploadLoop(session, session.transport.uploadUrl, file, options2);
|
|
1801
|
+
} else if (session.transport?.type === "s3-multipart") {
|
|
1802
|
+
await this.s3MultipartUploadLoop(session, file, options2);
|
|
1493
1803
|
} else if (session.transport?.type === "s3-put") {
|
|
1494
1804
|
await this.s3PutUpload(session.transport.uploadUrl, file, options2);
|
|
1495
1805
|
} else if (session.transport?.type === "box") {
|
|
@@ -1497,14 +1807,52 @@ var FilloClient = class {
|
|
|
1497
1807
|
} else {
|
|
1498
1808
|
throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
|
|
1499
1809
|
}
|
|
1500
|
-
const
|
|
1810
|
+
const finalize = () => this.json(
|
|
1501
1811
|
`/uploads/${encodeURIComponent(session.id)}/complete`,
|
|
1502
1812
|
{
|
|
1503
1813
|
method: "POST",
|
|
1504
1814
|
headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
|
|
1505
|
-
...completeBody ? { body: JSON.stringify(completeBody) } : {}
|
|
1815
|
+
...completeBody ? { body: JSON.stringify(completeBody) } : {},
|
|
1816
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1506
1817
|
}
|
|
1507
1818
|
);
|
|
1819
|
+
let done;
|
|
1820
|
+
try {
|
|
1821
|
+
done = await finalize();
|
|
1822
|
+
} catch (error) {
|
|
1823
|
+
if (options2.signal?.aborted) throw error;
|
|
1824
|
+
if (!isControlRetryable(error)) throw error;
|
|
1825
|
+
const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
|
|
1826
|
+
let backoffMs = 250;
|
|
1827
|
+
for (; ; ) {
|
|
1828
|
+
const reconciled = await this.getUploadSession(
|
|
1829
|
+
session.id,
|
|
1830
|
+
session.token,
|
|
1831
|
+
this.uploadSignal(
|
|
1832
|
+
options2,
|
|
1833
|
+
Math.max(1, deadline - Date.now())
|
|
1834
|
+
)
|
|
1835
|
+
);
|
|
1836
|
+
if (reconciled.status === "complete" && reconciled.file) {
|
|
1837
|
+
return reconciled.file;
|
|
1838
|
+
}
|
|
1839
|
+
if (reconciled.status === "aborted") throw error;
|
|
1840
|
+
try {
|
|
1841
|
+
done = await finalize();
|
|
1842
|
+
break;
|
|
1843
|
+
} catch (retryError) {
|
|
1844
|
+
if (options2.signal?.aborted) throw retryError;
|
|
1845
|
+
if (!isControlRetryable(retryError, true)) throw retryError;
|
|
1846
|
+
}
|
|
1847
|
+
const remaining = deadline - Date.now();
|
|
1848
|
+
if (remaining <= 0) throw error;
|
|
1849
|
+
await waitForRetry(
|
|
1850
|
+
Math.min(backoffMs, remaining),
|
|
1851
|
+
options2.signal
|
|
1852
|
+
);
|
|
1853
|
+
backoffMs = Math.min(backoffMs * 2, 5e3);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1508
1856
|
if (!done.file) throw new FilloError("Upload completed but no file was returned");
|
|
1509
1857
|
return done.file;
|
|
1510
1858
|
}
|
|
@@ -1613,10 +1961,10 @@ var FilloClient = class {
|
|
|
1613
1961
|
/**
|
|
1614
1962
|
* Caller signal (if any) combined with a fresh per-request timeout. Created per
|
|
1615
1963
|
* fetch so each retry attempt gets its own deadline; the caller's own abort
|
|
1616
|
-
* still drives
|
|
1964
|
+
* still drives upload cancellation across every provider transport.
|
|
1617
1965
|
*/
|
|
1618
|
-
uploadSignal(options2) {
|
|
1619
|
-
const timeout = AbortSignal.timeout(
|
|
1966
|
+
uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1967
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
1620
1968
|
return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
|
|
1621
1969
|
}
|
|
1622
1970
|
/** Retry a request with exponential backoff; never retries an aborted upload. */
|
|
@@ -1665,7 +2013,13 @@ var FilloClient = class {
|
|
|
1665
2013
|
const match = range?.match(/bytes=0-(\d+)/);
|
|
1666
2014
|
if (match) offset = Number(match[1]) + 1;
|
|
1667
2015
|
else if (range == null) offset = 0;
|
|
1668
|
-
else
|
|
2016
|
+
else {
|
|
2017
|
+
offset = (await this.getUploadSession(
|
|
2018
|
+
session.id,
|
|
2019
|
+
session.token,
|
|
2020
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2021
|
+
)).uploadedBytes;
|
|
2022
|
+
}
|
|
1669
2023
|
break;
|
|
1670
2024
|
}
|
|
1671
2025
|
if (res.ok) {
|
|
@@ -1678,7 +2032,11 @@ var FilloClient = class {
|
|
|
1678
2032
|
attempt += 1;
|
|
1679
2033
|
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
1680
2034
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
1681
|
-
const fresh = await this.getUploadSession(
|
|
2035
|
+
const fresh = await this.getUploadSession(
|
|
2036
|
+
session.id,
|
|
2037
|
+
session.token,
|
|
2038
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2039
|
+
);
|
|
1682
2040
|
offset = fresh.uploadedBytes;
|
|
1683
2041
|
if (offset >= file.size) break;
|
|
1684
2042
|
end = Math.min(offset + session.chunkSize, file.size);
|
|
@@ -1695,6 +2053,80 @@ var FilloClient = class {
|
|
|
1695
2053
|
}
|
|
1696
2054
|
}
|
|
1697
2055
|
}
|
|
2056
|
+
/**
|
|
2057
|
+
* S3-compatible multipart protocol. Fillo owns the provider upload id and is
|
|
2058
|
+
* the only party allowed to complete it; the browser receives only narrowly
|
|
2059
|
+
* scoped UploadPart URLs. That keeps uploads resumable and means an old or
|
|
2060
|
+
* slow browser request cannot materialize an object after server-side abort.
|
|
2061
|
+
*/
|
|
2062
|
+
async s3MultipartUploadLoop(session, file, options2) {
|
|
2063
|
+
if (!Number.isSafeInteger(session.chunkSize) || session.chunkSize <= 0 || !Number.isSafeInteger(session.uploadedBytes) || session.uploadedBytes < 0 || session.uploadedBytes > file.size || session.uploadedBytes < file.size && session.uploadedBytes % session.chunkSize !== 0) {
|
|
2064
|
+
throw new FilloError("S3 upload session returned invalid progress", 502);
|
|
2065
|
+
}
|
|
2066
|
+
const report = makeReporter(file.size, options2);
|
|
2067
|
+
let offset = session.uploadedBytes;
|
|
2068
|
+
report(offset);
|
|
2069
|
+
while (offset < file.size) {
|
|
2070
|
+
options2.signal?.throwIfAborted();
|
|
2071
|
+
const partNumber = Math.floor(offset / session.chunkSize) + 1;
|
|
2072
|
+
if (partNumber > 1e4) {
|
|
2073
|
+
throw new FilloError("S3 upload needs too many parts", 413);
|
|
2074
|
+
}
|
|
2075
|
+
const end = Math.min(offset + session.chunkSize, file.size);
|
|
2076
|
+
const chunk = file.slice(offset, end);
|
|
2077
|
+
let attempt = 0;
|
|
2078
|
+
for (; ; ) {
|
|
2079
|
+
let uploadUrl;
|
|
2080
|
+
try {
|
|
2081
|
+
const signed = await this.json(
|
|
2082
|
+
`/uploads/${encodeURIComponent(session.id)}/parts`,
|
|
2083
|
+
{
|
|
2084
|
+
method: "POST",
|
|
2085
|
+
headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
|
|
2086
|
+
body: JSON.stringify({ partNumber }),
|
|
2087
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2088
|
+
}
|
|
2089
|
+
);
|
|
2090
|
+
if (typeof signed.uploadUrl !== "string" || !signed.uploadUrl) {
|
|
2091
|
+
throw new FilloError("S3 part URL was unavailable", 502);
|
|
2092
|
+
}
|
|
2093
|
+
uploadUrl = signed.uploadUrl;
|
|
2094
|
+
const res = await this.fetch(uploadUrl, {
|
|
2095
|
+
method: "PUT",
|
|
2096
|
+
body: chunk,
|
|
2097
|
+
signal: this.uploadSignal(options2, S3_PART_TIMEOUT_MS)
|
|
2098
|
+
});
|
|
2099
|
+
if (!res.ok) {
|
|
2100
|
+
throw new FilloError(`S3 part upload failed: ${res.status}`, res.status);
|
|
2101
|
+
}
|
|
2102
|
+
offset = end;
|
|
2103
|
+
report(offset);
|
|
2104
|
+
break;
|
|
2105
|
+
} catch (err) {
|
|
2106
|
+
if (options2.signal?.aborted) throw err;
|
|
2107
|
+
const fresh = await this.getUploadSession(
|
|
2108
|
+
session.id,
|
|
2109
|
+
session.token,
|
|
2110
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2111
|
+
);
|
|
2112
|
+
if (fresh.status === "aborted") {
|
|
2113
|
+
throw new FilloError("This upload session was cancelled", 409);
|
|
2114
|
+
}
|
|
2115
|
+
if (!Number.isSafeInteger(fresh.uploadedBytes) || fresh.uploadedBytes < offset || fresh.uploadedBytes > file.size || fresh.uploadedBytes < file.size && fresh.uploadedBytes % session.chunkSize !== 0) {
|
|
2116
|
+
throw new FilloError("S3 upload session returned invalid progress", 502);
|
|
2117
|
+
}
|
|
2118
|
+
if (fresh.uploadedBytes > offset) {
|
|
2119
|
+
offset = fresh.uploadedBytes;
|
|
2120
|
+
report(offset);
|
|
2121
|
+
break;
|
|
2122
|
+
}
|
|
2123
|
+
attempt += 1;
|
|
2124
|
+
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
2125
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
1698
2130
|
/**
|
|
1699
2131
|
* S3-compatible single PUT to a presigned URL — bytes go straight to the
|
|
1700
2132
|
* bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
|
|
@@ -1785,7 +2217,7 @@ function prefillFromParams(form, params) {
|
|
|
1785
2217
|
}
|
|
1786
2218
|
case "multi_select": {
|
|
1787
2219
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
1788
|
-
const picked = raw.split(",").filter((v) => ids.has(v));
|
|
2220
|
+
const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
|
|
1789
2221
|
if (picked.length > 0) data[field2.id] = picked;
|
|
1790
2222
|
break;
|
|
1791
2223
|
}
|
|
@@ -1842,25 +2274,36 @@ function randomSubmissionKey() {
|
|
|
1842
2274
|
}
|
|
1843
2275
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1844
2276
|
}
|
|
1845
|
-
function
|
|
2277
|
+
function readStoredJson(memory, storageKey2, memoryKey, isValid) {
|
|
1846
2278
|
const store = browserStorage();
|
|
1847
|
-
if (!store) return
|
|
2279
|
+
if (!store) return memory.get(memoryKey) ?? null;
|
|
1848
2280
|
try {
|
|
1849
|
-
const parsed = JSON.parse(store.getItem(
|
|
1850
|
-
if (parsed &&
|
|
2281
|
+
const parsed = JSON.parse(store.getItem(storageKey2) ?? "null");
|
|
2282
|
+
if (parsed && isValid(parsed)) return parsed;
|
|
1851
2283
|
} catch {
|
|
1852
2284
|
}
|
|
1853
2285
|
return null;
|
|
1854
2286
|
}
|
|
1855
|
-
function
|
|
1856
|
-
|
|
2287
|
+
function writeStoredJson(memory, storageKey2, memoryKey, value) {
|
|
2288
|
+
memory.set(memoryKey, value);
|
|
1857
2289
|
const store = browserStorage();
|
|
1858
2290
|
if (!store) return;
|
|
1859
2291
|
try {
|
|
1860
|
-
store.setItem(
|
|
2292
|
+
store.setItem(storageKey2, JSON.stringify(value));
|
|
1861
2293
|
} catch {
|
|
1862
2294
|
}
|
|
1863
2295
|
}
|
|
2296
|
+
function readStoredSubmission(formId) {
|
|
2297
|
+
return readStoredJson(
|
|
2298
|
+
memorySubmissionKeys,
|
|
2299
|
+
submissionStorageKey(formId),
|
|
2300
|
+
formId,
|
|
2301
|
+
(p) => typeof p.key === "string"
|
|
2302
|
+
);
|
|
2303
|
+
}
|
|
2304
|
+
function writeStoredSubmission(formId, value) {
|
|
2305
|
+
writeStoredJson(memorySubmissionKeys, submissionStorageKey(formId), formId, value);
|
|
2306
|
+
}
|
|
1864
2307
|
function ensureSubmissionKey(formId) {
|
|
1865
2308
|
const existing = readStoredSubmission(formId);
|
|
1866
2309
|
if (existing?.key) return existing.key;
|
|
@@ -1876,14 +2319,52 @@ function markSubmitted(formId, responseId) {
|
|
|
1876
2319
|
responseId
|
|
1877
2320
|
});
|
|
1878
2321
|
}
|
|
1879
|
-
function
|
|
1880
|
-
|
|
1881
|
-
return
|
|
2322
|
+
function visitorSubmissionKeyId(form, formId, data) {
|
|
2323
|
+
const scope = responseScopeValue(form.settings, data);
|
|
2324
|
+
return scope === null ? formId : `${formId}::${scope}`;
|
|
2325
|
+
}
|
|
2326
|
+
function hasSubmittedOnce(form, formId, data) {
|
|
2327
|
+
if (form.settings.responseLimit?.by !== "browser" || !formId) return false;
|
|
2328
|
+
return Boolean(readStoredSubmission(visitorSubmissionKeyId(form, formId, data))?.submittedAt);
|
|
2329
|
+
}
|
|
2330
|
+
var memoryDraftRefs = /* @__PURE__ */ new Map();
|
|
2331
|
+
function draftStorageKey(formId) {
|
|
2332
|
+
return `fillo:draft:${formId}`;
|
|
2333
|
+
}
|
|
2334
|
+
function readDraftRef(formId) {
|
|
2335
|
+
return readStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, (p) => {
|
|
2336
|
+
const ref = p;
|
|
2337
|
+
return typeof ref.id === "string" && typeof ref.token === "string";
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
function writeDraftRef(formId, ref) {
|
|
2341
|
+
writeStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, ref);
|
|
2342
|
+
}
|
|
2343
|
+
function clearDraftRef(formId) {
|
|
2344
|
+
memoryDraftRefs.delete(formId);
|
|
2345
|
+
const store = browserStorage();
|
|
2346
|
+
if (!store) return;
|
|
2347
|
+
try {
|
|
2348
|
+
store.removeItem(draftStorageKey(formId));
|
|
2349
|
+
} catch {
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
function isDraftGone(err) {
|
|
2353
|
+
return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
|
|
1882
2354
|
}
|
|
2355
|
+
var DRAFT_DEBOUNCE_MS = 1500;
|
|
1883
2356
|
function submitFailureMessage(err) {
|
|
1884
2357
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
1885
2358
|
return "Couldn't reach the server \u2014 check your connection and try again. If this keeps happening, a browser extension, firewall, or the site's Content-Security-Policy may be blocking the request.";
|
|
1886
2359
|
}
|
|
2360
|
+
function syncResolutionFailureMessage(err) {
|
|
2361
|
+
if (isFilloError(err)) {
|
|
2362
|
+
const status = err.status ?? 0;
|
|
2363
|
+
const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
|
|
2364
|
+
if (definitive) return "This form is unavailable.";
|
|
2365
|
+
}
|
|
2366
|
+
return submitFailureMessage(err);
|
|
2367
|
+
}
|
|
1887
2368
|
function createFormController(options2) {
|
|
1888
2369
|
const { onChange, onSubmitted } = options2;
|
|
1889
2370
|
const initial = normalizeFormSchema(options2.form);
|
|
@@ -1891,15 +2372,14 @@ function createFormController(options2) {
|
|
|
1891
2372
|
let form = initial.schema;
|
|
1892
2373
|
let formId = options2.formId;
|
|
1893
2374
|
let client = options2.client;
|
|
2375
|
+
let respondent = options2.respondent;
|
|
1894
2376
|
let data = { ...options2.initialData ?? {} };
|
|
1895
2377
|
let errors = {};
|
|
1896
2378
|
let pageIndex = 0;
|
|
1897
2379
|
let status = "idle";
|
|
2380
|
+
let restoredSubmission = false;
|
|
2381
|
+
let resumeLinkFailed = false;
|
|
1898
2382
|
queueMicrotask(() => {
|
|
1899
|
-
if (status === "idle" && hasSubmittedOnce(form, formId)) {
|
|
1900
|
-
status = "submitted";
|
|
1901
|
-
notify();
|
|
1902
|
-
}
|
|
1903
2383
|
if (typeof location !== "undefined" && location.search) {
|
|
1904
2384
|
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
1905
2385
|
let merged = null;
|
|
@@ -1914,6 +2394,35 @@ function createFormController(options2) {
|
|
|
1914
2394
|
notify();
|
|
1915
2395
|
}
|
|
1916
2396
|
}
|
|
2397
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2398
|
+
status = "submitted";
|
|
2399
|
+
restoredSubmission = true;
|
|
2400
|
+
notify();
|
|
2401
|
+
}
|
|
2402
|
+
let adopting = false;
|
|
2403
|
+
if (form.settings.saveProgress && formId && client && typeof location !== "undefined" && location.hash.includes("fillo-draft=")) {
|
|
2404
|
+
const match = /fillo-draft=([^&]+)/.exec(location.hash);
|
|
2405
|
+
const raw = match?.[1] ? decodeURIComponent(match[1]) : "";
|
|
2406
|
+
const dot = raw.indexOf(".");
|
|
2407
|
+
if (dot > 0) {
|
|
2408
|
+
adopting = true;
|
|
2409
|
+
const did = raw.slice(0, dot);
|
|
2410
|
+
const dtok = raw.slice(dot + 1);
|
|
2411
|
+
client.getDraft(did, dtok, true).then((d) => {
|
|
2412
|
+
writeDraftRef(formId, { id: did, token: d.token ?? dtok });
|
|
2413
|
+
try {
|
|
2414
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
2415
|
+
} catch {
|
|
2416
|
+
}
|
|
2417
|
+
}).catch((err) => {
|
|
2418
|
+
if (isDraftGone(err)) {
|
|
2419
|
+
resumeLinkFailed = true;
|
|
2420
|
+
notify();
|
|
2421
|
+
}
|
|
2422
|
+
}).finally(() => maybeRestoreDraft());
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
if (!adopting) maybeRestoreDraft();
|
|
1917
2426
|
});
|
|
1918
2427
|
let submitError;
|
|
1919
2428
|
const uploadingFields = /* @__PURE__ */ new Set();
|
|
@@ -1922,6 +2431,17 @@ function createFormController(options2) {
|
|
|
1922
2431
|
const idempotencyKey = randomSubmissionKey();
|
|
1923
2432
|
let sessionId = null;
|
|
1924
2433
|
let sessionStarted = false;
|
|
2434
|
+
let resumedDraft = false;
|
|
2435
|
+
let editingPrevious = false;
|
|
2436
|
+
let submissionKind = "created";
|
|
2437
|
+
let editAttempted = false;
|
|
2438
|
+
let draftRef = null;
|
|
2439
|
+
let draftTimer = null;
|
|
2440
|
+
let draftChain = Promise.resolve();
|
|
2441
|
+
let draftDirty = false;
|
|
2442
|
+
let draftDisabled = false;
|
|
2443
|
+
let draftRestoreAttempted = false;
|
|
2444
|
+
let draftAffinityAttempted = false;
|
|
1925
2445
|
function computeSnapshot() {
|
|
1926
2446
|
const pageCount = form.pages.length;
|
|
1927
2447
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
@@ -1933,11 +2453,20 @@ function createFormController(options2) {
|
|
|
1933
2453
|
pageIndex: clamped,
|
|
1934
2454
|
pageCount,
|
|
1935
2455
|
page,
|
|
1936
|
-
|
|
2456
|
+
// Whole-form visibility fixpoint (not just this page's fields) so a field
|
|
2457
|
+
// gated by a cross-page answer renders/validates exactly when the server
|
|
2458
|
+
// keeps it — never a field whose answer submit would silently drop.
|
|
2459
|
+
blocks: visiblePageBlocks(form, page, data),
|
|
1937
2460
|
isFirstPage: clamped === 0,
|
|
1938
2461
|
isLastPage: clamped === pageCount - 1,
|
|
1939
2462
|
uploading: uploadingFields.size > 0,
|
|
1940
|
-
submitError
|
|
2463
|
+
submitError,
|
|
2464
|
+
restoredSubmission,
|
|
2465
|
+
resumedDraft,
|
|
2466
|
+
editingPrevious,
|
|
2467
|
+
duplicateSubmission: submissionKind === "duplicate",
|
|
2468
|
+
updatedSubmission: submissionKind === "updated",
|
|
2469
|
+
resumeLinkFailed
|
|
1941
2470
|
};
|
|
1942
2471
|
}
|
|
1943
2472
|
let snapshot = computeSnapshot();
|
|
@@ -1956,13 +2485,161 @@ function createFormController(options2) {
|
|
|
1956
2485
|
const target = form.pages[index];
|
|
1957
2486
|
if (!target) return {};
|
|
1958
2487
|
const pageErrors = {};
|
|
1959
|
-
for (const block of
|
|
2488
|
+
for (const block of visiblePageBlocks(form, target, data)) {
|
|
1960
2489
|
if (!isField(block)) continue;
|
|
1961
2490
|
const error = validateField(block, data[block.id]);
|
|
1962
2491
|
if (error) pageErrors[block.id] = error;
|
|
1963
2492
|
}
|
|
1964
2493
|
return pageErrors;
|
|
1965
2494
|
}
|
|
2495
|
+
function draftsEnabled() {
|
|
2496
|
+
return Boolean(form.settings.saveProgress && client && formId && !draftDisabled);
|
|
2497
|
+
}
|
|
2498
|
+
function knownFieldIds() {
|
|
2499
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2500
|
+
for (const page of form.pages) {
|
|
2501
|
+
for (const block of page.blocks) if (isField(block)) ids.add(block.id);
|
|
2502
|
+
}
|
|
2503
|
+
return ids;
|
|
2504
|
+
}
|
|
2505
|
+
function applyDraftSnapshot(draft) {
|
|
2506
|
+
if (status !== "idle" || sessionStarted) return;
|
|
2507
|
+
const ids = knownFieldIds();
|
|
2508
|
+
let merged = null;
|
|
2509
|
+
for (const [key, value] of Object.entries(draft.data ?? {})) {
|
|
2510
|
+
if (data[key] === void 0 && ids.has(key)) {
|
|
2511
|
+
merged = merged ?? { ...data };
|
|
2512
|
+
merged[key] = value;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
const target = typeof draft.page === "number" && Number.isFinite(draft.page) ? Math.max(0, Math.min(Math.floor(draft.page), form.pages.length - 1)) : 0;
|
|
2516
|
+
const movePage = target > 0 && pageIndex === 0;
|
|
2517
|
+
if (!merged && !movePage) return;
|
|
2518
|
+
if (merged) data = merged;
|
|
2519
|
+
if (movePage) pageIndex = target;
|
|
2520
|
+
resumedDraft = true;
|
|
2521
|
+
notify();
|
|
2522
|
+
}
|
|
2523
|
+
function maybeRestoreDraft() {
|
|
2524
|
+
if (status !== "idle") return;
|
|
2525
|
+
if (!draftsEnabled()) {
|
|
2526
|
+
maybePrefillOwnResponse();
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
const fid = formId;
|
|
2530
|
+
const c = client;
|
|
2531
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2532
|
+
if (ref && !draftRestoreAttempted) {
|
|
2533
|
+
draftRestoreAttempted = true;
|
|
2534
|
+
draftRef = ref;
|
|
2535
|
+
void c.getDraft(ref.id, ref.token).then(applyDraftSnapshot).catch((err) => {
|
|
2536
|
+
if (isDraftGone(err)) {
|
|
2537
|
+
clearDraftRef(fid);
|
|
2538
|
+
draftRef = null;
|
|
2539
|
+
}
|
|
2540
|
+
});
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (!ref && !draftAffinityAttempted && respondent?.hash) {
|
|
2544
|
+
draftAffinityAttempted = true;
|
|
2545
|
+
void c.createDraft(fid, { data: {}, page: 0, respondent }).then((created) => {
|
|
2546
|
+
if (draftRef) return;
|
|
2547
|
+
draftRef = { id: created.id, token: created.token };
|
|
2548
|
+
writeDraftRef(fid, draftRef);
|
|
2549
|
+
if (created.existing) {
|
|
2550
|
+
return c.getDraft(created.id, created.token).then(applyDraftSnapshot);
|
|
2551
|
+
}
|
|
2552
|
+
maybePrefillOwnResponse();
|
|
2553
|
+
}).catch((err) => {
|
|
2554
|
+
if (!(isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500)) {
|
|
2555
|
+
draftAffinityAttempted = false;
|
|
2556
|
+
}
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function maybePrefillOwnResponse() {
|
|
2561
|
+
if (editAttempted || form.settings.responseLimit?.onRepeat !== "update") return;
|
|
2562
|
+
if (!client || !formId || !respondent?.hash || status !== "idle") return;
|
|
2563
|
+
editAttempted = true;
|
|
2564
|
+
const c = client;
|
|
2565
|
+
const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
|
|
2566
|
+
void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
|
|
2567
|
+
if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
|
|
2568
|
+
applyDraftSnapshot({ data: own.data, page: 0 });
|
|
2569
|
+
if (resumedDraft) {
|
|
2570
|
+
resumedDraft = false;
|
|
2571
|
+
editingPrevious = true;
|
|
2572
|
+
notify();
|
|
2573
|
+
}
|
|
2574
|
+
}).catch(() => {
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
function persistDraft(keepalive = false) {
|
|
2578
|
+
if (draftTimer) {
|
|
2579
|
+
clearTimeout(draftTimer);
|
|
2580
|
+
draftTimer = null;
|
|
2581
|
+
}
|
|
2582
|
+
if (!draftsEnabled() || status !== "idle" || !draftDirty) return;
|
|
2583
|
+
const fid = formId;
|
|
2584
|
+
const c = client;
|
|
2585
|
+
draftDirty = false;
|
|
2586
|
+
draftChain = draftChain.then(async () => {
|
|
2587
|
+
const payload = {
|
|
2588
|
+
data,
|
|
2589
|
+
page: Math.max(0, Math.min(pageIndex, form.pages.length - 1))
|
|
2590
|
+
};
|
|
2591
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2592
|
+
try {
|
|
2593
|
+
if (ref) {
|
|
2594
|
+
try {
|
|
2595
|
+
await c.saveDraft(ref.id, ref.token, payload, keepalive ? { keepalive: true } : void 0);
|
|
2596
|
+
draftRef = ref;
|
|
2597
|
+
return;
|
|
2598
|
+
} catch (err) {
|
|
2599
|
+
if (!isDraftGone(err)) throw err;
|
|
2600
|
+
clearDraftRef(fid);
|
|
2601
|
+
draftRef = null;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
const created = await c.createDraft(
|
|
2605
|
+
fid,
|
|
2606
|
+
respondent?.hash ? { ...payload, respondent } : payload
|
|
2607
|
+
);
|
|
2608
|
+
draftRef = { id: created.id, token: created.token };
|
|
2609
|
+
writeDraftRef(fid, draftRef);
|
|
2610
|
+
if (created.existing) draftDirty = true;
|
|
2611
|
+
} catch (err) {
|
|
2612
|
+
if (isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500 && err.status !== 429) {
|
|
2613
|
+
draftDisabled = true;
|
|
2614
|
+
} else {
|
|
2615
|
+
draftDirty = true;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
function scheduleDraftSave() {
|
|
2621
|
+
if (!draftsEnabled() || status !== "idle") return;
|
|
2622
|
+
draftDirty = true;
|
|
2623
|
+
if (draftTimer) clearTimeout(draftTimer);
|
|
2624
|
+
draftTimer = setTimeout(() => {
|
|
2625
|
+
draftTimer = null;
|
|
2626
|
+
persistDraft();
|
|
2627
|
+
}, DRAFT_DEBOUNCE_MS);
|
|
2628
|
+
}
|
|
2629
|
+
function checkpointDraft() {
|
|
2630
|
+
if (!draftsEnabled() || !sessionStarted && !draftRef) return;
|
|
2631
|
+
draftDirty = true;
|
|
2632
|
+
persistDraft();
|
|
2633
|
+
}
|
|
2634
|
+
function discardDraftState() {
|
|
2635
|
+
if (draftTimer) {
|
|
2636
|
+
clearTimeout(draftTimer);
|
|
2637
|
+
draftTimer = null;
|
|
2638
|
+
}
|
|
2639
|
+
draftDirty = false;
|
|
2640
|
+
if (formId) clearDraftRef(formId);
|
|
2641
|
+
draftRef = null;
|
|
2642
|
+
}
|
|
1966
2643
|
function setValue(fieldId, value) {
|
|
1967
2644
|
if (!sessionStarted && client && formId) {
|
|
1968
2645
|
sessionStarted = true;
|
|
@@ -1975,6 +2652,7 @@ function createFormController(options2) {
|
|
|
1975
2652
|
errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
|
|
1976
2653
|
}
|
|
1977
2654
|
notify();
|
|
2655
|
+
scheduleDraftSave();
|
|
1978
2656
|
}
|
|
1979
2657
|
function setUploading(fieldId, busy) {
|
|
1980
2658
|
if (busy) uploadingFields.add(fieldId);
|
|
@@ -1995,11 +2673,13 @@ function createFormController(options2) {
|
|
|
1995
2673
|
pageIndex = Math.min(clamped + 1, pageCount - 1);
|
|
1996
2674
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
1997
2675
|
notify();
|
|
2676
|
+
checkpointDraft();
|
|
1998
2677
|
}
|
|
1999
2678
|
function back() {
|
|
2000
2679
|
errors = {};
|
|
2001
2680
|
pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
2002
2681
|
notify();
|
|
2682
|
+
checkpointDraft();
|
|
2003
2683
|
}
|
|
2004
2684
|
async function submit() {
|
|
2005
2685
|
if (status === "submitting" || status === "submitted") return;
|
|
@@ -2007,13 +2687,13 @@ function createFormController(options2) {
|
|
|
2007
2687
|
if (!result.ok) {
|
|
2008
2688
|
errors = result.errors;
|
|
2009
2689
|
const firstBad = form.pages.findIndex(
|
|
2010
|
-
(p) =>
|
|
2690
|
+
(p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
|
|
2011
2691
|
);
|
|
2012
2692
|
if (firstBad >= 0) pageIndex = firstBad;
|
|
2013
2693
|
notify();
|
|
2014
2694
|
return;
|
|
2015
2695
|
}
|
|
2016
|
-
if (client &&
|
|
2696
|
+
if (client && options2.resolveFormId) {
|
|
2017
2697
|
status = "submitting";
|
|
2018
2698
|
submitError = void 0;
|
|
2019
2699
|
notify();
|
|
@@ -2021,14 +2701,15 @@ function createFormController(options2) {
|
|
|
2021
2701
|
formId = await options2.resolveFormId();
|
|
2022
2702
|
} catch (err) {
|
|
2023
2703
|
status = "idle";
|
|
2024
|
-
submitError =
|
|
2704
|
+
submitError = syncResolutionFailureMessage(err);
|
|
2025
2705
|
notify();
|
|
2026
2706
|
throw err;
|
|
2027
2707
|
}
|
|
2028
2708
|
}
|
|
2029
2709
|
if (client && formId) {
|
|
2030
|
-
const visitorKey = form.settings.
|
|
2710
|
+
const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
|
|
2031
2711
|
const submissionKey = visitorKey ?? idempotencyKey;
|
|
2712
|
+
const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
|
|
2032
2713
|
status = "submitting";
|
|
2033
2714
|
submitError = void 0;
|
|
2034
2715
|
notify();
|
|
@@ -2038,7 +2719,9 @@ function createFormController(options2) {
|
|
|
2038
2719
|
hp: options2.getHoneypot?.() ?? "",
|
|
2039
2720
|
elapsedMs: Date.now() - mountedAt,
|
|
2040
2721
|
surface: options2.surface ?? "headless",
|
|
2041
|
-
submissionKey
|
|
2722
|
+
submissionKey,
|
|
2723
|
+
draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
|
|
2724
|
+
respondent
|
|
2042
2725
|
});
|
|
2043
2726
|
} catch (err) {
|
|
2044
2727
|
status = "idle";
|
|
@@ -2048,12 +2731,19 @@ function createFormController(options2) {
|
|
|
2048
2731
|
}
|
|
2049
2732
|
if (!res.ok) {
|
|
2050
2733
|
errors = res.errors ?? {};
|
|
2734
|
+
const firstBad = form.pages.findIndex(
|
|
2735
|
+
(p) => visiblePageBlocks(form, p, data).some((b) => b.id in errors)
|
|
2736
|
+
);
|
|
2737
|
+
if (firstBad >= 0) pageIndex = firstBad;
|
|
2738
|
+
else submitError = "We couldn't submit \u2014 please review your answers.";
|
|
2051
2739
|
status = "idle";
|
|
2052
2740
|
notify();
|
|
2053
2741
|
return;
|
|
2054
2742
|
}
|
|
2743
|
+
submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
|
|
2055
2744
|
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
2056
|
-
if (visitorKey) markSubmitted(formId, res.responseId);
|
|
2745
|
+
if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
|
|
2746
|
+
discardDraftState();
|
|
2057
2747
|
status = "submitted";
|
|
2058
2748
|
notify();
|
|
2059
2749
|
emitSubmitted(res.responseId, result.data);
|
|
@@ -2086,15 +2776,47 @@ function createFormController(options2) {
|
|
|
2086
2776
|
setContext(ctx) {
|
|
2087
2777
|
if ("formId" in ctx) formId = ctx.formId;
|
|
2088
2778
|
if ("client" in ctx) client = ctx.client;
|
|
2779
|
+
if ("respondent" in ctx) respondent = ctx.respondent;
|
|
2089
2780
|
if (ctx.form && ctx.form !== form) {
|
|
2090
2781
|
const next2 = normalizeFormSchema(ctx.form);
|
|
2091
2782
|
if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
|
|
2092
2783
|
form = next2.schema;
|
|
2093
2784
|
}
|
|
2094
|
-
if (status === "idle" && hasSubmittedOnce(form, formId))
|
|
2785
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2786
|
+
status = "submitted";
|
|
2787
|
+
restoredSubmission = true;
|
|
2788
|
+
}
|
|
2789
|
+
maybeRestoreDraft();
|
|
2790
|
+
notify();
|
|
2791
|
+
},
|
|
2792
|
+
flushDraft() {
|
|
2793
|
+
persistDraft(true);
|
|
2794
|
+
},
|
|
2795
|
+
resetDraft() {
|
|
2796
|
+
if (status === "submitting") return;
|
|
2797
|
+
const ref = draftRef ?? (formId ? readDraftRef(formId) : null);
|
|
2798
|
+
discardDraftState();
|
|
2799
|
+
if (ref && client) void client.deleteDraft(ref.id, ref.token).catch(() => {
|
|
2800
|
+
});
|
|
2801
|
+
data = { ...options2.initialData ?? {} };
|
|
2802
|
+
if (typeof location !== "undefined" && location.search) {
|
|
2803
|
+
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
2804
|
+
for (const [key, value] of Object.entries(prefillFromParams(form, params))) {
|
|
2805
|
+
if (data[key] === void 0) data[key] = value;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
errors = {};
|
|
2809
|
+
pageIndex = 0;
|
|
2810
|
+
submitError = void 0;
|
|
2811
|
+
resumedDraft = false;
|
|
2812
|
+
editingPrevious = false;
|
|
2095
2813
|
notify();
|
|
2096
2814
|
},
|
|
2097
2815
|
destroy() {
|
|
2816
|
+
if (draftTimer) {
|
|
2817
|
+
clearTimeout(draftTimer);
|
|
2818
|
+
draftTimer = null;
|
|
2819
|
+
}
|
|
2098
2820
|
listeners.clear();
|
|
2099
2821
|
}
|
|
2100
2822
|
};
|
|
@@ -2105,9 +2827,8 @@ function isAutoSubmitBlock(block) {
|
|
|
2105
2827
|
return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
|
|
2106
2828
|
}
|
|
2107
2829
|
function needsExplicitSubmit(visible) {
|
|
2108
|
-
const interactive = visible.filter((b) =>
|
|
2109
|
-
if (interactive.length
|
|
2110
|
-
if (interactive.length > 1) return true;
|
|
2830
|
+
const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
|
|
2831
|
+
if (interactive.length !== 1) return true;
|
|
2111
2832
|
return !isAutoSubmitBlock(interactive[0]);
|
|
2112
2833
|
}
|
|
2113
2834
|
function shouldAutoSubmit(field2, value, ctx) {
|
|
@@ -2155,7 +2876,8 @@ var FILLO_SLOTS = [
|
|
|
2155
2876
|
"error",
|
|
2156
2877
|
"footer",
|
|
2157
2878
|
"button",
|
|
2158
|
-
"success"
|
|
2879
|
+
"success",
|
|
2880
|
+
"resume"
|
|
2159
2881
|
];
|
|
2160
2882
|
var FILLO_DATA_ATTRS = {
|
|
2161
2883
|
/** Slot name, on every slot element: `data-fillo="control"`. */
|
|
@@ -2204,32 +2926,6 @@ var FILLO_THEME_VARS = [
|
|
|
2204
2926
|
{ var: "--fillo-primary-contrast" }
|
|
2205
2927
|
];
|
|
2206
2928
|
|
|
2207
|
-
// src/strings.ts
|
|
2208
|
-
var DEFAULT_STRINGS = {
|
|
2209
|
-
back: "Back",
|
|
2210
|
-
next: "Next",
|
|
2211
|
-
submit: "Submit",
|
|
2212
|
-
submitting: "Submitting\u2026",
|
|
2213
|
-
uploading: "Uploading\u2026",
|
|
2214
|
-
optional: " (optional)",
|
|
2215
|
-
other: "Other",
|
|
2216
|
-
otherPrompt: "Other \u2014 please specify",
|
|
2217
|
-
otherPlaceholder: "Your answer",
|
|
2218
|
-
choosePlaceholder: "Choose\u2026",
|
|
2219
|
-
successTitle: "Thanks!",
|
|
2220
|
-
successMessage: "Your response has been recorded.",
|
|
2221
|
-
closed: "This form is no longer accepting responses.",
|
|
2222
|
-
notLive: "This form isn't live yet.",
|
|
2223
|
-
submitFailed: "This form can't submit right now. Please try again in a moment.",
|
|
2224
|
-
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
2225
|
-
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
2226
|
-
loadFailed: "This form could not be loaded.",
|
|
2227
|
-
renderFailed: "This form could not be rendered."
|
|
2228
|
-
};
|
|
2229
|
-
function resolveStrings(overrides) {
|
|
2230
|
-
return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
2929
|
// src/define.ts
|
|
2234
2930
|
function defineForm(def) {
|
|
2235
2931
|
return {
|
|
@@ -2250,15 +2946,18 @@ function isCodeForm(form) {
|
|
|
2250
2946
|
}
|
|
2251
2947
|
var syncCache = /* @__PURE__ */ new Map();
|
|
2252
2948
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2949
|
+
function isRetryableSyncError(error) {
|
|
2950
|
+
if (!isFilloError(error)) return true;
|
|
2951
|
+
const status = error.status ?? 0;
|
|
2952
|
+
return status === 0 || status === 408 || status === 429 || status >= 500;
|
|
2953
|
+
}
|
|
2253
2954
|
async function syncWithRetry(client, form) {
|
|
2254
2955
|
let delay = 800;
|
|
2255
2956
|
for (let attempt = 0; ; attempt++) {
|
|
2256
2957
|
try {
|
|
2257
2958
|
return await client.syncForm(form.id, form.schema, form.theme ?? null);
|
|
2258
2959
|
} catch (err) {
|
|
2259
|
-
|
|
2260
|
-
const retryable = !isFilloError(err) || status === 0 || status === 408 || status === 429 || status >= 500;
|
|
2261
|
-
if (!retryable || attempt >= 2) throw err;
|
|
2960
|
+
if (!isRetryableSyncError(err) || attempt >= 2) throw err;
|
|
2262
2961
|
const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
|
|
2263
2962
|
await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
|
|
2264
2963
|
delay *= 2;
|
|
@@ -2270,17 +2969,32 @@ function contentHash(input) {
|
|
|
2270
2969
|
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
2271
2970
|
return (h >>> 0).toString(36);
|
|
2272
2971
|
}
|
|
2972
|
+
function formSchemasEqual(left, right) {
|
|
2973
|
+
const canonicalize = (value) => {
|
|
2974
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
2975
|
+
if (value && typeof value === "object") {
|
|
2976
|
+
const record2 = value;
|
|
2977
|
+
return Object.fromEntries(
|
|
2978
|
+
Object.keys(record2).sort().map((key) => [key, canonicalize(record2[key])])
|
|
2979
|
+
);
|
|
2980
|
+
}
|
|
2981
|
+
return value;
|
|
2982
|
+
};
|
|
2983
|
+
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
2984
|
+
}
|
|
2985
|
+
var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
|
|
2986
|
+
var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
|
|
2273
2987
|
function storageKey(client, handle) {
|
|
2274
|
-
return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
|
|
2988
|
+
return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
|
|
2275
2989
|
}
|
|
2276
2990
|
function readStoredSync(client, handle, hash) {
|
|
2277
2991
|
try {
|
|
2278
2992
|
const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
|
|
2279
2993
|
if (!raw) return null;
|
|
2280
2994
|
const parsed = JSON.parse(raw);
|
|
2281
|
-
if (typeof parsed.formId !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number")
|
|
2995
|
+
if (typeof parsed.formId !== "string" || typeof parsed.slug !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number" || parsed.status !== void 0 && parsed.status !== "draft" && parsed.status !== "published")
|
|
2282
2996
|
return null;
|
|
2283
|
-
const ttl = parsed.status
|
|
2997
|
+
const ttl = syncTtlMs(parsed.status);
|
|
2284
2998
|
if (Date.now() - parsed.ts > ttl) return null;
|
|
2285
2999
|
return parsed;
|
|
2286
3000
|
} catch {
|
|
@@ -2288,6 +3002,7 @@ function readStoredSync(client, handle, hash) {
|
|
|
2288
3002
|
}
|
|
2289
3003
|
}
|
|
2290
3004
|
function writeStoredSync(client, handle, hash, r) {
|
|
3005
|
+
if (r.staged || r.resolvedSchema || r.syncError) return;
|
|
2291
3006
|
try {
|
|
2292
3007
|
globalThis.localStorage?.setItem(
|
|
2293
3008
|
storageKey(client, handle),
|
|
@@ -2306,23 +3021,44 @@ function syncCodeForm(client, form, opts) {
|
|
|
2306
3021
|
const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
|
|
2307
3022
|
const hash = contentHash(content2);
|
|
2308
3023
|
const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
|
|
2309
|
-
|
|
2310
|
-
if (
|
|
3024
|
+
const cached = syncCache.get(key);
|
|
3025
|
+
if (cached && !opts?.bypassCache) {
|
|
3026
|
+
if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
|
|
3027
|
+
syncCache.delete(key);
|
|
3028
|
+
}
|
|
2311
3029
|
if (!opts?.bypassCache) {
|
|
2312
3030
|
const stored = readStoredSync(client, form.id, hash);
|
|
2313
3031
|
if (stored) {
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
3032
|
+
const promise2 = Promise.resolve({
|
|
3033
|
+
formId: stored.formId,
|
|
3034
|
+
slug: stored.slug,
|
|
3035
|
+
status: stored.status
|
|
3036
|
+
});
|
|
3037
|
+
syncCache.set(key, {
|
|
3038
|
+
promise: promise2,
|
|
3039
|
+
expiresAt: stored.ts + syncTtlMs(stored.status)
|
|
3040
|
+
});
|
|
3041
|
+
return promise2;
|
|
2317
3042
|
}
|
|
2318
3043
|
}
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
3044
|
+
const promise = syncWithRetry(client, form);
|
|
3045
|
+
const entry = { promise, expiresAt: null };
|
|
3046
|
+
syncCache.set(key, entry);
|
|
3047
|
+
promise.then(
|
|
3048
|
+
(result) => {
|
|
3049
|
+
if (syncCache.get(key) !== entry) return;
|
|
3050
|
+
if (isVolatileSync(result)) {
|
|
3051
|
+
syncCache.delete(key);
|
|
3052
|
+
return;
|
|
3053
|
+
}
|
|
3054
|
+
entry.expiresAt = Date.now() + syncTtlMs(result.status);
|
|
3055
|
+
writeStoredSync(client, form.id, hash, result);
|
|
3056
|
+
},
|
|
3057
|
+
() => {
|
|
3058
|
+
if (syncCache.get(key) === entry) syncCache.delete(key);
|
|
3059
|
+
}
|
|
3060
|
+
);
|
|
3061
|
+
return promise;
|
|
2326
3062
|
}
|
|
2327
3063
|
|
|
2328
3064
|
// src/jsx.ts
|
|
@@ -2362,8 +3098,8 @@ var JSX_BLOCK_SPECS = [
|
|
|
2362
3098
|
field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
|
|
2363
3099
|
field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
|
|
2364
3100
|
field("Checkbox", "checkbox", ["appearance"]),
|
|
2365
|
-
field("Rating", "rating", ["max"]),
|
|
2366
|
-
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
|
|
3101
|
+
field("Rating", "rating", ["max", "insightsMetric"]),
|
|
3102
|
+
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
|
|
2367
3103
|
field("Ranking", "ranking", ["options"], { optionChildren: true }),
|
|
2368
3104
|
field("Matrix", "matrix", ["rows", "columns"]),
|
|
2369
3105
|
field("Signature", "signature", []),
|
|
@@ -2528,6 +3264,18 @@ function buildOption(el, parent, parentId) {
|
|
|
2528
3264
|
if (props.icon !== void 0) option.icon = props.icon;
|
|
2529
3265
|
return option;
|
|
2530
3266
|
}
|
|
3267
|
+
function assertUniqueOptionIds(blockId, options2, label) {
|
|
3268
|
+
const optionIds = /* @__PURE__ */ new Set();
|
|
3269
|
+
for (const option of options2) {
|
|
3270
|
+
if (optionIds.has(option.id)) {
|
|
3271
|
+
throw new FilloJsxError(
|
|
3272
|
+
"DUPLICATE_ID",
|
|
3273
|
+
`Two ${label} in "${blockId}" share the id "${option.id}" \u2014 option ids are stored answer values and must be unique within the field`
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
optionIds.add(option.id);
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
2531
3279
|
function schemaFromJsx(children, meta) {
|
|
2532
3280
|
const elements = flatten(children);
|
|
2533
3281
|
const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
|
|
@@ -2568,7 +3316,15 @@ function schemaFromJsx(children, meta) {
|
|
|
2568
3316
|
pages.push({ id: "main", blocks });
|
|
2569
3317
|
}
|
|
2570
3318
|
const seen = /* @__PURE__ */ new Set();
|
|
3319
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
2571
3320
|
for (const page of pages) {
|
|
3321
|
+
if (pageIds.has(page.id)) {
|
|
3322
|
+
throw new FilloJsxError(
|
|
3323
|
+
"DUPLICATE_ID",
|
|
3324
|
+
`Two pages share the id "${page.id}" \u2014 page ids must be unique within a form`
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
pageIds.add(page.id);
|
|
2572
3328
|
for (const block of page.blocks) {
|
|
2573
3329
|
if (seen.has(block.id)) {
|
|
2574
3330
|
throw new FilloJsxError(
|
|
@@ -2577,6 +3333,11 @@ function schemaFromJsx(children, meta) {
|
|
|
2577
3333
|
);
|
|
2578
3334
|
}
|
|
2579
3335
|
seen.add(block.id);
|
|
3336
|
+
if ("options" in block) assertUniqueOptionIds(block.id, block.options, "options");
|
|
3337
|
+
if (block.kind === "matrix") {
|
|
3338
|
+
assertUniqueOptionIds(block.id, block.rows, "matrix rows");
|
|
3339
|
+
assertUniqueOptionIds(block.id, block.columns, "matrix columns");
|
|
3340
|
+
}
|
|
2580
3341
|
}
|
|
2581
3342
|
}
|
|
2582
3343
|
return {
|
|
@@ -2651,9 +3412,11 @@ function pipeBlock(block, data, form) {
|
|
|
2651
3412
|
export {
|
|
2652
3413
|
BLOCK_KIND_META,
|
|
2653
3414
|
CONTENT_KINDS,
|
|
3415
|
+
DEFAULT_FIELD_STRINGS,
|
|
2654
3416
|
DEFAULT_STRINGS,
|
|
2655
3417
|
DRAFT_KINDS,
|
|
2656
3418
|
FILLO_DATA_ATTRS,
|
|
3419
|
+
FILLO_MIN_SDK_VERSION,
|
|
2657
3420
|
FILLO_SCHEMA_VERSION,
|
|
2658
3421
|
FILLO_SDK_VERSION,
|
|
2659
3422
|
FILLO_SLOTS,
|
|
@@ -2664,6 +3427,8 @@ export {
|
|
|
2664
3427
|
JSX_BLOCK_COMPONENTS,
|
|
2665
3428
|
JSX_BLOCK_SPECS,
|
|
2666
3429
|
PHONE_COUNTRIES,
|
|
3430
|
+
PHONE_POPOVER_VIEWPORT_GAP,
|
|
3431
|
+
REQUIRED_FIELD_MESSAGE,
|
|
2667
3432
|
Sha1,
|
|
2668
3433
|
allFields,
|
|
2669
3434
|
assembleForm,
|
|
@@ -2680,6 +3445,7 @@ export {
|
|
|
2680
3445
|
defineForm,
|
|
2681
3446
|
digitsOnly,
|
|
2682
3447
|
flagEmoji,
|
|
3448
|
+
formSchemasEqual,
|
|
2683
3449
|
formatAnswer,
|
|
2684
3450
|
formatNational,
|
|
2685
3451
|
isAutoSubmitBlock,
|
|
@@ -2691,13 +3457,16 @@ export {
|
|
|
2691
3457
|
needsExplicitSubmit,
|
|
2692
3458
|
normalizeFormSchema,
|
|
2693
3459
|
normalizeFormTheme,
|
|
3460
|
+
normalizeSettings,
|
|
2694
3461
|
parsePhone,
|
|
2695
3462
|
pipeBlock,
|
|
3463
|
+
positionPhonePopover,
|
|
2696
3464
|
prefillFromParams,
|
|
2697
3465
|
provisionWorkspace,
|
|
2698
3466
|
resolveSlotClass,
|
|
2699
3467
|
resolveStrings,
|
|
2700
3468
|
resolveText,
|
|
3469
|
+
responseScopeValue,
|
|
2701
3470
|
schemaFromJsx,
|
|
2702
3471
|
sha1Base64,
|
|
2703
3472
|
shouldAutoSubmit,
|
|
@@ -2709,5 +3478,6 @@ export {
|
|
|
2709
3478
|
validateResponse,
|
|
2710
3479
|
visibleBlocks,
|
|
2711
3480
|
visibleFields,
|
|
3481
|
+
visiblePageBlocks,
|
|
2712
3482
|
when
|
|
2713
3483
|
};
|