@usefillo/core 0.7.0 → 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 +3 -3
- package/dist/index.d.ts +198 -31
- package/dist/index.js +529 -109
- 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,6 +82,15 @@ 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
|
}
|
|
@@ -81,7 +98,10 @@ function responseScopeValue(settings, data) {
|
|
|
81
98
|
const field2 = settings.responseLimit?.scopeField;
|
|
82
99
|
if (!field2) return null;
|
|
83
100
|
const value = data[field2];
|
|
84
|
-
|
|
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;
|
|
85
105
|
}
|
|
86
106
|
function visibleFields(form, data) {
|
|
87
107
|
const fields = allFields(form);
|
|
@@ -370,6 +390,75 @@ function isPossiblePhone(value) {
|
|
|
370
390
|
if (country?.lengths.length) return country.lengths.includes(national.length);
|
|
371
391
|
return national.length >= 4;
|
|
372
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
|
+
}
|
|
373
462
|
|
|
374
463
|
// src/validation.ts
|
|
375
464
|
var fileValueSchema = z.object({
|
|
@@ -389,7 +478,7 @@ function isEmpty(value) {
|
|
|
389
478
|
}
|
|
390
479
|
function validateField(field2, value) {
|
|
391
480
|
if (isEmpty(value)) {
|
|
392
|
-
return field2.required ?
|
|
481
|
+
return field2.required ? REQUIRED_FIELD_MESSAGE : null;
|
|
393
482
|
}
|
|
394
483
|
switch (field2.kind) {
|
|
395
484
|
case "short_text":
|
|
@@ -402,11 +491,16 @@ function validateField(field2, value) {
|
|
|
402
491
|
return null;
|
|
403
492
|
}
|
|
404
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`;
|
|
405
497
|
const r = z.email().safeParse(value);
|
|
406
498
|
return r.success ? null : "Enter a valid email address";
|
|
407
499
|
}
|
|
408
500
|
case "url": {
|
|
409
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`;
|
|
410
504
|
try {
|
|
411
505
|
const url = new URL(value);
|
|
412
506
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -441,9 +535,13 @@ function validateField(field2, value) {
|
|
|
441
535
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
442
536
|
const arr = value;
|
|
443
537
|
let others = 0;
|
|
538
|
+
const seen = /* @__PURE__ */ new Set();
|
|
444
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);
|
|
445
543
|
if (ids.has(v)) continue;
|
|
446
|
-
if (field2.allowOther !== true ||
|
|
544
|
+
if (field2.allowOther !== true || v.length === 0 || v.length > 500)
|
|
447
545
|
return "Pick valid options";
|
|
448
546
|
if (++others > 1) return "Only one 'Other' answer is allowed";
|
|
449
547
|
}
|
|
@@ -535,6 +633,7 @@ function validateResponse(form, data) {
|
|
|
535
633
|
}
|
|
536
634
|
function normalizeValue(field2, value) {
|
|
537
635
|
if (value === void 0 || value === null) return value;
|
|
636
|
+
if (field2.kind === "custom") return value;
|
|
538
637
|
if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
|
|
539
638
|
const n = Number(value);
|
|
540
639
|
return Number.isNaN(n) ? value : n;
|
|
@@ -600,7 +699,8 @@ var schemaShape = z2.object({
|
|
|
600
699
|
});
|
|
601
700
|
var MAX_SCHEMA_VERSION = 1;
|
|
602
701
|
var FILLO_SCHEMA_VERSION = 1;
|
|
603
|
-
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";
|
|
604
704
|
function str(value, max, fallback = "") {
|
|
605
705
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
606
706
|
}
|
|
@@ -630,6 +730,14 @@ function options(value, max = 200) {
|
|
|
630
730
|
return id && label ? [cleanObject({ id, label, icon })] : [];
|
|
631
731
|
});
|
|
632
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
|
+
}
|
|
633
741
|
function conditions(value) {
|
|
634
742
|
if (!Array.isArray(value)) return void 0;
|
|
635
743
|
const normalized = value.slice(0, 20).flatMap((item) => {
|
|
@@ -769,17 +877,25 @@ function normalizeBlock(input) {
|
|
|
769
877
|
case "signature":
|
|
770
878
|
case "date":
|
|
771
879
|
return cleanObject(baseField(rec, kind));
|
|
772
|
-
case "rating":
|
|
773
|
-
|
|
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
|
+
}
|
|
774
888
|
case "linear_scale": {
|
|
775
889
|
const min = int(rec.min, 0, 9, 1);
|
|
776
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;
|
|
777
892
|
return cleanObject({
|
|
778
893
|
...baseField(rec, kind),
|
|
779
894
|
min,
|
|
780
895
|
max,
|
|
781
896
|
minLabel: optionalStr(rec.minLabel, 100),
|
|
782
|
-
maxLabel: optionalStr(rec.maxLabel, 100)
|
|
897
|
+
maxLabel: optionalStr(rec.maxLabel, 100),
|
|
898
|
+
insightsMetric
|
|
783
899
|
});
|
|
784
900
|
}
|
|
785
901
|
case "ranking": {
|
|
@@ -838,16 +954,41 @@ function normalizeFormSchema(input) {
|
|
|
838
954
|
return block ? [block] : [];
|
|
839
955
|
});
|
|
840
956
|
return cleanObject({
|
|
841
|
-
id: page.id,
|
|
957
|
+
id: str(page.id, 128),
|
|
842
958
|
title: optionalStr(page.title, 500),
|
|
843
959
|
blocks
|
|
844
960
|
});
|
|
845
961
|
});
|
|
962
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
846
963
|
const seen = /* @__PURE__ */ new Set();
|
|
847
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);
|
|
848
968
|
for (const block of page.blocks) {
|
|
849
969
|
if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
|
|
850
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
|
+
}
|
|
851
992
|
}
|
|
852
993
|
}
|
|
853
994
|
if (pages.every((page) => page.blocks.length === 0)) {
|
|
@@ -949,6 +1090,9 @@ function formatAnswer(field2, value) {
|
|
|
949
1090
|
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
950
1091
|
var MASK = 256 - 256 % ALPHABET.length;
|
|
951
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
|
+
}
|
|
952
1096
|
const csprng = globalThis.crypto;
|
|
953
1097
|
if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
|
|
954
1098
|
throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
|
|
@@ -978,12 +1122,12 @@ var BLOCK_KIND_META = {
|
|
|
978
1122
|
dropdown: { label: "Dropdown", hint: "Select from a list" },
|
|
979
1123
|
checkbox: { label: "Yes / No", hint: "Single checkbox" },
|
|
980
1124
|
rating: { label: "Rating", hint: "1\u20135 stars" },
|
|
981
|
-
linear_scale: { label: "Linear scale", hint: "
|
|
1125
|
+
linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
|
|
982
1126
|
ranking: { label: "Ranking", hint: "Order options by preference" },
|
|
983
1127
|
matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
|
|
984
1128
|
signature: { label: "Signature", hint: "Draw to sign" },
|
|
985
1129
|
date: { label: "Date", hint: "Date picker" },
|
|
986
|
-
file_upload: { label: "File upload", hint: "
|
|
1130
|
+
file_upload: { label: "File upload", hint: "Direct to connected storage" },
|
|
987
1131
|
hidden: { label: "Hidden field", hint: "Filled from the URL" },
|
|
988
1132
|
custom: { label: "Custom", hint: "Your own component (code only)" },
|
|
989
1133
|
heading: { label: "Heading", hint: "Section title" },
|
|
@@ -1018,10 +1162,11 @@ function createBlock(kind) {
|
|
|
1018
1162
|
id,
|
|
1019
1163
|
kind,
|
|
1020
1164
|
label: "How likely are you to recommend us?",
|
|
1021
|
-
min:
|
|
1165
|
+
min: 0,
|
|
1022
1166
|
max: 10,
|
|
1023
|
-
minLabel: "Not likely",
|
|
1024
|
-
maxLabel: "
|
|
1167
|
+
minLabel: "Not at all likely",
|
|
1168
|
+
maxLabel: "Extremely likely",
|
|
1169
|
+
insightsMetric: "nps"
|
|
1025
1170
|
};
|
|
1026
1171
|
case "ranking":
|
|
1027
1172
|
return {
|
|
@@ -1137,12 +1282,21 @@ function assembleBlock(spec) {
|
|
|
1137
1282
|
break;
|
|
1138
1283
|
case "rating":
|
|
1139
1284
|
block.max = clampInt(spec.max, 3, 10, 5);
|
|
1285
|
+
if (spec.insightsMetric === "csat" && block.max === 5) {
|
|
1286
|
+
block.insightsMetric = "csat";
|
|
1287
|
+
}
|
|
1140
1288
|
break;
|
|
1141
1289
|
case "linear_scale":
|
|
1142
1290
|
block.min = spec.min === 0 ? 0 : 1;
|
|
1143
1291
|
block.max = clampInt(spec.max, 2, 10, 10);
|
|
1144
1292
|
if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
|
|
1145
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
|
+
}
|
|
1146
1300
|
break;
|
|
1147
1301
|
case "number":
|
|
1148
1302
|
if (typeof spec.min === "number") block.min = spec.min;
|
|
@@ -1280,25 +1434,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
|
|
|
1280
1434
|
|
|
1281
1435
|
// src/client.ts
|
|
1282
1436
|
var FilloError = class extends Error {
|
|
1283
|
-
constructor(message, status, retryAfterSec2) {
|
|
1437
|
+
constructor(message, status, retryAfterSec2, code) {
|
|
1284
1438
|
super(message);
|
|
1285
1439
|
this.status = status;
|
|
1286
1440
|
this.retryAfterSec = retryAfterSec2;
|
|
1441
|
+
this.code = code;
|
|
1287
1442
|
this.name = "FilloError";
|
|
1288
1443
|
}
|
|
1289
1444
|
status;
|
|
1290
1445
|
retryAfterSec;
|
|
1446
|
+
code;
|
|
1291
1447
|
};
|
|
1292
1448
|
function isFilloError(err) {
|
|
1293
1449
|
return err instanceof Error && err.name === "FilloError";
|
|
1294
1450
|
}
|
|
1295
|
-
async function
|
|
1451
|
+
async function errorDetails(res, fallback) {
|
|
1296
1452
|
try {
|
|
1297
1453
|
const body = await res.json();
|
|
1298
|
-
|
|
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
|
+
};
|
|
1299
1458
|
} catch {
|
|
1300
1459
|
}
|
|
1301
|
-
return fallback;
|
|
1460
|
+
return { message: fallback };
|
|
1302
1461
|
}
|
|
1303
1462
|
function retryAfterSec(res) {
|
|
1304
1463
|
const raw = Number(res.headers.get("retry-after"));
|
|
@@ -1306,11 +1465,31 @@ function retryAfterSec(res) {
|
|
|
1306
1465
|
}
|
|
1307
1466
|
var MAX_CHUNK_RETRIES = 3;
|
|
1308
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
|
+
}
|
|
1309
1488
|
function isRetryableStatus(status) {
|
|
1310
1489
|
return status >= 500 || status === 429 || status === 408;
|
|
1311
1490
|
}
|
|
1312
1491
|
function asRequestError(err) {
|
|
1313
|
-
if (err instanceof DOMException && err.name === "TimeoutError")
|
|
1492
|
+
if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
|
|
1314
1493
|
return new FilloError("Request timed out", 0);
|
|
1315
1494
|
return err;
|
|
1316
1495
|
}
|
|
@@ -1381,8 +1560,8 @@ var FilloClient = class {
|
|
|
1381
1560
|
throw asRequestError(err);
|
|
1382
1561
|
}
|
|
1383
1562
|
if (!res.ok) {
|
|
1384
|
-
const message = await
|
|
1385
|
-
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);
|
|
1386
1565
|
}
|
|
1387
1566
|
return await res.json();
|
|
1388
1567
|
}
|
|
@@ -1393,8 +1572,10 @@ var FilloClient = class {
|
|
|
1393
1572
|
return form;
|
|
1394
1573
|
}
|
|
1395
1574
|
/**
|
|
1396
|
-
*
|
|
1397
|
-
* 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.
|
|
1398
1579
|
*/
|
|
1399
1580
|
syncForm(handle, schema, theme) {
|
|
1400
1581
|
if (!this.key) {
|
|
@@ -1434,8 +1615,8 @@ var FilloClient = class {
|
|
|
1434
1615
|
return { ok: false, errors: body2.errors };
|
|
1435
1616
|
}
|
|
1436
1617
|
if (!res.ok) {
|
|
1437
|
-
const message = await
|
|
1438
|
-
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);
|
|
1439
1620
|
}
|
|
1440
1621
|
const body = await res.json();
|
|
1441
1622
|
return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
|
|
@@ -1445,12 +1626,18 @@ var FilloClient = class {
|
|
|
1445
1626
|
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
1446
1627
|
* ({id, hash}); anything else 404s (returned as null), because an
|
|
1447
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.
|
|
1448
1635
|
*/
|
|
1449
|
-
async fetchOwnResponse(formId, respondent) {
|
|
1636
|
+
async fetchOwnResponse(formId, respondent, scopeValue) {
|
|
1450
1637
|
try {
|
|
1451
1638
|
return await this.json(
|
|
1452
1639
|
`/forms/${encodeURIComponent(formId)}/respondent-response`,
|
|
1453
|
-
{ method: "POST", body: JSON.stringify({ respondent }) }
|
|
1640
|
+
{ method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
|
|
1454
1641
|
);
|
|
1455
1642
|
} catch (err) {
|
|
1456
1643
|
if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
|
|
@@ -1530,18 +1717,20 @@ var FilloClient = class {
|
|
|
1530
1717
|
});
|
|
1531
1718
|
}
|
|
1532
1719
|
/** Current state of an upload session — used to resume after interruption. */
|
|
1533
|
-
getUploadSession(sessionId, token) {
|
|
1720
|
+
getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
|
|
1534
1721
|
return this.json(
|
|
1535
1722
|
`/uploads/${encodeURIComponent(sessionId)}`,
|
|
1536
|
-
|
|
1723
|
+
{
|
|
1724
|
+
...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
|
|
1725
|
+
signal
|
|
1726
|
+
}
|
|
1537
1727
|
);
|
|
1538
1728
|
}
|
|
1539
1729
|
/**
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* 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.
|
|
1545
1734
|
*
|
|
1546
1735
|
* Depending on the form's storage settings the server picks a transport:
|
|
1547
1736
|
* direct-to-Google-Drive resumable
|
|
@@ -1549,28 +1738,68 @@ var FilloClient = class {
|
|
|
1549
1738
|
* form owner's Drive and never touch the Fillo server.
|
|
1550
1739
|
*/
|
|
1551
1740
|
async uploadFile(formId, file, options2) {
|
|
1552
|
-
const name = file instanceof File ? file.name : "upload.bin";
|
|
1553
|
-
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();
|
|
1554
1743
|
let session;
|
|
1555
1744
|
if (options2.sessionId) {
|
|
1556
|
-
|
|
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;
|
|
1557
1755
|
} else {
|
|
1558
|
-
|
|
1756
|
+
const requestId = createId(24);
|
|
1757
|
+
const requestToken = createId(32);
|
|
1758
|
+
const createSession = () => this.json(
|
|
1559
1759
|
`/forms/${encodeURIComponent(formId)}/uploads`,
|
|
1560
1760
|
{
|
|
1561
1761
|
method: "POST",
|
|
1562
1762
|
body: JSON.stringify({
|
|
1763
|
+
requestId,
|
|
1764
|
+
uploadToken: requestToken,
|
|
1563
1765
|
fieldId: options2.fieldId,
|
|
1564
1766
|
fileName: name,
|
|
1565
1767
|
size: file.size,
|
|
1566
1768
|
mime
|
|
1567
|
-
})
|
|
1769
|
+
}),
|
|
1770
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1568
1771
|
}
|
|
1569
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
|
+
}
|
|
1570
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);
|
|
1571
1798
|
let completeBody;
|
|
1572
1799
|
if (session.transport?.type === "gdrive") {
|
|
1573
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);
|
|
1574
1803
|
} else if (session.transport?.type === "s3-put") {
|
|
1575
1804
|
await this.s3PutUpload(session.transport.uploadUrl, file, options2);
|
|
1576
1805
|
} else if (session.transport?.type === "box") {
|
|
@@ -1578,14 +1807,52 @@ var FilloClient = class {
|
|
|
1578
1807
|
} else {
|
|
1579
1808
|
throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
|
|
1580
1809
|
}
|
|
1581
|
-
const
|
|
1810
|
+
const finalize = () => this.json(
|
|
1582
1811
|
`/uploads/${encodeURIComponent(session.id)}/complete`,
|
|
1583
1812
|
{
|
|
1584
1813
|
method: "POST",
|
|
1585
1814
|
headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
|
|
1586
|
-
...completeBody ? { body: JSON.stringify(completeBody) } : {}
|
|
1815
|
+
...completeBody ? { body: JSON.stringify(completeBody) } : {},
|
|
1816
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1587
1817
|
}
|
|
1588
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
|
+
}
|
|
1589
1856
|
if (!done.file) throw new FilloError("Upload completed but no file was returned");
|
|
1590
1857
|
return done.file;
|
|
1591
1858
|
}
|
|
@@ -1694,10 +1961,10 @@ var FilloClient = class {
|
|
|
1694
1961
|
/**
|
|
1695
1962
|
* Caller signal (if any) combined with a fresh per-request timeout. Created per
|
|
1696
1963
|
* fetch so each retry attempt gets its own deadline; the caller's own abort
|
|
1697
|
-
* still drives
|
|
1964
|
+
* still drives upload cancellation across every provider transport.
|
|
1698
1965
|
*/
|
|
1699
|
-
uploadSignal(options2) {
|
|
1700
|
-
const timeout = AbortSignal.timeout(
|
|
1966
|
+
uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
1967
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
1701
1968
|
return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
|
|
1702
1969
|
}
|
|
1703
1970
|
/** Retry a request with exponential backoff; never retries an aborted upload. */
|
|
@@ -1746,7 +2013,13 @@ var FilloClient = class {
|
|
|
1746
2013
|
const match = range?.match(/bytes=0-(\d+)/);
|
|
1747
2014
|
if (match) offset = Number(match[1]) + 1;
|
|
1748
2015
|
else if (range == null) offset = 0;
|
|
1749
|
-
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
|
+
}
|
|
1750
2023
|
break;
|
|
1751
2024
|
}
|
|
1752
2025
|
if (res.ok) {
|
|
@@ -1759,7 +2032,11 @@ var FilloClient = class {
|
|
|
1759
2032
|
attempt += 1;
|
|
1760
2033
|
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
1761
2034
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
1762
|
-
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
|
+
);
|
|
1763
2040
|
offset = fresh.uploadedBytes;
|
|
1764
2041
|
if (offset >= file.size) break;
|
|
1765
2042
|
end = Math.min(offset + session.chunkSize, file.size);
|
|
@@ -1776,6 +2053,80 @@ var FilloClient = class {
|
|
|
1776
2053
|
}
|
|
1777
2054
|
}
|
|
1778
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
|
+
}
|
|
1779
2130
|
/**
|
|
1780
2131
|
* S3-compatible single PUT to a presigned URL — bytes go straight to the
|
|
1781
2132
|
* bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
|
|
@@ -1866,7 +2217,7 @@ function prefillFromParams(form, params) {
|
|
|
1866
2217
|
}
|
|
1867
2218
|
case "multi_select": {
|
|
1868
2219
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
1869
|
-
const picked = raw.split(",").filter((v) => ids.has(v));
|
|
2220
|
+
const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
|
|
1870
2221
|
if (picked.length > 0) data[field2.id] = picked;
|
|
1871
2222
|
break;
|
|
1872
2223
|
}
|
|
@@ -2006,6 +2357,14 @@ function submitFailureMessage(err) {
|
|
|
2006
2357
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
2007
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.";
|
|
2008
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
|
+
}
|
|
2009
2368
|
function createFormController(options2) {
|
|
2010
2369
|
const { onChange, onSubmitted } = options2;
|
|
2011
2370
|
const initial = normalizeFormSchema(options2.form);
|
|
@@ -2019,6 +2378,7 @@ function createFormController(options2) {
|
|
|
2019
2378
|
let pageIndex = 0;
|
|
2020
2379
|
let status = "idle";
|
|
2021
2380
|
let restoredSubmission = false;
|
|
2381
|
+
let resumeLinkFailed = false;
|
|
2022
2382
|
queueMicrotask(() => {
|
|
2023
2383
|
if (typeof location !== "undefined" && location.search) {
|
|
2024
2384
|
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
@@ -2054,7 +2414,11 @@ function createFormController(options2) {
|
|
|
2054
2414
|
history.replaceState(null, "", location.pathname + location.search);
|
|
2055
2415
|
} catch {
|
|
2056
2416
|
}
|
|
2057
|
-
}).catch(() => {
|
|
2417
|
+
}).catch((err) => {
|
|
2418
|
+
if (isDraftGone(err)) {
|
|
2419
|
+
resumeLinkFailed = true;
|
|
2420
|
+
notify();
|
|
2421
|
+
}
|
|
2058
2422
|
}).finally(() => maybeRestoreDraft());
|
|
2059
2423
|
}
|
|
2060
2424
|
}
|
|
@@ -2069,6 +2433,7 @@ function createFormController(options2) {
|
|
|
2069
2433
|
let sessionStarted = false;
|
|
2070
2434
|
let resumedDraft = false;
|
|
2071
2435
|
let editingPrevious = false;
|
|
2436
|
+
let submissionKind = "created";
|
|
2072
2437
|
let editAttempted = false;
|
|
2073
2438
|
let draftRef = null;
|
|
2074
2439
|
let draftTimer = null;
|
|
@@ -2088,14 +2453,20 @@ function createFormController(options2) {
|
|
|
2088
2453
|
pageIndex: clamped,
|
|
2089
2454
|
pageCount,
|
|
2090
2455
|
page,
|
|
2091
|
-
|
|
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),
|
|
2092
2460
|
isFirstPage: clamped === 0,
|
|
2093
2461
|
isLastPage: clamped === pageCount - 1,
|
|
2094
2462
|
uploading: uploadingFields.size > 0,
|
|
2095
2463
|
submitError,
|
|
2096
2464
|
restoredSubmission,
|
|
2097
2465
|
resumedDraft,
|
|
2098
|
-
editingPrevious
|
|
2466
|
+
editingPrevious,
|
|
2467
|
+
duplicateSubmission: submissionKind === "duplicate",
|
|
2468
|
+
updatedSubmission: submissionKind === "updated",
|
|
2469
|
+
resumeLinkFailed
|
|
2099
2470
|
};
|
|
2100
2471
|
}
|
|
2101
2472
|
let snapshot = computeSnapshot();
|
|
@@ -2114,7 +2485,7 @@ function createFormController(options2) {
|
|
|
2114
2485
|
const target = form.pages[index];
|
|
2115
2486
|
if (!target) return {};
|
|
2116
2487
|
const pageErrors = {};
|
|
2117
|
-
for (const block of
|
|
2488
|
+
for (const block of visiblePageBlocks(form, target, data)) {
|
|
2118
2489
|
if (!isField(block)) continue;
|
|
2119
2490
|
const error = validateField(block, data[block.id]);
|
|
2120
2491
|
if (error) pageErrors[block.id] = error;
|
|
@@ -2191,7 +2562,8 @@ function createFormController(options2) {
|
|
|
2191
2562
|
if (!client || !formId || !respondent?.hash || status !== "idle") return;
|
|
2192
2563
|
editAttempted = true;
|
|
2193
2564
|
const c = client;
|
|
2194
|
-
|
|
2565
|
+
const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
|
|
2566
|
+
void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
|
|
2195
2567
|
if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
|
|
2196
2568
|
applyDraftSnapshot({ data: own.data, page: 0 });
|
|
2197
2569
|
if (resumedDraft) {
|
|
@@ -2315,13 +2687,13 @@ function createFormController(options2) {
|
|
|
2315
2687
|
if (!result.ok) {
|
|
2316
2688
|
errors = result.errors;
|
|
2317
2689
|
const firstBad = form.pages.findIndex(
|
|
2318
|
-
(p) =>
|
|
2690
|
+
(p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
|
|
2319
2691
|
);
|
|
2320
2692
|
if (firstBad >= 0) pageIndex = firstBad;
|
|
2321
2693
|
notify();
|
|
2322
2694
|
return;
|
|
2323
2695
|
}
|
|
2324
|
-
if (client &&
|
|
2696
|
+
if (client && options2.resolveFormId) {
|
|
2325
2697
|
status = "submitting";
|
|
2326
2698
|
submitError = void 0;
|
|
2327
2699
|
notify();
|
|
@@ -2329,7 +2701,7 @@ function createFormController(options2) {
|
|
|
2329
2701
|
formId = await options2.resolveFormId();
|
|
2330
2702
|
} catch (err) {
|
|
2331
2703
|
status = "idle";
|
|
2332
|
-
submitError =
|
|
2704
|
+
submitError = syncResolutionFailureMessage(err);
|
|
2333
2705
|
notify();
|
|
2334
2706
|
throw err;
|
|
2335
2707
|
}
|
|
@@ -2359,10 +2731,16 @@ function createFormController(options2) {
|
|
|
2359
2731
|
}
|
|
2360
2732
|
if (!res.ok) {
|
|
2361
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.";
|
|
2362
2739
|
status = "idle";
|
|
2363
2740
|
notify();
|
|
2364
2741
|
return;
|
|
2365
2742
|
}
|
|
2743
|
+
submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
|
|
2366
2744
|
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
2367
2745
|
if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
|
|
2368
2746
|
discardDraftState();
|
|
@@ -2449,9 +2827,8 @@ function isAutoSubmitBlock(block) {
|
|
|
2449
2827
|
return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
|
|
2450
2828
|
}
|
|
2451
2829
|
function needsExplicitSubmit(visible) {
|
|
2452
|
-
const interactive = visible.filter((b) =>
|
|
2453
|
-
if (interactive.length
|
|
2454
|
-
if (interactive.length > 1) return true;
|
|
2830
|
+
const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
|
|
2831
|
+
if (interactive.length !== 1) return true;
|
|
2455
2832
|
return !isAutoSubmitBlock(interactive[0]);
|
|
2456
2833
|
}
|
|
2457
2834
|
function shouldAutoSubmit(field2, value, ctx) {
|
|
@@ -2549,35 +2926,6 @@ var FILLO_THEME_VARS = [
|
|
|
2549
2926
|
{ var: "--fillo-primary-contrast" }
|
|
2550
2927
|
];
|
|
2551
2928
|
|
|
2552
|
-
// src/strings.ts
|
|
2553
|
-
var DEFAULT_STRINGS = {
|
|
2554
|
-
back: "Back",
|
|
2555
|
-
next: "Next",
|
|
2556
|
-
submit: "Submit",
|
|
2557
|
-
submitting: "Submitting\u2026",
|
|
2558
|
-
uploading: "Uploading\u2026",
|
|
2559
|
-
optional: " (optional)",
|
|
2560
|
-
other: "Other",
|
|
2561
|
-
otherPrompt: "Other \u2014 please specify",
|
|
2562
|
-
otherPlaceholder: "Your answer",
|
|
2563
|
-
choosePlaceholder: "Choose\u2026",
|
|
2564
|
-
successTitle: "Thanks!",
|
|
2565
|
-
successMessage: "Your response has been recorded.",
|
|
2566
|
-
closed: "This form is no longer accepting responses.",
|
|
2567
|
-
notLive: "This form isn't live yet.",
|
|
2568
|
-
submitFailed: "This form can't submit right now. Please try again in a moment.",
|
|
2569
|
-
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
2570
|
-
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
2571
|
-
loadFailed: "This form could not be loaded.",
|
|
2572
|
-
renderFailed: "This form could not be rendered.",
|
|
2573
|
-
resumeNotice: "Picked up where you left off.",
|
|
2574
|
-
editNotice: "You're updating your earlier response.",
|
|
2575
|
-
resumeStartOver: "Start over"
|
|
2576
|
-
};
|
|
2577
|
-
function resolveStrings(overrides) {
|
|
2578
|
-
return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
|
|
2579
|
-
}
|
|
2580
|
-
|
|
2581
2929
|
// src/define.ts
|
|
2582
2930
|
function defineForm(def) {
|
|
2583
2931
|
return {
|
|
@@ -2598,15 +2946,18 @@ function isCodeForm(form) {
|
|
|
2598
2946
|
}
|
|
2599
2947
|
var syncCache = /* @__PURE__ */ new Map();
|
|
2600
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
|
+
}
|
|
2601
2954
|
async function syncWithRetry(client, form) {
|
|
2602
2955
|
let delay = 800;
|
|
2603
2956
|
for (let attempt = 0; ; attempt++) {
|
|
2604
2957
|
try {
|
|
2605
2958
|
return await client.syncForm(form.id, form.schema, form.theme ?? null);
|
|
2606
2959
|
} catch (err) {
|
|
2607
|
-
|
|
2608
|
-
const retryable = !isFilloError(err) || status === 0 || status === 408 || status === 429 || status >= 500;
|
|
2609
|
-
if (!retryable || attempt >= 2) throw err;
|
|
2960
|
+
if (!isRetryableSyncError(err) || attempt >= 2) throw err;
|
|
2610
2961
|
const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
|
|
2611
2962
|
await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
|
|
2612
2963
|
delay *= 2;
|
|
@@ -2618,17 +2969,32 @@ function contentHash(input) {
|
|
|
2618
2969
|
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
2619
2970
|
return (h >>> 0).toString(36);
|
|
2620
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);
|
|
2621
2987
|
function storageKey(client, handle) {
|
|
2622
|
-
return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
|
|
2988
|
+
return `fillo:sync:v2:${client.baseUrl}|${client.key}|${handle}`;
|
|
2623
2989
|
}
|
|
2624
2990
|
function readStoredSync(client, handle, hash) {
|
|
2625
2991
|
try {
|
|
2626
2992
|
const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
|
|
2627
2993
|
if (!raw) return null;
|
|
2628
2994
|
const parsed = JSON.parse(raw);
|
|
2629
|
-
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")
|
|
2630
2996
|
return null;
|
|
2631
|
-
const ttl = parsed.status
|
|
2997
|
+
const ttl = syncTtlMs(parsed.status);
|
|
2632
2998
|
if (Date.now() - parsed.ts > ttl) return null;
|
|
2633
2999
|
return parsed;
|
|
2634
3000
|
} catch {
|
|
@@ -2636,6 +3002,7 @@ function readStoredSync(client, handle, hash) {
|
|
|
2636
3002
|
}
|
|
2637
3003
|
}
|
|
2638
3004
|
function writeStoredSync(client, handle, hash, r) {
|
|
3005
|
+
if (r.staged || r.resolvedSchema || r.syncError) return;
|
|
2639
3006
|
try {
|
|
2640
3007
|
globalThis.localStorage?.setItem(
|
|
2641
3008
|
storageKey(client, handle),
|
|
@@ -2654,23 +3021,44 @@ function syncCodeForm(client, form, opts) {
|
|
|
2654
3021
|
const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
|
|
2655
3022
|
const hash = contentHash(content2);
|
|
2656
3023
|
const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
|
|
2657
|
-
|
|
2658
|
-
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
|
+
}
|
|
2659
3029
|
if (!opts?.bypassCache) {
|
|
2660
3030
|
const stored = readStoredSync(client, form.id, hash);
|
|
2661
3031
|
if (stored) {
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
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;
|
|
2665
3042
|
}
|
|
2666
3043
|
}
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
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;
|
|
2674
3062
|
}
|
|
2675
3063
|
|
|
2676
3064
|
// src/jsx.ts
|
|
@@ -2710,8 +3098,8 @@ var JSX_BLOCK_SPECS = [
|
|
|
2710
3098
|
field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
|
|
2711
3099
|
field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
|
|
2712
3100
|
field("Checkbox", "checkbox", ["appearance"]),
|
|
2713
|
-
field("Rating", "rating", ["max"]),
|
|
2714
|
-
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
|
|
3101
|
+
field("Rating", "rating", ["max", "insightsMetric"]),
|
|
3102
|
+
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
|
|
2715
3103
|
field("Ranking", "ranking", ["options"], { optionChildren: true }),
|
|
2716
3104
|
field("Matrix", "matrix", ["rows", "columns"]),
|
|
2717
3105
|
field("Signature", "signature", []),
|
|
@@ -2876,6 +3264,18 @@ function buildOption(el, parent, parentId) {
|
|
|
2876
3264
|
if (props.icon !== void 0) option.icon = props.icon;
|
|
2877
3265
|
return option;
|
|
2878
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
|
+
}
|
|
2879
3279
|
function schemaFromJsx(children, meta) {
|
|
2880
3280
|
const elements = flatten(children);
|
|
2881
3281
|
const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
|
|
@@ -2916,7 +3316,15 @@ function schemaFromJsx(children, meta) {
|
|
|
2916
3316
|
pages.push({ id: "main", blocks });
|
|
2917
3317
|
}
|
|
2918
3318
|
const seen = /* @__PURE__ */ new Set();
|
|
3319
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
2919
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);
|
|
2920
3328
|
for (const block of page.blocks) {
|
|
2921
3329
|
if (seen.has(block.id)) {
|
|
2922
3330
|
throw new FilloJsxError(
|
|
@@ -2925,6 +3333,11 @@ function schemaFromJsx(children, meta) {
|
|
|
2925
3333
|
);
|
|
2926
3334
|
}
|
|
2927
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
|
+
}
|
|
2928
3341
|
}
|
|
2929
3342
|
}
|
|
2930
3343
|
return {
|
|
@@ -2999,9 +3412,11 @@ function pipeBlock(block, data, form) {
|
|
|
2999
3412
|
export {
|
|
3000
3413
|
BLOCK_KIND_META,
|
|
3001
3414
|
CONTENT_KINDS,
|
|
3415
|
+
DEFAULT_FIELD_STRINGS,
|
|
3002
3416
|
DEFAULT_STRINGS,
|
|
3003
3417
|
DRAFT_KINDS,
|
|
3004
3418
|
FILLO_DATA_ATTRS,
|
|
3419
|
+
FILLO_MIN_SDK_VERSION,
|
|
3005
3420
|
FILLO_SCHEMA_VERSION,
|
|
3006
3421
|
FILLO_SDK_VERSION,
|
|
3007
3422
|
FILLO_SLOTS,
|
|
@@ -3012,6 +3427,8 @@ export {
|
|
|
3012
3427
|
JSX_BLOCK_COMPONENTS,
|
|
3013
3428
|
JSX_BLOCK_SPECS,
|
|
3014
3429
|
PHONE_COUNTRIES,
|
|
3430
|
+
PHONE_POPOVER_VIEWPORT_GAP,
|
|
3431
|
+
REQUIRED_FIELD_MESSAGE,
|
|
3015
3432
|
Sha1,
|
|
3016
3433
|
allFields,
|
|
3017
3434
|
assembleForm,
|
|
@@ -3028,6 +3445,7 @@ export {
|
|
|
3028
3445
|
defineForm,
|
|
3029
3446
|
digitsOnly,
|
|
3030
3447
|
flagEmoji,
|
|
3448
|
+
formSchemasEqual,
|
|
3031
3449
|
formatAnswer,
|
|
3032
3450
|
formatNational,
|
|
3033
3451
|
isAutoSubmitBlock,
|
|
@@ -3042,6 +3460,7 @@ export {
|
|
|
3042
3460
|
normalizeSettings,
|
|
3043
3461
|
parsePhone,
|
|
3044
3462
|
pipeBlock,
|
|
3463
|
+
positionPhonePopover,
|
|
3045
3464
|
prefillFromParams,
|
|
3046
3465
|
provisionWorkspace,
|
|
3047
3466
|
resolveSlotClass,
|
|
@@ -3059,5 +3478,6 @@ export {
|
|
|
3059
3478
|
validateResponse,
|
|
3060
3479
|
visibleBlocks,
|
|
3061
3480
|
visibleFields,
|
|
3481
|
+
visiblePageBlocks,
|
|
3062
3482
|
when
|
|
3063
3483
|
};
|