@usefillo/core 0.7.0 → 0.9.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 +360 -36
- package/dist/index.js +715 -121
- 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,14 +47,20 @@ 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
|
}
|
|
57
|
+
function conditionsMet(conds, resolve) {
|
|
58
|
+
if (conds.length === 0) return true;
|
|
59
|
+
return conds.every((cond) => evalCondition(cond, resolve));
|
|
60
|
+
}
|
|
49
61
|
function blockVisibleWith(block, resolve) {
|
|
50
62
|
if (!block.visibleIf || block.visibleIf.length === 0) return true;
|
|
51
|
-
return block.visibleIf
|
|
63
|
+
return conditionsMet(block.visibleIf, resolve);
|
|
52
64
|
}
|
|
53
65
|
function makeResolver(data, scoped, visible) {
|
|
54
66
|
return (fieldId) => scoped.has(fieldId) && !visible.has(fieldId) ? void 0 : data[fieldId];
|
|
@@ -74,6 +86,15 @@ function visibleBlocks(page, data) {
|
|
|
74
86
|
const resolve = makeResolver(data, scoped, visibleFieldIds(fields, data));
|
|
75
87
|
return page.blocks.filter((b) => blockVisibleWith(b, resolve));
|
|
76
88
|
}
|
|
89
|
+
function visiblePageBlocks(form, page, data) {
|
|
90
|
+
const fields = allFields(form);
|
|
91
|
+
const scoped = new Set(fields.map((f) => f.id));
|
|
92
|
+
const formVisible = visibleFieldIds(fields, data);
|
|
93
|
+
const resolve = makeResolver(data, scoped, formVisible);
|
|
94
|
+
return page.blocks.filter(
|
|
95
|
+
(b) => isField(b) ? formVisible.has(b.id) : blockVisibleWith(b, resolve)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
77
98
|
function allFields(form) {
|
|
78
99
|
return form.pages.flatMap((p) => p.blocks.filter(isField));
|
|
79
100
|
}
|
|
@@ -81,13 +102,80 @@ function responseScopeValue(settings, data) {
|
|
|
81
102
|
const field2 = settings.responseLimit?.scopeField;
|
|
82
103
|
if (!field2) return null;
|
|
83
104
|
const value = data[field2];
|
|
84
|
-
|
|
105
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : null;
|
|
106
|
+
if (typeof value !== "string") return null;
|
|
107
|
+
const normalized = value.trim();
|
|
108
|
+
return normalized || null;
|
|
85
109
|
}
|
|
86
110
|
function visibleFields(form, data) {
|
|
87
111
|
const fields = allFields(form);
|
|
88
112
|
const visible = visibleFieldIds(fields, data);
|
|
89
113
|
return fields.filter((f) => visible.has(f.id));
|
|
90
114
|
}
|
|
115
|
+
function formResolver(form, data) {
|
|
116
|
+
const fields = allFields(form);
|
|
117
|
+
const scoped = new Set(fields.map((f) => f.id));
|
|
118
|
+
return makeResolver(data, scoped, visibleFieldIds(fields, data));
|
|
119
|
+
}
|
|
120
|
+
function resolveNextPage(form, currentPageId, data) {
|
|
121
|
+
const page = form.pages.find((p) => p.id === currentPageId);
|
|
122
|
+
const rules = page?.next;
|
|
123
|
+
if (!rules || rules.length === 0) return { linear: true };
|
|
124
|
+
const resolve = formResolver(form, data);
|
|
125
|
+
for (const rule of rules) {
|
|
126
|
+
if (conditionsMet(rule.when, resolve)) {
|
|
127
|
+
return rule.to === "end" ? { end: true } : { to: rule.to };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { linear: true };
|
|
131
|
+
}
|
|
132
|
+
function reachablePageSequence(form, data) {
|
|
133
|
+
const seq = [];
|
|
134
|
+
if (form.pages.length === 0) return seq;
|
|
135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
136
|
+
let index = 0;
|
|
137
|
+
for (let steps = 0; steps <= form.pages.length; steps++) {
|
|
138
|
+
const page = form.pages[index];
|
|
139
|
+
if (!page || seen.has(page.id)) break;
|
|
140
|
+
seen.add(page.id);
|
|
141
|
+
seq.push(page.id);
|
|
142
|
+
const nav = resolveNextPage(form, page.id, data);
|
|
143
|
+
if ("end" in nav) break;
|
|
144
|
+
if ("to" in nav) {
|
|
145
|
+
const target = form.pages.findIndex((p) => p.id === nav.to);
|
|
146
|
+
if (target < 0) break;
|
|
147
|
+
index = target;
|
|
148
|
+
} else {
|
|
149
|
+
index += 1;
|
|
150
|
+
if (index >= form.pages.length) break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return seq;
|
|
154
|
+
}
|
|
155
|
+
function reachablePageIds(form, data) {
|
|
156
|
+
return new Set(reachablePageSequence(form, data));
|
|
157
|
+
}
|
|
158
|
+
function isTerminalPage(form, pageId, data) {
|
|
159
|
+
if ("end" in resolveNextPage(form, pageId, data)) return true;
|
|
160
|
+
const seq = reachablePageSequence(form, data);
|
|
161
|
+
return seq.length > 0 && seq[seq.length - 1] === pageId;
|
|
162
|
+
}
|
|
163
|
+
function reachableFieldIds(form, data) {
|
|
164
|
+
const reachablePages = reachablePageIds(form, data);
|
|
165
|
+
const visible = visibleFieldIds(allFields(form), data);
|
|
166
|
+
const ids = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const page of form.pages) {
|
|
168
|
+
if (!reachablePages.has(page.id)) continue;
|
|
169
|
+
for (const block of page.blocks) {
|
|
170
|
+
if (isField(block) && visible.has(block.id)) ids.add(block.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return ids;
|
|
174
|
+
}
|
|
175
|
+
function reachableFields(form, data) {
|
|
176
|
+
const ids = reachableFieldIds(form, data);
|
|
177
|
+
return allFields(form).filter((f) => ids.has(f.id));
|
|
178
|
+
}
|
|
91
179
|
|
|
92
180
|
// src/validation.ts
|
|
93
181
|
import * as z from "zod/mini";
|
|
@@ -370,6 +458,76 @@ function isPossiblePhone(value) {
|
|
|
370
458
|
if (country?.lengths.length) return country.lengths.includes(national.length);
|
|
371
459
|
return national.length >= 4;
|
|
372
460
|
}
|
|
461
|
+
var PHONE_POPOVER_VIEWPORT_GAP = 8;
|
|
462
|
+
function positionPhonePopover(anchor, popover) {
|
|
463
|
+
if (!anchor || !popover || typeof window === "undefined") return "below";
|
|
464
|
+
const gap = PHONE_POPOVER_VIEWPORT_GAP;
|
|
465
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
466
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
467
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
468
|
+
const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
|
|
469
|
+
const spaceAbove = Math.max(0, anchorRect.top - gap);
|
|
470
|
+
const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
|
|
471
|
+
const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
|
|
472
|
+
const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
|
|
473
|
+
const maxWidth = Math.max(0, viewportWidth - gap * 2);
|
|
474
|
+
popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
|
|
475
|
+
popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
|
|
476
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
|
|
477
|
+
const width = Math.min(popover.offsetWidth, maxWidth);
|
|
478
|
+
let offsetX = 0;
|
|
479
|
+
const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
|
|
480
|
+
if (rightOverflow > 0) offsetX -= rightOverflow;
|
|
481
|
+
const leftOverflow = gap - (anchorRect.left + offsetX);
|
|
482
|
+
if (leftOverflow > 0) offsetX += leftOverflow;
|
|
483
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
|
|
484
|
+
return placement;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/strings.ts
|
|
488
|
+
var REQUIRED_FIELD_MESSAGE = "This field is required";
|
|
489
|
+
var DEFAULT_STRINGS = {
|
|
490
|
+
back: "Back",
|
|
491
|
+
next: "Next",
|
|
492
|
+
submit: "Submit",
|
|
493
|
+
submitting: "Submitting\u2026",
|
|
494
|
+
uploading: "Uploading\u2026",
|
|
495
|
+
optional: " (optional)",
|
|
496
|
+
other: "Other",
|
|
497
|
+
otherPrompt: "Other \u2014 please specify",
|
|
498
|
+
otherPlaceholder: "Your answer",
|
|
499
|
+
choosePlaceholder: "Choose\u2026",
|
|
500
|
+
successTitle: "Thanks!",
|
|
501
|
+
successMessage: "Your response has been recorded.",
|
|
502
|
+
closed: "This form is no longer accepting responses.",
|
|
503
|
+
notLive: "This form isn't live yet.",
|
|
504
|
+
submitFailed: "This form can't submit right now. Please try again in a moment.",
|
|
505
|
+
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
506
|
+
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
507
|
+
loadFailed: "This form could not be loaded.",
|
|
508
|
+
renderFailed: "This form could not be rendered.",
|
|
509
|
+
resumeNotice: "Picked up where you left off.",
|
|
510
|
+
editNotice: "You're updating your earlier response.",
|
|
511
|
+
resumeStartOver: "Start over",
|
|
512
|
+
challengeUnavailable: "The verification check couldn't load. Refresh the page, or check that challenges.cloudflare.com isn't blocked."
|
|
513
|
+
};
|
|
514
|
+
var DEFAULT_FIELD_STRINGS = {
|
|
515
|
+
required: REQUIRED_FIELD_MESSAGE,
|
|
516
|
+
resumeLinkExpired: "That saved-progress link expired or was already used \u2014 start again below.",
|
|
517
|
+
alreadyAnswered: "You've already answered this form.",
|
|
518
|
+
uploadRetry: "Retry",
|
|
519
|
+
uploadsDisabled: "Uploads are disabled in preview",
|
|
520
|
+
uploadFailed: "Upload failed \u2014 try again",
|
|
521
|
+
dropzoneTitle: (multiple) => `Drop ${multiple ? "files" : "a file"} here or click to browse`,
|
|
522
|
+
dropzoneHint: (maxMb) => `Up to ${maxMb} MB per file`,
|
|
523
|
+
fileTooLarge: (maxMb) => `Larger than ${maxMb} MB limit`,
|
|
524
|
+
filesUploading: (count) => `Uploading ${count} ${count === 1 ? "file" : "files"}, please wait\u2026`,
|
|
525
|
+
uploadsFailed: (count) => `${count} ${count === 1 ? "upload" : "uploads"} failed.`,
|
|
526
|
+
filesUploaded: (count) => `${count} ${count === 1 ? "file" : "files"} uploaded.`
|
|
527
|
+
};
|
|
528
|
+
function resolveStrings(overrides) {
|
|
529
|
+
return overrides ? { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS, ...overrides } : { ...DEFAULT_STRINGS, ...DEFAULT_FIELD_STRINGS };
|
|
530
|
+
}
|
|
373
531
|
|
|
374
532
|
// src/validation.ts
|
|
375
533
|
var fileValueSchema = z.object({
|
|
@@ -389,7 +547,7 @@ function isEmpty(value) {
|
|
|
389
547
|
}
|
|
390
548
|
function validateField(field2, value) {
|
|
391
549
|
if (isEmpty(value)) {
|
|
392
|
-
return field2.required ?
|
|
550
|
+
return field2.required ? REQUIRED_FIELD_MESSAGE : null;
|
|
393
551
|
}
|
|
394
552
|
switch (field2.kind) {
|
|
395
553
|
case "short_text":
|
|
@@ -402,11 +560,16 @@ function validateField(field2, value) {
|
|
|
402
560
|
return null;
|
|
403
561
|
}
|
|
404
562
|
case "email": {
|
|
563
|
+
if (typeof value !== "string") return "Enter a valid email address";
|
|
564
|
+
if (field2.maxLength && value.length > field2.maxLength)
|
|
565
|
+
return `Must be at most ${field2.maxLength} characters`;
|
|
405
566
|
const r = z.email().safeParse(value);
|
|
406
567
|
return r.success ? null : "Enter a valid email address";
|
|
407
568
|
}
|
|
408
569
|
case "url": {
|
|
409
570
|
if (typeof value !== "string") return "Enter a valid URL (including https://)";
|
|
571
|
+
if (field2.maxLength && value.length > field2.maxLength)
|
|
572
|
+
return `Must be at most ${field2.maxLength} characters`;
|
|
410
573
|
try {
|
|
411
574
|
const url = new URL(value);
|
|
412
575
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
@@ -441,9 +604,13 @@ function validateField(field2, value) {
|
|
|
441
604
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
442
605
|
const arr = value;
|
|
443
606
|
let others = 0;
|
|
607
|
+
const seen = /* @__PURE__ */ new Set();
|
|
444
608
|
for (const v of arr) {
|
|
609
|
+
if (typeof v !== "string") return "Pick valid options";
|
|
610
|
+
if (seen.has(v)) return "Pick each option only once";
|
|
611
|
+
seen.add(v);
|
|
445
612
|
if (ids.has(v)) continue;
|
|
446
|
-
if (field2.allowOther !== true ||
|
|
613
|
+
if (field2.allowOther !== true || v.length === 0 || v.length > 500)
|
|
447
614
|
return "Pick valid options";
|
|
448
615
|
if (++others > 1) return "Only one 'Other' answer is allowed";
|
|
449
616
|
}
|
|
@@ -519,7 +686,7 @@ function validateField(field2, value) {
|
|
|
519
686
|
}
|
|
520
687
|
}
|
|
521
688
|
function validateResponse(form, data) {
|
|
522
|
-
const fields =
|
|
689
|
+
const fields = reachableFields(form, data);
|
|
523
690
|
const errors = {};
|
|
524
691
|
const cleaned = {};
|
|
525
692
|
for (const field2 of fields) {
|
|
@@ -535,6 +702,7 @@ function validateResponse(form, data) {
|
|
|
535
702
|
}
|
|
536
703
|
function normalizeValue(field2, value) {
|
|
537
704
|
if (value === void 0 || value === null) return value;
|
|
705
|
+
if (field2.kind === "custom") return value;
|
|
538
706
|
if (field2.kind === "number" && typeof value === "string" && value.trim() !== "") {
|
|
539
707
|
const n = Number(value);
|
|
540
708
|
return Number.isNaN(n) ? value : n;
|
|
@@ -589,7 +757,11 @@ var blockSchema = z2.looseObject({
|
|
|
589
757
|
var pageSchema = z2.object({
|
|
590
758
|
id: idSchema,
|
|
591
759
|
title: z2.optional(z2.string().check(z2.maxLength(500))),
|
|
592
|
-
blocks: z2.array(blockSchema).check(z2.maxLength(500))
|
|
760
|
+
blocks: z2.array(blockSchema).check(z2.maxLength(500)),
|
|
761
|
+
// Optional conditional page flow. Kept as unknown here (this schema is strict,
|
|
762
|
+
// so it would otherwise be dropped at parse) and normalized by hand below once
|
|
763
|
+
// the page-id set is known — a jump to a missing page is dropped, never kept.
|
|
764
|
+
next: z2.optional(z2.unknown())
|
|
593
765
|
});
|
|
594
766
|
var schemaShape = z2.object({
|
|
595
767
|
version: z2.literal(1),
|
|
@@ -600,7 +772,9 @@ var schemaShape = z2.object({
|
|
|
600
772
|
});
|
|
601
773
|
var MAX_SCHEMA_VERSION = 1;
|
|
602
774
|
var FILLO_SCHEMA_VERSION = 1;
|
|
603
|
-
var FILLO_SDK_VERSION = true ? "0.
|
|
775
|
+
var FILLO_SDK_VERSION = true ? "0.9.0" : "0.0.0-dev";
|
|
776
|
+
var FILLO_MIN_SDK_VERSION = "0.4.0";
|
|
777
|
+
var FILLO_CHALLENGE_MIN_SDK_VERSION = "0.9.0";
|
|
604
778
|
function str(value, max, fallback = "") {
|
|
605
779
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
606
780
|
}
|
|
@@ -630,6 +804,14 @@ function options(value, max = 200) {
|
|
|
630
804
|
return id && label ? [cleanObject({ id, label, icon })] : [];
|
|
631
805
|
});
|
|
632
806
|
}
|
|
807
|
+
function duplicateOptionId(items) {
|
|
808
|
+
const ids = /* @__PURE__ */ new Set();
|
|
809
|
+
for (const { id } of items) {
|
|
810
|
+
if (ids.has(id)) return id;
|
|
811
|
+
ids.add(id);
|
|
812
|
+
}
|
|
813
|
+
return void 0;
|
|
814
|
+
}
|
|
633
815
|
function conditions(value) {
|
|
634
816
|
if (!Array.isArray(value)) return void 0;
|
|
635
817
|
const normalized = value.slice(0, 20).flatMap((item) => {
|
|
@@ -647,6 +829,23 @@ function conditions(value) {
|
|
|
647
829
|
});
|
|
648
830
|
return normalized.length ? normalized : void 0;
|
|
649
831
|
}
|
|
832
|
+
function jumpRules(value, pageIds, sourceId, allowedFieldIds) {
|
|
833
|
+
if (!Array.isArray(value)) return void 0;
|
|
834
|
+
const rules = value.slice(0, 50).flatMap((item) => {
|
|
835
|
+
if (!item || typeof item !== "object") return [];
|
|
836
|
+
const rec = item;
|
|
837
|
+
const to = typeof rec.to === "string" ? rec.to : void 0;
|
|
838
|
+
if (!to || to !== "end" && !pageIds.has(to)) return [];
|
|
839
|
+
if (to === sourceId) return [];
|
|
840
|
+
const rawWhen = rec.when;
|
|
841
|
+
if (rawWhen !== void 0 && !Array.isArray(rawWhen)) return [];
|
|
842
|
+
const when2 = conditions(rawWhen);
|
|
843
|
+
if (Array.isArray(rawWhen) && rawWhen.length > 0 && when2 === void 0) return [];
|
|
844
|
+
if (when2 && when2.some((c) => !allowedFieldIds.has(c.fieldId))) return [];
|
|
845
|
+
return [{ when: when2 ?? [], to }];
|
|
846
|
+
});
|
|
847
|
+
return rules.length ? rules : void 0;
|
|
848
|
+
}
|
|
650
849
|
function baseBlock(rec) {
|
|
651
850
|
return {
|
|
652
851
|
id: str(rec.id, 128),
|
|
@@ -690,6 +889,17 @@ function normalizeResponseLimit(value) {
|
|
|
690
889
|
onRepeat
|
|
691
890
|
};
|
|
692
891
|
}
|
|
892
|
+
function normalizeTrust(value) {
|
|
893
|
+
if (!value || typeof value !== "object") return void 0;
|
|
894
|
+
const rec = value;
|
|
895
|
+
const unverified = rec.unverified === "allow" || rec.unverified === "quarantine" ? rec.unverified : void 0;
|
|
896
|
+
const challenge = rec.challenge === "turnstile" ? "turnstile" : void 0;
|
|
897
|
+
if (!unverified && !challenge) return void 0;
|
|
898
|
+
return {
|
|
899
|
+
...unverified ? { unverified } : {},
|
|
900
|
+
...challenge ? { challenge } : {}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
693
903
|
function normalizeSettings(value) {
|
|
694
904
|
const rec = value && typeof value === "object" ? value : {};
|
|
695
905
|
const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
|
|
@@ -701,6 +911,7 @@ function normalizeSettings(value) {
|
|
|
701
911
|
redirectUrl: normalizeUrl(rec.redirectUrl),
|
|
702
912
|
showProgress: bool(rec.showProgress),
|
|
703
913
|
responseLimit: normalizeResponseLimit(rec.responseLimit),
|
|
914
|
+
trust: normalizeTrust(rec.trust),
|
|
704
915
|
notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
|
|
705
916
|
sendReceipt: bool(rec.sendReceipt),
|
|
706
917
|
saveProgress: bool(rec.saveProgress),
|
|
@@ -769,17 +980,25 @@ function normalizeBlock(input) {
|
|
|
769
980
|
case "signature":
|
|
770
981
|
case "date":
|
|
771
982
|
return cleanObject(baseField(rec, kind));
|
|
772
|
-
case "rating":
|
|
773
|
-
|
|
983
|
+
case "rating": {
|
|
984
|
+
const max = int(rec.max, 1, 10, 5);
|
|
985
|
+
return cleanObject({
|
|
986
|
+
...baseField(rec, kind),
|
|
987
|
+
max,
|
|
988
|
+
insightsMetric: rec.insightsMetric === "csat" && max === 5 ? "csat" : void 0
|
|
989
|
+
});
|
|
990
|
+
}
|
|
774
991
|
case "linear_scale": {
|
|
775
992
|
const min = int(rec.min, 0, 9, 1);
|
|
776
993
|
const max = int(rec.max, min + 1, 10, 10);
|
|
994
|
+
const insightsMetric = rec.insightsMetric === "nps" && min === 0 && max === 10 ? "nps" : rec.insightsMetric === "csat" && min === 1 && max === 5 ? "csat" : void 0;
|
|
777
995
|
return cleanObject({
|
|
778
996
|
...baseField(rec, kind),
|
|
779
997
|
min,
|
|
780
998
|
max,
|
|
781
999
|
minLabel: optionalStr(rec.minLabel, 100),
|
|
782
|
-
maxLabel: optionalStr(rec.maxLabel, 100)
|
|
1000
|
+
maxLabel: optionalStr(rec.maxLabel, 100),
|
|
1001
|
+
insightsMetric
|
|
783
1002
|
});
|
|
784
1003
|
}
|
|
785
1004
|
case "ranking": {
|
|
@@ -832,22 +1051,56 @@ function normalizeFormSchema(input) {
|
|
|
832
1051
|
if (parsed.data.version > MAX_SCHEMA_VERSION) {
|
|
833
1052
|
return { ok: false, error: `Unsupported schema version: ${parsed.data.version}` };
|
|
834
1053
|
}
|
|
835
|
-
const
|
|
1054
|
+
const rebuilt = parsed.data.pages.map((page) => {
|
|
836
1055
|
const blocks = page.blocks.flatMap((raw) => {
|
|
837
1056
|
const block = normalizeBlock(raw);
|
|
838
1057
|
return block ? [block] : [];
|
|
839
1058
|
});
|
|
1059
|
+
return { id: str(page.id, 128), title: optionalStr(page.title, 500), blocks, next: page.next };
|
|
1060
|
+
});
|
|
1061
|
+
const targetIds = new Set(rebuilt.map((p) => p.id));
|
|
1062
|
+
const priorFieldIds = /* @__PURE__ */ new Set();
|
|
1063
|
+
const pages = rebuilt.map((page) => {
|
|
1064
|
+
for (const block of page.blocks) {
|
|
1065
|
+
if (isField(block)) priorFieldIds.add(block.id);
|
|
1066
|
+
}
|
|
840
1067
|
return cleanObject({
|
|
841
1068
|
id: page.id,
|
|
842
|
-
title:
|
|
843
|
-
blocks
|
|
1069
|
+
title: page.title,
|
|
1070
|
+
blocks: page.blocks,
|
|
1071
|
+
next: jumpRules(page.next, targetIds, page.id, new Set(priorFieldIds))
|
|
844
1072
|
});
|
|
845
1073
|
});
|
|
1074
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
846
1075
|
const seen = /* @__PURE__ */ new Set();
|
|
847
1076
|
for (const page of pages) {
|
|
1077
|
+
if (!page.id) return { ok: false, error: "Page ids cannot be blank" };
|
|
1078
|
+
if (pageIds.has(page.id)) return { ok: false, error: `Duplicate page id: ${page.id}` };
|
|
1079
|
+
pageIds.add(page.id);
|
|
848
1080
|
for (const block of page.blocks) {
|
|
849
1081
|
if (seen.has(block.id)) return { ok: false, error: `Duplicate block id: ${block.id}` };
|
|
850
1082
|
seen.add(block.id);
|
|
1083
|
+
if (block.kind === "number" && block.min !== void 0 && block.max !== void 0 && block.min > block.max) {
|
|
1084
|
+
return { ok: false, error: `Field ${block.id} has min greater than max` };
|
|
1085
|
+
}
|
|
1086
|
+
if (block.kind === "select" || block.kind === "multi_select" || block.kind === "dropdown" || block.kind === "ranking") {
|
|
1087
|
+
const duplicate = duplicateOptionId(block.options);
|
|
1088
|
+
if (duplicate) {
|
|
1089
|
+
return { ok: false, error: `Duplicate option id in field ${block.id}: ${duplicate}` };
|
|
1090
|
+
}
|
|
1091
|
+
} else if (block.kind === "matrix") {
|
|
1092
|
+
const duplicateRow = duplicateOptionId(block.rows);
|
|
1093
|
+
if (duplicateRow) {
|
|
1094
|
+
return { ok: false, error: `Duplicate matrix row id in field ${block.id}: ${duplicateRow}` };
|
|
1095
|
+
}
|
|
1096
|
+
const duplicateColumn = duplicateOptionId(block.columns);
|
|
1097
|
+
if (duplicateColumn) {
|
|
1098
|
+
return {
|
|
1099
|
+
ok: false,
|
|
1100
|
+
error: `Duplicate matrix column id in field ${block.id}: ${duplicateColumn}`
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
851
1104
|
}
|
|
852
1105
|
}
|
|
853
1106
|
if (pages.every((page) => page.blocks.length === 0)) {
|
|
@@ -949,6 +1202,9 @@ function formatAnswer(field2, value) {
|
|
|
949
1202
|
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
950
1203
|
var MASK = 256 - 256 % ALPHABET.length;
|
|
951
1204
|
function createId(size = 12) {
|
|
1205
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1206
|
+
throw new RangeError("createId size must be a positive safe integer");
|
|
1207
|
+
}
|
|
952
1208
|
const csprng = globalThis.crypto;
|
|
953
1209
|
if (typeof csprng === "undefined" || typeof csprng.getRandomValues !== "function") {
|
|
954
1210
|
throw new Error("createId requires Web Crypto (globalThis.crypto.getRandomValues)");
|
|
@@ -978,12 +1234,12 @@ var BLOCK_KIND_META = {
|
|
|
978
1234
|
dropdown: { label: "Dropdown", hint: "Select from a list" },
|
|
979
1235
|
checkbox: { label: "Yes / No", hint: "Single checkbox" },
|
|
980
1236
|
rating: { label: "Rating", hint: "1\u20135 stars" },
|
|
981
|
-
linear_scale: { label: "Linear scale", hint: "
|
|
1237
|
+
linear_scale: { label: "Linear scale", hint: "0\u201310 with end labels" },
|
|
982
1238
|
ranking: { label: "Ranking", hint: "Order options by preference" },
|
|
983
1239
|
matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
|
|
984
1240
|
signature: { label: "Signature", hint: "Draw to sign" },
|
|
985
1241
|
date: { label: "Date", hint: "Date picker" },
|
|
986
|
-
file_upload: { label: "File upload", hint: "
|
|
1242
|
+
file_upload: { label: "File upload", hint: "Direct to connected storage" },
|
|
987
1243
|
hidden: { label: "Hidden field", hint: "Filled from the URL" },
|
|
988
1244
|
custom: { label: "Custom", hint: "Your own component (code only)" },
|
|
989
1245
|
heading: { label: "Heading", hint: "Section title" },
|
|
@@ -1018,10 +1274,11 @@ function createBlock(kind) {
|
|
|
1018
1274
|
id,
|
|
1019
1275
|
kind,
|
|
1020
1276
|
label: "How likely are you to recommend us?",
|
|
1021
|
-
min:
|
|
1277
|
+
min: 0,
|
|
1022
1278
|
max: 10,
|
|
1023
|
-
minLabel: "Not likely",
|
|
1024
|
-
maxLabel: "
|
|
1279
|
+
minLabel: "Not at all likely",
|
|
1280
|
+
maxLabel: "Extremely likely",
|
|
1281
|
+
insightsMetric: "nps"
|
|
1025
1282
|
};
|
|
1026
1283
|
case "ranking":
|
|
1027
1284
|
return {
|
|
@@ -1137,12 +1394,21 @@ function assembleBlock(spec) {
|
|
|
1137
1394
|
break;
|
|
1138
1395
|
case "rating":
|
|
1139
1396
|
block.max = clampInt(spec.max, 3, 10, 5);
|
|
1397
|
+
if (spec.insightsMetric === "csat" && block.max === 5) {
|
|
1398
|
+
block.insightsMetric = "csat";
|
|
1399
|
+
}
|
|
1140
1400
|
break;
|
|
1141
1401
|
case "linear_scale":
|
|
1142
1402
|
block.min = spec.min === 0 ? 0 : 1;
|
|
1143
1403
|
block.max = clampInt(spec.max, 2, 10, 10);
|
|
1144
1404
|
if (spec.minLabel) block.minLabel = str2(spec.minLabel, 100);
|
|
1145
1405
|
if (spec.maxLabel) block.maxLabel = str2(spec.maxLabel, 100);
|
|
1406
|
+
delete block.insightsMetric;
|
|
1407
|
+
if (spec.insightsMetric === "nps" && block.min === 0 && block.max === 10) {
|
|
1408
|
+
block.insightsMetric = "nps";
|
|
1409
|
+
} else if (spec.insightsMetric === "csat" && block.min === 1 && block.max === 5) {
|
|
1410
|
+
block.insightsMetric = "csat";
|
|
1411
|
+
}
|
|
1146
1412
|
break;
|
|
1147
1413
|
case "number":
|
|
1148
1414
|
if (typeof spec.min === "number") block.min = spec.min;
|
|
@@ -1280,25 +1546,30 @@ var sha1Base64 = (bytes) => bytesToBase64(new Sha1().update(bytes).digest());
|
|
|
1280
1546
|
|
|
1281
1547
|
// src/client.ts
|
|
1282
1548
|
var FilloError = class extends Error {
|
|
1283
|
-
constructor(message, status, retryAfterSec2) {
|
|
1549
|
+
constructor(message, status, retryAfterSec2, code) {
|
|
1284
1550
|
super(message);
|
|
1285
1551
|
this.status = status;
|
|
1286
1552
|
this.retryAfterSec = retryAfterSec2;
|
|
1553
|
+
this.code = code;
|
|
1287
1554
|
this.name = "FilloError";
|
|
1288
1555
|
}
|
|
1289
1556
|
status;
|
|
1290
1557
|
retryAfterSec;
|
|
1558
|
+
code;
|
|
1291
1559
|
};
|
|
1292
1560
|
function isFilloError(err) {
|
|
1293
1561
|
return err instanceof Error && err.name === "FilloError";
|
|
1294
1562
|
}
|
|
1295
|
-
async function
|
|
1563
|
+
async function errorDetails(res, fallback) {
|
|
1296
1564
|
try {
|
|
1297
1565
|
const body = await res.json();
|
|
1298
|
-
|
|
1566
|
+
return {
|
|
1567
|
+
message: typeof body.error === "string" && body.error ? body.error : fallback,
|
|
1568
|
+
code: typeof body.code === "string" && body.code ? body.code : void 0
|
|
1569
|
+
};
|
|
1299
1570
|
} catch {
|
|
1300
1571
|
}
|
|
1301
|
-
return fallback;
|
|
1572
|
+
return { message: fallback };
|
|
1302
1573
|
}
|
|
1303
1574
|
function retryAfterSec(res) {
|
|
1304
1575
|
const raw = Number(res.headers.get("retry-after"));
|
|
@@ -1306,11 +1577,31 @@ function retryAfterSec(res) {
|
|
|
1306
1577
|
}
|
|
1307
1578
|
var MAX_CHUNK_RETRIES = 3;
|
|
1308
1579
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
1580
|
+
var S3_PART_TIMEOUT_MS = 5 * 6e4;
|
|
1581
|
+
var UPLOAD_CONTROL_TIMEOUT_MS = 10 * 6e4;
|
|
1582
|
+
function waitForRetry(ms, signal) {
|
|
1583
|
+
signal?.throwIfAborted();
|
|
1584
|
+
return new Promise((resolve, reject) => {
|
|
1585
|
+
const aborted = () => {
|
|
1586
|
+
clearTimeout(timer);
|
|
1587
|
+
reject(signal?.reason);
|
|
1588
|
+
};
|
|
1589
|
+
const timer = setTimeout(() => {
|
|
1590
|
+
signal?.removeEventListener("abort", aborted);
|
|
1591
|
+
resolve();
|
|
1592
|
+
}, ms);
|
|
1593
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
function isControlRetryable(error, includeBusy = false) {
|
|
1597
|
+
const status = isFilloError(error) ? error.status ?? 0 : 0;
|
|
1598
|
+
return status === 0 || status === 503 || status === 504 || includeBusy && status === 409 && isFilloError(error) && error.retryAfterSec !== void 0;
|
|
1599
|
+
}
|
|
1309
1600
|
function isRetryableStatus(status) {
|
|
1310
1601
|
return status >= 500 || status === 429 || status === 408;
|
|
1311
1602
|
}
|
|
1312
1603
|
function asRequestError(err) {
|
|
1313
|
-
if (err instanceof DOMException && err.name === "TimeoutError")
|
|
1604
|
+
if (typeof DOMException !== "undefined" && err instanceof DOMException && err.name === "TimeoutError")
|
|
1314
1605
|
return new FilloError("Request timed out", 0);
|
|
1315
1606
|
return err;
|
|
1316
1607
|
}
|
|
@@ -1381,8 +1672,8 @@ var FilloClient = class {
|
|
|
1381
1672
|
throw asRequestError(err);
|
|
1382
1673
|
}
|
|
1383
1674
|
if (!res.ok) {
|
|
1384
|
-
const message = await
|
|
1385
|
-
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1675
|
+
const { message, code } = await errorDetails(res, `Request failed: ${res.status}`);
|
|
1676
|
+
throw new FilloError(message, res.status, retryAfterSec(res), code);
|
|
1386
1677
|
}
|
|
1387
1678
|
return await res.json();
|
|
1388
1679
|
}
|
|
@@ -1393,8 +1684,10 @@ var FilloClient = class {
|
|
|
1393
1684
|
return form;
|
|
1394
1685
|
}
|
|
1395
1686
|
/**
|
|
1396
|
-
*
|
|
1397
|
-
* publishable key.
|
|
1687
|
+
* Resolve a code-defined form through the workspace identified by the
|
|
1688
|
+
* client's publishable key. Depending on workspace policy, changed content
|
|
1689
|
+
* may be staged for review or resolved to the authoritative live snapshot.
|
|
1690
|
+
* Returns the canonical form id used for submissions.
|
|
1398
1691
|
*/
|
|
1399
1692
|
syncForm(handle, schema, theme) {
|
|
1400
1693
|
if (!this.key) {
|
|
@@ -1434,8 +1727,8 @@ var FilloClient = class {
|
|
|
1434
1727
|
return { ok: false, errors: body2.errors };
|
|
1435
1728
|
}
|
|
1436
1729
|
if (!res.ok) {
|
|
1437
|
-
const message = await
|
|
1438
|
-
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1730
|
+
const { message, code } = await errorDetails(res, `Submit failed: ${res.status}`);
|
|
1731
|
+
throw new FilloError(message, res.status, retryAfterSec(res), code);
|
|
1439
1732
|
}
|
|
1440
1733
|
const body = await res.json();
|
|
1441
1734
|
return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
|
|
@@ -1445,12 +1738,18 @@ var FilloClient = class {
|
|
|
1445
1738
|
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
1446
1739
|
* ({id, hash}); anything else 404s (returned as null), because an
|
|
1447
1740
|
* unverified read would hand anyone's answers to any page script.
|
|
1741
|
+
*
|
|
1742
|
+
* `scopeValue` scopes the lookup for forms whose response limit is keyed to
|
|
1743
|
+
* a field (responseLimit.scopeField) — e.g. one response per article. When
|
|
1744
|
+
* the form is scoped, the server returns 404 unless the matching scope value
|
|
1745
|
+
* is sent, so a page loaded for article B never prefills article A's answers.
|
|
1746
|
+
* Optional and back-compatible: omit it for unscoped forms.
|
|
1448
1747
|
*/
|
|
1449
|
-
async fetchOwnResponse(formId, respondent) {
|
|
1748
|
+
async fetchOwnResponse(formId, respondent, scopeValue) {
|
|
1450
1749
|
try {
|
|
1451
1750
|
return await this.json(
|
|
1452
1751
|
`/forms/${encodeURIComponent(formId)}/respondent-response`,
|
|
1453
|
-
{ method: "POST", body: JSON.stringify({ respondent }) }
|
|
1752
|
+
{ method: "POST", body: JSON.stringify({ respondent, scopeValue }) }
|
|
1454
1753
|
);
|
|
1455
1754
|
} catch (err) {
|
|
1456
1755
|
if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
|
|
@@ -1530,18 +1829,20 @@ var FilloClient = class {
|
|
|
1530
1829
|
});
|
|
1531
1830
|
}
|
|
1532
1831
|
/** Current state of an upload session — used to resume after interruption. */
|
|
1533
|
-
getUploadSession(sessionId, token) {
|
|
1832
|
+
getUploadSession(sessionId, token, signal = AbortSignal.timeout(UPLOAD_CONTROL_TIMEOUT_MS)) {
|
|
1534
1833
|
return this.json(
|
|
1535
1834
|
`/uploads/${encodeURIComponent(sessionId)}`,
|
|
1536
|
-
|
|
1835
|
+
{
|
|
1836
|
+
...token ? { headers: { "X-Fillo-Upload-Token": token } } : {},
|
|
1837
|
+
signal
|
|
1838
|
+
}
|
|
1537
1839
|
);
|
|
1538
1840
|
}
|
|
1539
1841
|
/**
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
* is sent twice or skipped.
|
|
1842
|
+
* Provider-aware browser-direct upload. Creates a session, uses the storage
|
|
1843
|
+
* transport selected by the server, reports progress, and finalizes into a
|
|
1844
|
+
* FileValue. Resumable providers can continue an existing session; one-shot
|
|
1845
|
+
* transports are retried according to their own safe semantics.
|
|
1545
1846
|
*
|
|
1546
1847
|
* Depending on the form's storage settings the server picks a transport:
|
|
1547
1848
|
* direct-to-Google-Drive resumable
|
|
@@ -1549,28 +1850,68 @@ var FilloClient = class {
|
|
|
1549
1850
|
* form owner's Drive and never touch the Fillo server.
|
|
1550
1851
|
*/
|
|
1551
1852
|
async uploadFile(formId, file, options2) {
|
|
1552
|
-
const name = file instanceof File ? file.name : "upload.bin";
|
|
1553
|
-
const mime = file.type || "application/octet-stream";
|
|
1853
|
+
const name = typeof File !== "undefined" && file instanceof File ? file.name : "upload.bin";
|
|
1854
|
+
const mime = (file.type || "application/octet-stream").trim().toLowerCase();
|
|
1554
1855
|
let session;
|
|
1555
1856
|
if (options2.sessionId) {
|
|
1556
|
-
|
|
1857
|
+
const resumed = await this.getUploadSession(
|
|
1858
|
+
options2.sessionId,
|
|
1859
|
+
options2.uploadToken,
|
|
1860
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1861
|
+
);
|
|
1862
|
+
if (resumed.formId !== formId || resumed.fieldId !== options2.fieldId || resumed.fileName !== name || resumed.size !== file.size || resumed.mime.trim().toLowerCase() !== mime) {
|
|
1863
|
+
throw new FilloError("File does not match this upload session", 400);
|
|
1864
|
+
}
|
|
1865
|
+
session = resumed;
|
|
1866
|
+
session.token ||= options2.uploadToken;
|
|
1557
1867
|
} else {
|
|
1558
|
-
|
|
1868
|
+
const requestId = createId(24);
|
|
1869
|
+
const requestToken = createId(32);
|
|
1870
|
+
const createSession = () => this.json(
|
|
1559
1871
|
`/forms/${encodeURIComponent(formId)}/uploads`,
|
|
1560
1872
|
{
|
|
1561
1873
|
method: "POST",
|
|
1562
1874
|
body: JSON.stringify({
|
|
1875
|
+
requestId,
|
|
1876
|
+
uploadToken: requestToken,
|
|
1563
1877
|
fieldId: options2.fieldId,
|
|
1564
1878
|
fileName: name,
|
|
1565
1879
|
size: file.size,
|
|
1566
1880
|
mime
|
|
1567
|
-
})
|
|
1881
|
+
}),
|
|
1882
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1568
1883
|
}
|
|
1569
1884
|
);
|
|
1885
|
+
const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
|
|
1886
|
+
let backoffMs = 250;
|
|
1887
|
+
for (; ; ) {
|
|
1888
|
+
try {
|
|
1889
|
+
session = await createSession();
|
|
1890
|
+
break;
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
if (options2.signal?.aborted) throw error;
|
|
1893
|
+
if (!isControlRetryable(error, true)) throw error;
|
|
1894
|
+
const remaining = deadline - Date.now();
|
|
1895
|
+
if (remaining <= 0) throw error;
|
|
1896
|
+
await waitForRetry(
|
|
1897
|
+
Math.min(backoffMs, remaining),
|
|
1898
|
+
options2.signal
|
|
1899
|
+
);
|
|
1900
|
+
backoffMs = Math.min(backoffMs * 2, 5e3);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1570
1903
|
}
|
|
1904
|
+
options2.onSession?.({
|
|
1905
|
+
sessionId: session.id,
|
|
1906
|
+
uploadToken: session.token
|
|
1907
|
+
});
|
|
1908
|
+
if (session.status === "complete" && session.file) return session.file;
|
|
1909
|
+
if (session.status === "aborted") throw new FilloError("This upload session was cancelled", 409);
|
|
1571
1910
|
let completeBody;
|
|
1572
1911
|
if (session.transport?.type === "gdrive") {
|
|
1573
1912
|
await this.driveUploadLoop(session, session.transport.uploadUrl, file, options2);
|
|
1913
|
+
} else if (session.transport?.type === "s3-multipart") {
|
|
1914
|
+
await this.s3MultipartUploadLoop(session, file, options2);
|
|
1574
1915
|
} else if (session.transport?.type === "s3-put") {
|
|
1575
1916
|
await this.s3PutUpload(session.transport.uploadUrl, file, options2);
|
|
1576
1917
|
} else if (session.transport?.type === "box") {
|
|
@@ -1578,14 +1919,52 @@ var FilloClient = class {
|
|
|
1578
1919
|
} else {
|
|
1579
1920
|
throw new FilloError("This form's storage doesn't support uploads \u2014 connect Drive, S3, or Box.");
|
|
1580
1921
|
}
|
|
1581
|
-
const
|
|
1922
|
+
const finalize = () => this.json(
|
|
1582
1923
|
`/uploads/${encodeURIComponent(session.id)}/complete`,
|
|
1583
1924
|
{
|
|
1584
1925
|
method: "POST",
|
|
1585
1926
|
headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
|
|
1586
|
-
...completeBody ? { body: JSON.stringify(completeBody) } : {}
|
|
1927
|
+
...completeBody ? { body: JSON.stringify(completeBody) } : {},
|
|
1928
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
1587
1929
|
}
|
|
1588
1930
|
);
|
|
1931
|
+
let done;
|
|
1932
|
+
try {
|
|
1933
|
+
done = await finalize();
|
|
1934
|
+
} catch (error) {
|
|
1935
|
+
if (options2.signal?.aborted) throw error;
|
|
1936
|
+
if (!isControlRetryable(error)) throw error;
|
|
1937
|
+
const deadline = Date.now() + UPLOAD_CONTROL_TIMEOUT_MS;
|
|
1938
|
+
let backoffMs = 250;
|
|
1939
|
+
for (; ; ) {
|
|
1940
|
+
const reconciled = await this.getUploadSession(
|
|
1941
|
+
session.id,
|
|
1942
|
+
session.token,
|
|
1943
|
+
this.uploadSignal(
|
|
1944
|
+
options2,
|
|
1945
|
+
Math.max(1, deadline - Date.now())
|
|
1946
|
+
)
|
|
1947
|
+
);
|
|
1948
|
+
if (reconciled.status === "complete" && reconciled.file) {
|
|
1949
|
+
return reconciled.file;
|
|
1950
|
+
}
|
|
1951
|
+
if (reconciled.status === "aborted") throw error;
|
|
1952
|
+
try {
|
|
1953
|
+
done = await finalize();
|
|
1954
|
+
break;
|
|
1955
|
+
} catch (retryError) {
|
|
1956
|
+
if (options2.signal?.aborted) throw retryError;
|
|
1957
|
+
if (!isControlRetryable(retryError, true)) throw retryError;
|
|
1958
|
+
}
|
|
1959
|
+
const remaining = deadline - Date.now();
|
|
1960
|
+
if (remaining <= 0) throw error;
|
|
1961
|
+
await waitForRetry(
|
|
1962
|
+
Math.min(backoffMs, remaining),
|
|
1963
|
+
options2.signal
|
|
1964
|
+
);
|
|
1965
|
+
backoffMs = Math.min(backoffMs * 2, 5e3);
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1589
1968
|
if (!done.file) throw new FilloError("Upload completed but no file was returned");
|
|
1590
1969
|
return done.file;
|
|
1591
1970
|
}
|
|
@@ -1694,10 +2073,10 @@ var FilloClient = class {
|
|
|
1694
2073
|
/**
|
|
1695
2074
|
* Caller signal (if any) combined with a fresh per-request timeout. Created per
|
|
1696
2075
|
* fetch so each retry attempt gets its own deadline; the caller's own abort
|
|
1697
|
-
* still drives
|
|
2076
|
+
* still drives upload cancellation across every provider transport.
|
|
1698
2077
|
*/
|
|
1699
|
-
uploadSignal(options2) {
|
|
1700
|
-
const timeout = AbortSignal.timeout(
|
|
2078
|
+
uploadSignal(options2, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
2079
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
1701
2080
|
return options2.signal ? AbortSignal.any([options2.signal, timeout]) : timeout;
|
|
1702
2081
|
}
|
|
1703
2082
|
/** Retry a request with exponential backoff; never retries an aborted upload. */
|
|
@@ -1746,7 +2125,13 @@ var FilloClient = class {
|
|
|
1746
2125
|
const match = range?.match(/bytes=0-(\d+)/);
|
|
1747
2126
|
if (match) offset = Number(match[1]) + 1;
|
|
1748
2127
|
else if (range == null) offset = 0;
|
|
1749
|
-
else
|
|
2128
|
+
else {
|
|
2129
|
+
offset = (await this.getUploadSession(
|
|
2130
|
+
session.id,
|
|
2131
|
+
session.token,
|
|
2132
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2133
|
+
)).uploadedBytes;
|
|
2134
|
+
}
|
|
1750
2135
|
break;
|
|
1751
2136
|
}
|
|
1752
2137
|
if (res.ok) {
|
|
@@ -1759,7 +2144,11 @@ var FilloClient = class {
|
|
|
1759
2144
|
attempt += 1;
|
|
1760
2145
|
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
1761
2146
|
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
1762
|
-
const fresh = await this.getUploadSession(
|
|
2147
|
+
const fresh = await this.getUploadSession(
|
|
2148
|
+
session.id,
|
|
2149
|
+
session.token,
|
|
2150
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2151
|
+
);
|
|
1763
2152
|
offset = fresh.uploadedBytes;
|
|
1764
2153
|
if (offset >= file.size) break;
|
|
1765
2154
|
end = Math.min(offset + session.chunkSize, file.size);
|
|
@@ -1776,6 +2165,80 @@ var FilloClient = class {
|
|
|
1776
2165
|
}
|
|
1777
2166
|
}
|
|
1778
2167
|
}
|
|
2168
|
+
/**
|
|
2169
|
+
* S3-compatible multipart protocol. Fillo owns the provider upload id and is
|
|
2170
|
+
* the only party allowed to complete it; the browser receives only narrowly
|
|
2171
|
+
* scoped UploadPart URLs. That keeps uploads resumable and means an old or
|
|
2172
|
+
* slow browser request cannot materialize an object after server-side abort.
|
|
2173
|
+
*/
|
|
2174
|
+
async s3MultipartUploadLoop(session, file, options2) {
|
|
2175
|
+
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) {
|
|
2176
|
+
throw new FilloError("S3 upload session returned invalid progress", 502);
|
|
2177
|
+
}
|
|
2178
|
+
const report = makeReporter(file.size, options2);
|
|
2179
|
+
let offset = session.uploadedBytes;
|
|
2180
|
+
report(offset);
|
|
2181
|
+
while (offset < file.size) {
|
|
2182
|
+
options2.signal?.throwIfAborted();
|
|
2183
|
+
const partNumber = Math.floor(offset / session.chunkSize) + 1;
|
|
2184
|
+
if (partNumber > 1e4) {
|
|
2185
|
+
throw new FilloError("S3 upload needs too many parts", 413);
|
|
2186
|
+
}
|
|
2187
|
+
const end = Math.min(offset + session.chunkSize, file.size);
|
|
2188
|
+
const chunk = file.slice(offset, end);
|
|
2189
|
+
let attempt = 0;
|
|
2190
|
+
for (; ; ) {
|
|
2191
|
+
let uploadUrl;
|
|
2192
|
+
try {
|
|
2193
|
+
const signed = await this.json(
|
|
2194
|
+
`/uploads/${encodeURIComponent(session.id)}/parts`,
|
|
2195
|
+
{
|
|
2196
|
+
method: "POST",
|
|
2197
|
+
headers: session.token ? { "X-Fillo-Upload-Token": session.token } : void 0,
|
|
2198
|
+
body: JSON.stringify({ partNumber }),
|
|
2199
|
+
signal: this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2200
|
+
}
|
|
2201
|
+
);
|
|
2202
|
+
if (typeof signed.uploadUrl !== "string" || !signed.uploadUrl) {
|
|
2203
|
+
throw new FilloError("S3 part URL was unavailable", 502);
|
|
2204
|
+
}
|
|
2205
|
+
uploadUrl = signed.uploadUrl;
|
|
2206
|
+
const res = await this.fetch(uploadUrl, {
|
|
2207
|
+
method: "PUT",
|
|
2208
|
+
body: chunk,
|
|
2209
|
+
signal: this.uploadSignal(options2, S3_PART_TIMEOUT_MS)
|
|
2210
|
+
});
|
|
2211
|
+
if (!res.ok) {
|
|
2212
|
+
throw new FilloError(`S3 part upload failed: ${res.status}`, res.status);
|
|
2213
|
+
}
|
|
2214
|
+
offset = end;
|
|
2215
|
+
report(offset);
|
|
2216
|
+
break;
|
|
2217
|
+
} catch (err) {
|
|
2218
|
+
if (options2.signal?.aborted) throw err;
|
|
2219
|
+
const fresh = await this.getUploadSession(
|
|
2220
|
+
session.id,
|
|
2221
|
+
session.token,
|
|
2222
|
+
this.uploadSignal(options2, UPLOAD_CONTROL_TIMEOUT_MS)
|
|
2223
|
+
);
|
|
2224
|
+
if (fresh.status === "aborted") {
|
|
2225
|
+
throw new FilloError("This upload session was cancelled", 409);
|
|
2226
|
+
}
|
|
2227
|
+
if (!Number.isSafeInteger(fresh.uploadedBytes) || fresh.uploadedBytes < offset || fresh.uploadedBytes > file.size || fresh.uploadedBytes < file.size && fresh.uploadedBytes % session.chunkSize !== 0) {
|
|
2228
|
+
throw new FilloError("S3 upload session returned invalid progress", 502);
|
|
2229
|
+
}
|
|
2230
|
+
if (fresh.uploadedBytes > offset) {
|
|
2231
|
+
offset = fresh.uploadedBytes;
|
|
2232
|
+
report(offset);
|
|
2233
|
+
break;
|
|
2234
|
+
}
|
|
2235
|
+
attempt += 1;
|
|
2236
|
+
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
2237
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt));
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
1779
2242
|
/**
|
|
1780
2243
|
* S3-compatible single PUT to a presigned URL — bytes go straight to the
|
|
1781
2244
|
* bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
|
|
@@ -1866,7 +2329,7 @@ function prefillFromParams(form, params) {
|
|
|
1866
2329
|
}
|
|
1867
2330
|
case "multi_select": {
|
|
1868
2331
|
const ids = new Set(field2.options.map((o) => o.id));
|
|
1869
|
-
const picked = raw.split(",").filter((v) => ids.has(v));
|
|
2332
|
+
const picked = [...new Set(raw.split(",").filter((v) => ids.has(v)))];
|
|
1870
2333
|
if (picked.length > 0) data[field2.id] = picked;
|
|
1871
2334
|
break;
|
|
1872
2335
|
}
|
|
@@ -2002,10 +2465,20 @@ function isDraftGone(err) {
|
|
|
2002
2465
|
return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
|
|
2003
2466
|
}
|
|
2004
2467
|
var DRAFT_DEBOUNCE_MS = 1500;
|
|
2468
|
+
var CHALLENGE_INCOMPLETE_MESSAGE = "Please complete the verification check, then submit.";
|
|
2469
|
+
var CHALLENGE_RETRY_MESSAGE = "That verification didn't go through. Please complete the check again and resubmit.";
|
|
2005
2470
|
function submitFailureMessage(err) {
|
|
2006
2471
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
2007
2472
|
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
2473
|
}
|
|
2474
|
+
function syncResolutionFailureMessage(err) {
|
|
2475
|
+
if (isFilloError(err)) {
|
|
2476
|
+
const status = err.status ?? 0;
|
|
2477
|
+
const definitive = status > 0 && status < 500 && status !== 408 && status !== 429;
|
|
2478
|
+
if (definitive) return "This form is unavailable.";
|
|
2479
|
+
}
|
|
2480
|
+
return submitFailureMessage(err);
|
|
2481
|
+
}
|
|
2009
2482
|
function createFormController(options2) {
|
|
2010
2483
|
const { onChange, onSubmitted } = options2;
|
|
2011
2484
|
const initial = normalizeFormSchema(options2.form);
|
|
@@ -2019,6 +2492,7 @@ function createFormController(options2) {
|
|
|
2019
2492
|
let pageIndex = 0;
|
|
2020
2493
|
let status = "idle";
|
|
2021
2494
|
let restoredSubmission = false;
|
|
2495
|
+
let resumeLinkFailed = false;
|
|
2022
2496
|
queueMicrotask(() => {
|
|
2023
2497
|
if (typeof location !== "undefined" && location.search) {
|
|
2024
2498
|
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
@@ -2054,7 +2528,11 @@ function createFormController(options2) {
|
|
|
2054
2528
|
history.replaceState(null, "", location.pathname + location.search);
|
|
2055
2529
|
} catch {
|
|
2056
2530
|
}
|
|
2057
|
-
}).catch(() => {
|
|
2531
|
+
}).catch((err) => {
|
|
2532
|
+
if (isDraftGone(err)) {
|
|
2533
|
+
resumeLinkFailed = true;
|
|
2534
|
+
notify();
|
|
2535
|
+
}
|
|
2058
2536
|
}).finally(() => maybeRestoreDraft());
|
|
2059
2537
|
}
|
|
2060
2538
|
}
|
|
@@ -2069,6 +2547,7 @@ function createFormController(options2) {
|
|
|
2069
2547
|
let sessionStarted = false;
|
|
2070
2548
|
let resumedDraft = false;
|
|
2071
2549
|
let editingPrevious = false;
|
|
2550
|
+
let submissionKind = "created";
|
|
2072
2551
|
let editAttempted = false;
|
|
2073
2552
|
let draftRef = null;
|
|
2074
2553
|
let draftTimer = null;
|
|
@@ -2088,14 +2567,23 @@ function createFormController(options2) {
|
|
|
2088
2567
|
pageIndex: clamped,
|
|
2089
2568
|
pageCount,
|
|
2090
2569
|
page,
|
|
2091
|
-
|
|
2570
|
+
// Whole-form visibility fixpoint (not just this page's fields) so a field
|
|
2571
|
+
// gated by a cross-page answer renders/validates exactly when the server
|
|
2572
|
+
// keeps it — never a field whose answer submit would silently drop.
|
|
2573
|
+
blocks: visiblePageBlocks(form, page, data),
|
|
2092
2574
|
isFirstPage: clamped === 0,
|
|
2093
|
-
|
|
2575
|
+
// Terminal-aware: true when the current page is the last reachable page OR
|
|
2576
|
+
// a matched jump rule ends the form here — so the footer reads Submit and
|
|
2577
|
+
// its handler submits. Same shared engine the validator reaches with.
|
|
2578
|
+
isLastPage: isTerminalPage(form, page.id, data),
|
|
2094
2579
|
uploading: uploadingFields.size > 0,
|
|
2095
2580
|
submitError,
|
|
2096
2581
|
restoredSubmission,
|
|
2097
2582
|
resumedDraft,
|
|
2098
|
-
editingPrevious
|
|
2583
|
+
editingPrevious,
|
|
2584
|
+
duplicateSubmission: submissionKind === "duplicate",
|
|
2585
|
+
updatedSubmission: submissionKind === "updated",
|
|
2586
|
+
resumeLinkFailed
|
|
2099
2587
|
};
|
|
2100
2588
|
}
|
|
2101
2589
|
let snapshot = computeSnapshot();
|
|
@@ -2114,7 +2602,7 @@ function createFormController(options2) {
|
|
|
2114
2602
|
const target = form.pages[index];
|
|
2115
2603
|
if (!target) return {};
|
|
2116
2604
|
const pageErrors = {};
|
|
2117
|
-
for (const block of
|
|
2605
|
+
for (const block of visiblePageBlocks(form, target, data)) {
|
|
2118
2606
|
if (!isField(block)) continue;
|
|
2119
2607
|
const error = validateField(block, data[block.id]);
|
|
2120
2608
|
if (error) pageErrors[block.id] = error;
|
|
@@ -2191,7 +2679,8 @@ function createFormController(options2) {
|
|
|
2191
2679
|
if (!client || !formId || !respondent?.hash || status !== "idle") return;
|
|
2192
2680
|
editAttempted = true;
|
|
2193
2681
|
const c = client;
|
|
2194
|
-
|
|
2682
|
+
const scopeValue = responseScopeValue(form.settings, data) ?? void 0;
|
|
2683
|
+
void c.fetchOwnResponse(formId, respondent, scopeValue).then((own) => {
|
|
2195
2684
|
if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
|
|
2196
2685
|
applyDraftSnapshot({ data: own.data, page: 0 });
|
|
2197
2686
|
if (resumedDraft) {
|
|
@@ -2287,6 +2776,20 @@ function createFormController(options2) {
|
|
|
2287
2776
|
else uploadingFields.delete(fieldId);
|
|
2288
2777
|
notify();
|
|
2289
2778
|
}
|
|
2779
|
+
function currentSeqPosition(seq) {
|
|
2780
|
+
if (seq.length === 0) return -1;
|
|
2781
|
+
const pageCount = form.pages.length;
|
|
2782
|
+
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
2783
|
+
const currentId = form.pages[clamped]?.id;
|
|
2784
|
+
const exact = currentId === void 0 ? -1 : seq.indexOf(currentId);
|
|
2785
|
+
if (exact >= 0) return exact;
|
|
2786
|
+
let fallback = 0;
|
|
2787
|
+
for (let i = 0; i < seq.length; i++) {
|
|
2788
|
+
const idx = form.pages.findIndex((p) => p.id === seq[i]);
|
|
2789
|
+
if (idx >= 0 && idx <= clamped) fallback = i;
|
|
2790
|
+
}
|
|
2791
|
+
return fallback;
|
|
2792
|
+
}
|
|
2290
2793
|
function next() {
|
|
2291
2794
|
const pageCount = form.pages.length;
|
|
2292
2795
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
@@ -2298,14 +2801,31 @@ function createFormController(options2) {
|
|
|
2298
2801
|
return;
|
|
2299
2802
|
}
|
|
2300
2803
|
}
|
|
2301
|
-
|
|
2804
|
+
const current = form.pages[clamped];
|
|
2805
|
+
const seq = reachablePageSequence(form, data);
|
|
2806
|
+
const pos = currentSeqPosition(seq);
|
|
2807
|
+
const terminal = (current ? isTerminalPage(form, current.id, data) : true) || pos < 0 || pos + 1 >= seq.length;
|
|
2808
|
+
if (terminal) {
|
|
2809
|
+
void submit();
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
const nextIndex = form.pages.findIndex((p) => p.id === seq[pos + 1]);
|
|
2813
|
+
pageIndex = nextIndex >= 0 ? nextIndex : Math.min(clamped + 1, pageCount - 1);
|
|
2302
2814
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
2303
2815
|
notify();
|
|
2304
2816
|
checkpointDraft();
|
|
2305
2817
|
}
|
|
2306
2818
|
function back() {
|
|
2307
2819
|
errors = {};
|
|
2308
|
-
|
|
2820
|
+
const seq = reachablePageSequence(form, data);
|
|
2821
|
+
const pos = currentSeqPosition(seq);
|
|
2822
|
+
if (pos > 0) {
|
|
2823
|
+
const prevIndex = form.pages.findIndex((p) => p.id === seq[pos - 1]);
|
|
2824
|
+
pageIndex = prevIndex >= 0 ? prevIndex : Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
2825
|
+
} else if (seq.length > 0) {
|
|
2826
|
+
const firstIndex = form.pages.findIndex((p) => p.id === seq[0]);
|
|
2827
|
+
pageIndex = firstIndex >= 0 ? firstIndex : 0;
|
|
2828
|
+
}
|
|
2309
2829
|
notify();
|
|
2310
2830
|
checkpointDraft();
|
|
2311
2831
|
}
|
|
@@ -2315,13 +2835,13 @@ function createFormController(options2) {
|
|
|
2315
2835
|
if (!result.ok) {
|
|
2316
2836
|
errors = result.errors;
|
|
2317
2837
|
const firstBad = form.pages.findIndex(
|
|
2318
|
-
(p) =>
|
|
2838
|
+
(p) => visiblePageBlocks(form, p, data).some((b) => b.id in result.errors)
|
|
2319
2839
|
);
|
|
2320
2840
|
if (firstBad >= 0) pageIndex = firstBad;
|
|
2321
2841
|
notify();
|
|
2322
2842
|
return;
|
|
2323
2843
|
}
|
|
2324
|
-
if (client &&
|
|
2844
|
+
if (client && options2.resolveFormId) {
|
|
2325
2845
|
status = "submitting";
|
|
2326
2846
|
submitError = void 0;
|
|
2327
2847
|
notify();
|
|
@@ -2329,7 +2849,7 @@ function createFormController(options2) {
|
|
|
2329
2849
|
formId = await options2.resolveFormId();
|
|
2330
2850
|
} catch (err) {
|
|
2331
2851
|
status = "idle";
|
|
2332
|
-
submitError =
|
|
2852
|
+
submitError = syncResolutionFailureMessage(err);
|
|
2333
2853
|
notify();
|
|
2334
2854
|
throw err;
|
|
2335
2855
|
}
|
|
@@ -2338,6 +2858,13 @@ function createFormController(options2) {
|
|
|
2338
2858
|
const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
|
|
2339
2859
|
const submissionKey = visitorKey ?? idempotencyKey;
|
|
2340
2860
|
const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
|
|
2861
|
+
const challengeToken = options2.challengeRequired ? options2.getChallengeToken?.() : void 0;
|
|
2862
|
+
if (options2.challengeRequired && !challengeToken) {
|
|
2863
|
+
status = "idle";
|
|
2864
|
+
submitError = CHALLENGE_INCOMPLETE_MESSAGE;
|
|
2865
|
+
notify();
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2341
2868
|
status = "submitting";
|
|
2342
2869
|
submitError = void 0;
|
|
2343
2870
|
notify();
|
|
@@ -2349,20 +2876,33 @@ function createFormController(options2) {
|
|
|
2349
2876
|
surface: options2.surface ?? "headless",
|
|
2350
2877
|
submissionKey,
|
|
2351
2878
|
draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
|
|
2352
|
-
respondent
|
|
2879
|
+
respondent,
|
|
2880
|
+
challengeToken
|
|
2353
2881
|
});
|
|
2354
2882
|
} catch (err) {
|
|
2355
2883
|
status = "idle";
|
|
2884
|
+
if (isFilloError(err) && err.code === "challenge_failed") {
|
|
2885
|
+
submitError = CHALLENGE_RETRY_MESSAGE;
|
|
2886
|
+
options2.onChallengeFailed?.();
|
|
2887
|
+
notify();
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2356
2890
|
submitError = submitFailureMessage(err);
|
|
2357
2891
|
notify();
|
|
2358
2892
|
throw err;
|
|
2359
2893
|
}
|
|
2360
2894
|
if (!res.ok) {
|
|
2361
2895
|
errors = res.errors ?? {};
|
|
2896
|
+
const firstBad = form.pages.findIndex(
|
|
2897
|
+
(p) => visiblePageBlocks(form, p, data).some((b) => b.id in errors)
|
|
2898
|
+
);
|
|
2899
|
+
if (firstBad >= 0) pageIndex = firstBad;
|
|
2900
|
+
else submitError = "We couldn't submit \u2014 please review your answers.";
|
|
2362
2901
|
status = "idle";
|
|
2363
2902
|
notify();
|
|
2364
2903
|
return;
|
|
2365
2904
|
}
|
|
2905
|
+
submissionKind = res.duplicate ? "duplicate" : res.updated ? "updated" : "created";
|
|
2366
2906
|
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
2367
2907
|
if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
|
|
2368
2908
|
discardDraftState();
|
|
@@ -2449,14 +2989,13 @@ function isAutoSubmitBlock(block) {
|
|
|
2449
2989
|
return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
|
|
2450
2990
|
}
|
|
2451
2991
|
function needsExplicitSubmit(visible) {
|
|
2452
|
-
const interactive = visible.filter((b) =>
|
|
2453
|
-
if (interactive.length
|
|
2454
|
-
if (interactive.length > 1) return true;
|
|
2992
|
+
const interactive = visible.filter((b) => isField(b) && b.kind !== "hidden");
|
|
2993
|
+
if (interactive.length !== 1) return true;
|
|
2455
2994
|
return !isAutoSubmitBlock(interactive[0]);
|
|
2456
2995
|
}
|
|
2457
2996
|
function shouldAutoSubmit(field2, value, ctx) {
|
|
2458
2997
|
if (ctx.form.settings.submitMode !== "auto") return false;
|
|
2459
|
-
if (
|
|
2998
|
+
if (ctx.status !== "idle" || ctx.uploading) return false;
|
|
2460
2999
|
const hasDiscreteAnswer = (() => {
|
|
2461
3000
|
switch (field2.kind) {
|
|
2462
3001
|
case "select":
|
|
@@ -2474,7 +3013,9 @@ function shouldAutoSubmit(field2, value, ctx) {
|
|
|
2474
3013
|
if (!hasDiscreteAnswer) return false;
|
|
2475
3014
|
if (value === ctx.data[field2.id]) return false;
|
|
2476
3015
|
const nextData = { ...ctx.data, [field2.id]: value };
|
|
2477
|
-
const
|
|
3016
|
+
const page = ctx.form.pages.find((p) => p.blocks.some((b) => b.id === field2.id));
|
|
3017
|
+
if (!page || !isTerminalPage(ctx.form, page.id, nextData)) return false;
|
|
3018
|
+
const interactive = reachableFields(ctx.form, nextData).filter((f) => f.kind !== "hidden");
|
|
2478
3019
|
if (interactive.length !== 1 || interactive[0]?.id !== field2.id) return false;
|
|
2479
3020
|
return validateResponse(ctx.form, nextData).ok;
|
|
2480
3021
|
}
|
|
@@ -2500,7 +3041,8 @@ var FILLO_SLOTS = [
|
|
|
2500
3041
|
"footer",
|
|
2501
3042
|
"button",
|
|
2502
3043
|
"success",
|
|
2503
|
-
"resume"
|
|
3044
|
+
"resume",
|
|
3045
|
+
"turnstile"
|
|
2504
3046
|
];
|
|
2505
3047
|
var FILLO_DATA_ATTRS = {
|
|
2506
3048
|
/** Slot name, on every slot element: `data-fillo="control"`. */
|
|
@@ -2549,35 +3091,6 @@ var FILLO_THEME_VARS = [
|
|
|
2549
3091
|
{ var: "--fillo-primary-contrast" }
|
|
2550
3092
|
];
|
|
2551
3093
|
|
|
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
3094
|
// src/define.ts
|
|
2582
3095
|
function defineForm(def) {
|
|
2583
3096
|
return {
|
|
@@ -2598,15 +3111,18 @@ function isCodeForm(form) {
|
|
|
2598
3111
|
}
|
|
2599
3112
|
var syncCache = /* @__PURE__ */ new Map();
|
|
2600
3113
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3114
|
+
function isRetryableSyncError(error) {
|
|
3115
|
+
if (!isFilloError(error)) return true;
|
|
3116
|
+
const status = error.status ?? 0;
|
|
3117
|
+
return status === 0 || status === 408 || status === 429 || status >= 500;
|
|
3118
|
+
}
|
|
2601
3119
|
async function syncWithRetry(client, form) {
|
|
2602
3120
|
let delay = 800;
|
|
2603
3121
|
for (let attempt = 0; ; attempt++) {
|
|
2604
3122
|
try {
|
|
2605
3123
|
return await client.syncForm(form.id, form.schema, form.theme ?? null);
|
|
2606
3124
|
} catch (err) {
|
|
2607
|
-
|
|
2608
|
-
const retryable = !isFilloError(err) || status === 0 || status === 408 || status === 429 || status >= 500;
|
|
2609
|
-
if (!retryable || attempt >= 2) throw err;
|
|
3125
|
+
if (!isRetryableSyncError(err) || attempt >= 2) throw err;
|
|
2610
3126
|
const hinted = isFilloError(err) ? err.retryAfterSec : void 0;
|
|
2611
3127
|
await sleep(Math.min(hinted ? hinted * 1e3 : delay * (1 + Math.random() * 0.5), 15e3));
|
|
2612
3128
|
delay *= 2;
|
|
@@ -2618,17 +3134,32 @@ function contentHash(input) {
|
|
|
2618
3134
|
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
2619
3135
|
return (h >>> 0).toString(36);
|
|
2620
3136
|
}
|
|
3137
|
+
function formSchemasEqual(left, right) {
|
|
3138
|
+
const canonicalize = (value) => {
|
|
3139
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
3140
|
+
if (value && typeof value === "object") {
|
|
3141
|
+
const record2 = value;
|
|
3142
|
+
return Object.fromEntries(
|
|
3143
|
+
Object.keys(record2).sort().map((key) => [key, canonicalize(record2[key])])
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
return value;
|
|
3147
|
+
};
|
|
3148
|
+
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
|
3149
|
+
}
|
|
3150
|
+
var syncTtlMs = (status) => status === "published" ? 36e5 : 6e4;
|
|
3151
|
+
var isVolatileSync = (result) => Boolean(result.staged || result.resolvedSchema || result.syncError);
|
|
2621
3152
|
function storageKey(client, handle) {
|
|
2622
|
-
return `fillo:sync:${client.baseUrl}|${client.key}|${handle}`;
|
|
3153
|
+
return `fillo:sync:v3:${client.baseUrl}|${client.key}|${handle}`;
|
|
2623
3154
|
}
|
|
2624
3155
|
function readStoredSync(client, handle, hash) {
|
|
2625
3156
|
try {
|
|
2626
3157
|
const raw = globalThis.localStorage?.getItem(storageKey(client, handle));
|
|
2627
3158
|
if (!raw) return null;
|
|
2628
3159
|
const parsed = JSON.parse(raw);
|
|
2629
|
-
if (typeof parsed.formId !== "string" || parsed.hash !== hash || typeof parsed.ts !== "number")
|
|
3160
|
+
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
3161
|
return null;
|
|
2631
|
-
const ttl = parsed.status
|
|
3162
|
+
const ttl = syncTtlMs(parsed.status);
|
|
2632
3163
|
if (Date.now() - parsed.ts > ttl) return null;
|
|
2633
3164
|
return parsed;
|
|
2634
3165
|
} catch {
|
|
@@ -2636,6 +3167,8 @@ function readStoredSync(client, handle, hash) {
|
|
|
2636
3167
|
}
|
|
2637
3168
|
}
|
|
2638
3169
|
function writeStoredSync(client, handle, hash, r) {
|
|
3170
|
+
if (r.staged || r.resolvedSchema || r.syncError) return;
|
|
3171
|
+
if (r.challenge) return;
|
|
2639
3172
|
try {
|
|
2640
3173
|
globalThis.localStorage?.setItem(
|
|
2641
3174
|
storageKey(client, handle),
|
|
@@ -2654,23 +3187,44 @@ function syncCodeForm(client, form, opts) {
|
|
|
2654
3187
|
const content2 = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
|
|
2655
3188
|
const hash = contentHash(content2);
|
|
2656
3189
|
const key = `${client.baseUrl}:${client.key}:${form.id}:${hash}`;
|
|
2657
|
-
|
|
2658
|
-
if (
|
|
3190
|
+
const cached = syncCache.get(key);
|
|
3191
|
+
if (cached && !opts?.bypassCache) {
|
|
3192
|
+
if (cached.expiresAt === null || cached.expiresAt > Date.now()) return cached.promise;
|
|
3193
|
+
syncCache.delete(key);
|
|
3194
|
+
}
|
|
2659
3195
|
if (!opts?.bypassCache) {
|
|
2660
3196
|
const stored = readStoredSync(client, form.id, hash);
|
|
2661
3197
|
if (stored) {
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
3198
|
+
const promise2 = Promise.resolve({
|
|
3199
|
+
formId: stored.formId,
|
|
3200
|
+
slug: stored.slug,
|
|
3201
|
+
status: stored.status
|
|
3202
|
+
});
|
|
3203
|
+
syncCache.set(key, {
|
|
3204
|
+
promise: promise2,
|
|
3205
|
+
expiresAt: stored.ts + syncTtlMs(stored.status)
|
|
3206
|
+
});
|
|
3207
|
+
return promise2;
|
|
2665
3208
|
}
|
|
2666
3209
|
}
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
3210
|
+
const promise = syncWithRetry(client, form);
|
|
3211
|
+
const entry = { promise, expiresAt: null };
|
|
3212
|
+
syncCache.set(key, entry);
|
|
3213
|
+
promise.then(
|
|
3214
|
+
(result) => {
|
|
3215
|
+
if (syncCache.get(key) !== entry) return;
|
|
3216
|
+
if (isVolatileSync(result)) {
|
|
3217
|
+
syncCache.delete(key);
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
entry.expiresAt = Date.now() + syncTtlMs(result.status);
|
|
3221
|
+
writeStoredSync(client, form.id, hash, result);
|
|
3222
|
+
},
|
|
3223
|
+
() => {
|
|
3224
|
+
if (syncCache.get(key) === entry) syncCache.delete(key);
|
|
3225
|
+
}
|
|
3226
|
+
);
|
|
3227
|
+
return promise;
|
|
2674
3228
|
}
|
|
2675
3229
|
|
|
2676
3230
|
// src/jsx.ts
|
|
@@ -2710,8 +3264,8 @@ var JSX_BLOCK_SPECS = [
|
|
|
2710
3264
|
field("MultiSelect", "multi_select", CHOICE_EXTRA, { optionChildren: true }),
|
|
2711
3265
|
field("Dropdown", "dropdown", CHOICE_EXTRA, { optionChildren: true }),
|
|
2712
3266
|
field("Checkbox", "checkbox", ["appearance"]),
|
|
2713
|
-
field("Rating", "rating", ["max"]),
|
|
2714
|
-
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel"]),
|
|
3267
|
+
field("Rating", "rating", ["max", "insightsMetric"]),
|
|
3268
|
+
field("Scale", "linear_scale", ["min", "max", "minLabel", "maxLabel", "insightsMetric"]),
|
|
2715
3269
|
field("Ranking", "ranking", ["options"], { optionChildren: true }),
|
|
2716
3270
|
field("Matrix", "matrix", ["rows", "columns"]),
|
|
2717
3271
|
field("Signature", "signature", []),
|
|
@@ -2876,6 +3430,18 @@ function buildOption(el, parent, parentId) {
|
|
|
2876
3430
|
if (props.icon !== void 0) option.icon = props.icon;
|
|
2877
3431
|
return option;
|
|
2878
3432
|
}
|
|
3433
|
+
function assertUniqueOptionIds(blockId, options2, label) {
|
|
3434
|
+
const optionIds = /* @__PURE__ */ new Set();
|
|
3435
|
+
for (const option of options2) {
|
|
3436
|
+
if (optionIds.has(option.id)) {
|
|
3437
|
+
throw new FilloJsxError(
|
|
3438
|
+
"DUPLICATE_ID",
|
|
3439
|
+
`Two ${label} in "${blockId}" share the id "${option.id}" \u2014 option ids are stored answer values and must be unique within the field`
|
|
3440
|
+
);
|
|
3441
|
+
}
|
|
3442
|
+
optionIds.add(option.id);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
2879
3445
|
function schemaFromJsx(children, meta) {
|
|
2880
3446
|
const elements = flatten(children);
|
|
2881
3447
|
const pageSpecs = elements.map((el) => ({ el, spec: classify(el) }));
|
|
@@ -2916,7 +3482,15 @@ function schemaFromJsx(children, meta) {
|
|
|
2916
3482
|
pages.push({ id: "main", blocks });
|
|
2917
3483
|
}
|
|
2918
3484
|
const seen = /* @__PURE__ */ new Set();
|
|
3485
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
2919
3486
|
for (const page of pages) {
|
|
3487
|
+
if (pageIds.has(page.id)) {
|
|
3488
|
+
throw new FilloJsxError(
|
|
3489
|
+
"DUPLICATE_ID",
|
|
3490
|
+
`Two pages share the id "${page.id}" \u2014 page ids must be unique within a form`
|
|
3491
|
+
);
|
|
3492
|
+
}
|
|
3493
|
+
pageIds.add(page.id);
|
|
2920
3494
|
for (const block of page.blocks) {
|
|
2921
3495
|
if (seen.has(block.id)) {
|
|
2922
3496
|
throw new FilloJsxError(
|
|
@@ -2925,6 +3499,11 @@ function schemaFromJsx(children, meta) {
|
|
|
2925
3499
|
);
|
|
2926
3500
|
}
|
|
2927
3501
|
seen.add(block.id);
|
|
3502
|
+
if ("options" in block) assertUniqueOptionIds(block.id, block.options, "options");
|
|
3503
|
+
if (block.kind === "matrix") {
|
|
3504
|
+
assertUniqueOptionIds(block.id, block.rows, "matrix rows");
|
|
3505
|
+
assertUniqueOptionIds(block.id, block.columns, "matrix columns");
|
|
3506
|
+
}
|
|
2928
3507
|
}
|
|
2929
3508
|
}
|
|
2930
3509
|
return {
|
|
@@ -2999,9 +3578,12 @@ function pipeBlock(block, data, form) {
|
|
|
2999
3578
|
export {
|
|
3000
3579
|
BLOCK_KIND_META,
|
|
3001
3580
|
CONTENT_KINDS,
|
|
3581
|
+
DEFAULT_FIELD_STRINGS,
|
|
3002
3582
|
DEFAULT_STRINGS,
|
|
3003
3583
|
DRAFT_KINDS,
|
|
3584
|
+
FILLO_CHALLENGE_MIN_SDK_VERSION,
|
|
3004
3585
|
FILLO_DATA_ATTRS,
|
|
3586
|
+
FILLO_MIN_SDK_VERSION,
|
|
3005
3587
|
FILLO_SCHEMA_VERSION,
|
|
3006
3588
|
FILLO_SDK_VERSION,
|
|
3007
3589
|
FILLO_SLOTS,
|
|
@@ -3012,10 +3594,13 @@ export {
|
|
|
3012
3594
|
JSX_BLOCK_COMPONENTS,
|
|
3013
3595
|
JSX_BLOCK_SPECS,
|
|
3014
3596
|
PHONE_COUNTRIES,
|
|
3597
|
+
PHONE_POPOVER_VIEWPORT_GAP,
|
|
3598
|
+
REQUIRED_FIELD_MESSAGE,
|
|
3015
3599
|
Sha1,
|
|
3016
3600
|
allFields,
|
|
3017
3601
|
assembleForm,
|
|
3018
3602
|
codeFormFromJsx,
|
|
3603
|
+
conditionsMet,
|
|
3019
3604
|
contentHash,
|
|
3020
3605
|
countryByDialCode,
|
|
3021
3606
|
countryByIso,
|
|
@@ -3028,6 +3613,7 @@ export {
|
|
|
3028
3613
|
defineForm,
|
|
3029
3614
|
digitsOnly,
|
|
3030
3615
|
flagEmoji,
|
|
3616
|
+
formSchemasEqual,
|
|
3031
3617
|
formatAnswer,
|
|
3032
3618
|
formatNational,
|
|
3033
3619
|
isAutoSubmitBlock,
|
|
@@ -3036,14 +3622,21 @@ export {
|
|
|
3036
3622
|
isField,
|
|
3037
3623
|
isFilloError,
|
|
3038
3624
|
isPossiblePhone,
|
|
3625
|
+
isTerminalPage,
|
|
3039
3626
|
needsExplicitSubmit,
|
|
3040
3627
|
normalizeFormSchema,
|
|
3041
3628
|
normalizeFormTheme,
|
|
3042
3629
|
normalizeSettings,
|
|
3043
3630
|
parsePhone,
|
|
3044
3631
|
pipeBlock,
|
|
3632
|
+
positionPhonePopover,
|
|
3045
3633
|
prefillFromParams,
|
|
3046
3634
|
provisionWorkspace,
|
|
3635
|
+
reachableFieldIds,
|
|
3636
|
+
reachableFields,
|
|
3637
|
+
reachablePageIds,
|
|
3638
|
+
reachablePageSequence,
|
|
3639
|
+
resolveNextPage,
|
|
3047
3640
|
resolveSlotClass,
|
|
3048
3641
|
resolveStrings,
|
|
3049
3642
|
resolveText,
|
|
@@ -3059,5 +3652,6 @@ export {
|
|
|
3059
3652
|
validateResponse,
|
|
3060
3653
|
visibleBlocks,
|
|
3061
3654
|
visibleFields,
|
|
3655
|
+
visiblePageBlocks,
|
|
3062
3656
|
when
|
|
3063
3657
|
};
|