@usefillo/core 0.6.2 → 0.7.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 +1 -1
- package/dist/index.d.ts +237 -10
- package/dist/index.js +378 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ const form = defineForm({
|
|
|
28
28
|
|
|
29
29
|
Published renderer embeds can fetch by `formId` without a key. Use a client object for custom API origins, uploads, or submissions from your own renderer; add a publishable key only when syncing `defineForm()` schemas from code.
|
|
30
30
|
|
|
31
|
-
Schema settings include `submitMode: "auto"` for one-tap forms that should submit after a complete discrete answer instead of rendering the first submit button, and `
|
|
31
|
+
Schema settings include `submitMode: "auto"` for one-tap forms that should submit after a complete discrete answer instead of rendering the first submit button, and `responseLimit: { by: "browser", onRepeat: "keep" }` for browser-scoped duplicate prevention. Themes include `colorScheme: "light" | "dark" | "auto"` for renderer defaults.
|
|
32
32
|
|
|
33
33
|
Key exports: `createClient` / `FilloClient`, `defineForm`, `validateResponse`, `validateField`, `visibleBlocks`, and the schema types (`FormSchema`, `Field`, `FieldKind`, `FormTheme`, `ResponseData`, …).
|
|
34
34
|
|
package/dist/index.d.ts
CHANGED
|
@@ -148,6 +148,39 @@ interface FormPage {
|
|
|
148
148
|
title?: string;
|
|
149
149
|
blocks: Block[];
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Limit repeat responses. Absent = no limit (submit as often as you like).
|
|
153
|
+
*/
|
|
154
|
+
interface ResponseLimit {
|
|
155
|
+
/**
|
|
156
|
+
* Who counts as the same responder:
|
|
157
|
+
* - "browser": same device, anonymous — the SDK remembers this browser
|
|
158
|
+
* answered and sends a de-duplication key (ratings, polls, "was this
|
|
159
|
+
* helpful").
|
|
160
|
+
* - "field": the person self-identifies by answering `field` (an email or
|
|
161
|
+
* phone field). A self-claim — unverified, soft dedup — so the hosted link
|
|
162
|
+
* and anonymous embeds can still dedupe.
|
|
163
|
+
* - "identify": the identify() respondent (HMAC-verifiable). The strong
|
|
164
|
+
* path; only embedded surfaces that call identify() are recognized.
|
|
165
|
+
*/
|
|
166
|
+
by: "browser" | "field" | "identify";
|
|
167
|
+
/** The email/phone field whose answer identifies the person. `by: "field"` only. */
|
|
168
|
+
field?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Optionally sub-scope the limit by a field's answer (usually a hidden
|
|
171
|
+
* article/product/order id): "one response per responder PER that value" —
|
|
172
|
+
* e.g. one 👍 per visitor per article from a single shared form.
|
|
173
|
+
*/
|
|
174
|
+
scopeField?: string;
|
|
175
|
+
/**
|
|
176
|
+
* A repeat from the same responder: "keep" answers with the standing
|
|
177
|
+
* response (the first answer stands); "update" edits it in place (re-anchored
|
|
178
|
+
* to the current schema, `response.updated` webhook, previous answers
|
|
179
|
+
* prefilled for a verified respondent). "update" applies to `by: "identify"`
|
|
180
|
+
* only — a self-claim/browser must not overwrite another person's response.
|
|
181
|
+
*/
|
|
182
|
+
onRepeat: "keep" | "update";
|
|
183
|
+
}
|
|
151
184
|
interface FormSettings {
|
|
152
185
|
/**
|
|
153
186
|
* Default "button": respondents submit with the footer button. "auto" hides
|
|
@@ -160,17 +193,49 @@ interface FormSettings {
|
|
|
160
193
|
successMessage?: string;
|
|
161
194
|
redirectUrl?: string;
|
|
162
195
|
showProgress?: boolean;
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
* remember this browser has already answered and send a server-side
|
|
167
|
-
* de-duplication key with the response.
|
|
168
|
-
*/
|
|
169
|
-
submissionLimit?: "multiple" | "once_per_visitor";
|
|
196
|
+
/** Limit repeat responses (who counts as the same responder, and what a
|
|
197
|
+
* repeat does). Absent = no limit. See {@link ResponseLimit}. */
|
|
198
|
+
responseLimit?: ResponseLimit;
|
|
170
199
|
/** Notify this address on every submission. */
|
|
171
200
|
notifyEmail?: string;
|
|
172
201
|
/** Send respondents a receipt (to the first answered email field). */
|
|
173
202
|
sendReceipt?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Save respondents' in-progress answers to Fillo (default off). The SDK
|
|
205
|
+
* autosaves as they type and restores on return, so long forms survive
|
|
206
|
+
* reloads and tab closes. Off by default because it stores answers before
|
|
207
|
+
* the respondent chooses to submit — the form owner opts in.
|
|
208
|
+
*/
|
|
209
|
+
saveProgress?: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Let workspace members read the answers a respondent has typed but not yet
|
|
212
|
+
* submitted (default off; requires saveProgress). This exposes pre-submission
|
|
213
|
+
* content to the owner's team, so it's a separate opt-in with its own consent
|
|
214
|
+
* framing — disclose it in your privacy policy. Respondents still see the
|
|
215
|
+
* "progress saved" notice. The content is visible only in the authenticated
|
|
216
|
+
* dashboard; the draft token stays the only public read capability.
|
|
217
|
+
*/
|
|
218
|
+
draftAnswersVisible?: boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Email a respondent one "pick up where you left off" link when they leave a
|
|
221
|
+
* form idle (default off; requires saveProgress). Sent at most once per
|
|
222
|
+
* draft, to an address they entered or their verified account email — never
|
|
223
|
+
* with any answer content. Owner opt-in.
|
|
224
|
+
*/
|
|
225
|
+
resumeEmails?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* Where a resume link should land for an embedded form (http(s) only). The
|
|
228
|
+
* draft reference travels in the URL fragment, which the SDK adopts on load;
|
|
229
|
+
* defaults to the hosted /f page when unset.
|
|
230
|
+
*/
|
|
231
|
+
resumeUrl?: string;
|
|
232
|
+
/**
|
|
233
|
+
* Email the form's notification address a daily digest of who dropped off —
|
|
234
|
+
* abandoned and open draft counts, where people stalled, and verified
|
|
235
|
+
* respondents by name (default off; requires notifyEmail). Never any answer
|
|
236
|
+
* content or resume links.
|
|
237
|
+
*/
|
|
238
|
+
draftDigest?: boolean;
|
|
174
239
|
}
|
|
175
240
|
interface FormSchema {
|
|
176
241
|
version: 1;
|
|
@@ -281,6 +346,15 @@ declare function isBlockVisible(block: Block, data: ResponseData): boolean;
|
|
|
281
346
|
declare function visibleBlocks(page: FormPage, data: ResponseData): Block[];
|
|
282
347
|
/** Every field in the form (across pages), in order. */
|
|
283
348
|
declare function allFields(form: FormSchema): Field[];
|
|
349
|
+
/**
|
|
350
|
+
* The scope key for a response limit: the string form of the answer to
|
|
351
|
+
* `settings.responseLimit.scopeField`, or null when there is no scope field or the
|
|
352
|
+
* answer is not a usable scalar. A non-scalar answer (checkbox boolean,
|
|
353
|
+
* multi_select/ranking array, matrix object) means "no scope" — the limit
|
|
354
|
+
* spans the whole form rather than silently mis-bucketing. Shared by the SDK's
|
|
355
|
+
* per-visitor key and the server's per-person dedup so both scope identically.
|
|
356
|
+
*/
|
|
357
|
+
declare function responseScopeValue(settings: FormSettings, data: ResponseData): string | null;
|
|
284
358
|
/** Fields currently visible given the response data — the set that gets validated. */
|
|
285
359
|
declare function visibleFields(form: FormSchema, data: ResponseData): Field[];
|
|
286
360
|
|
|
@@ -302,6 +376,7 @@ declare function validateResponse(form: FormSchema, data: ResponseData): Validat
|
|
|
302
376
|
declare const FILLO_SCHEMA_VERSION: 1;
|
|
303
377
|
/** Injected from package.json at build time (tsup define) — never hand-edited. */
|
|
304
378
|
declare const FILLO_SDK_VERSION: string;
|
|
379
|
+
declare function normalizeSettings(value: unknown): FormSettings;
|
|
305
380
|
interface SchemaValidationResult {
|
|
306
381
|
ok: boolean;
|
|
307
382
|
/** Present when ok — a normalized, structurally-valid schema. */
|
|
@@ -478,6 +553,8 @@ type SubmitResult = {
|
|
|
478
553
|
responseId: string;
|
|
479
554
|
/** True when the API accepted the request as an already-recorded visitor response. */
|
|
480
555
|
duplicate?: boolean;
|
|
556
|
+
/** True when an update-in-place limit (responseLimit onRepeat "update") updated the person's living response. */
|
|
557
|
+
updated?: boolean;
|
|
481
558
|
errors?: undefined;
|
|
482
559
|
} | {
|
|
483
560
|
ok: false;
|
|
@@ -496,9 +573,74 @@ interface SubmitMeta {
|
|
|
496
573
|
surface?: "default" | "headless";
|
|
497
574
|
/**
|
|
498
575
|
* Browser-scoped de-duplication key sent when a form opts into
|
|
499
|
-
* settings.
|
|
576
|
+
* settings.responseLimit.by = "browser".
|
|
500
577
|
*/
|
|
501
578
|
submissionKey?: string;
|
|
579
|
+
/**
|
|
580
|
+
* Saved-progress draft this submission completes (forms with
|
|
581
|
+
* settings.saveProgress). The server deletes the draft with the response
|
|
582
|
+
* commit so it can't be resumed after submitting.
|
|
583
|
+
*/
|
|
584
|
+
draft?: {
|
|
585
|
+
id: string;
|
|
586
|
+
token: string;
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* Host-app account context for this respondent (identify()). Recorded with
|
|
590
|
+
* the response and shown in the dashboard/webhooks as a CLAIM from the
|
|
591
|
+
* embedding page — it is metadata, not authentication. `id` is your own
|
|
592
|
+
* stable user/account id.
|
|
593
|
+
*/
|
|
594
|
+
respondent?: FilloRespondent;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The host app's account context for the person filling the form. Passed as
|
|
598
|
+
* the `respondent` option on FilloForm / FilloProvider / renderForm /
|
|
599
|
+
* createFormController; Fillo keys responses to it so the dashboard,
|
|
600
|
+
* webhooks, and integrations can say WHO answered.
|
|
601
|
+
*/
|
|
602
|
+
interface FilloRespondent {
|
|
603
|
+
/** Your stable user/account id — the identity key within your workspace. */
|
|
604
|
+
id: string;
|
|
605
|
+
email?: string;
|
|
606
|
+
name?: string;
|
|
607
|
+
/** Small primitive facts (plan, role, region…) — not a data warehouse. */
|
|
608
|
+
traits?: Record<string, string | number | boolean>;
|
|
609
|
+
/**
|
|
610
|
+
* Identity verification (optional): hex HMAC-SHA256 of `id`, computed on
|
|
611
|
+
* YOUR server with the workspace identity secret from Fillo settings. Once
|
|
612
|
+
* the workspace holds a secret, Fillo records identity only with a valid
|
|
613
|
+
* hash — never compute this in the browser or the secret leaks.
|
|
614
|
+
*/
|
|
615
|
+
hash?: string;
|
|
616
|
+
}
|
|
617
|
+
/** Wire shape of a saved-progress draft (settings.saveProgress forms). */
|
|
618
|
+
interface ResponseDraft {
|
|
619
|
+
id: string;
|
|
620
|
+
formId: string;
|
|
621
|
+
/** Partial answers exactly as last saved — validated only at submit. */
|
|
622
|
+
data: ResponseData;
|
|
623
|
+
/** 0-based page the respondent was on when the draft was last saved. */
|
|
624
|
+
page: number;
|
|
625
|
+
/** A freshly rotated bearer, returned only when adopting a resume link — the
|
|
626
|
+
* URL token is spent, so this is what future saves must use. */
|
|
627
|
+
token?: string;
|
|
628
|
+
}
|
|
629
|
+
interface CreatedDraft {
|
|
630
|
+
id: string;
|
|
631
|
+
/**
|
|
632
|
+
* Per-draft bearer, returned once at creation; sent back as
|
|
633
|
+
* X-Fillo-Draft-Token on every read/save/delete of this draft.
|
|
634
|
+
*/
|
|
635
|
+
token: string;
|
|
636
|
+
/** ISO timestamp; the server slides it forward on every save. */
|
|
637
|
+
expiresAt?: string;
|
|
638
|
+
/**
|
|
639
|
+
* True when a VERIFIED identity picked up its existing draft from another
|
|
640
|
+
* device — the token was rotated to this caller, and the draft's answers
|
|
641
|
+
* were left untouched (fetch them with getDraft to restore).
|
|
642
|
+
*/
|
|
643
|
+
existing?: boolean;
|
|
502
644
|
}
|
|
503
645
|
interface UploadProgress {
|
|
504
646
|
uploadedBytes: number;
|
|
@@ -553,6 +695,16 @@ declare class FilloClient {
|
|
|
553
695
|
syncForm(handle: string, schema: FormSchema, theme?: FormTheme | null): Promise<SyncFormResult>;
|
|
554
696
|
/** Submit a response. Returns per-field errors instead of throwing on validation failure. */
|
|
555
697
|
submit(formId: string, data: ResponseData, meta?: SubmitMeta): Promise<SubmitResult>;
|
|
698
|
+
/**
|
|
699
|
+
* The identified person's own living response on an upsert-mode form —
|
|
700
|
+
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
701
|
+
* ({id, hash}); anything else 404s (returned as null), because an
|
|
702
|
+
* unverified read would hand anyone's answers to any page script.
|
|
703
|
+
*/
|
|
704
|
+
fetchOwnResponse(formId: string, respondent: FilloRespondent): Promise<{
|
|
705
|
+
responseId: string;
|
|
706
|
+
data: ResponseData;
|
|
707
|
+
} | null>;
|
|
556
708
|
/**
|
|
557
709
|
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
558
710
|
* session id, or null if tracking is unavailable (never blocks the form).
|
|
@@ -563,6 +715,33 @@ declare class FilloClient {
|
|
|
563
715
|
furthestPage?: number;
|
|
564
716
|
completed?: boolean;
|
|
565
717
|
}): void;
|
|
718
|
+
/**
|
|
719
|
+
* Create a saved-progress draft (forms with settings.saveProgress). Returns
|
|
720
|
+
* the draft id and its ownership token — the token is shown only once.
|
|
721
|
+
*/
|
|
722
|
+
createDraft(formId: string, body: {
|
|
723
|
+
data: ResponseData;
|
|
724
|
+
page?: number;
|
|
725
|
+
respondent?: FilloRespondent;
|
|
726
|
+
}): Promise<CreatedDraft>;
|
|
727
|
+
/** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
|
|
728
|
+
* `adopt` is set (resume-link adoption), the server rotates the bearer and
|
|
729
|
+
* returns the new `token` — the URL token becomes single-use. */
|
|
730
|
+
getDraft(draftId: string, token: string, adopt?: boolean): Promise<ResponseDraft>;
|
|
731
|
+
/**
|
|
732
|
+
* Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
|
|
733
|
+
* tab-close flush outlive the page; browsers reject keepalive bodies over
|
|
734
|
+
* ~64KB, in which case this save is simply lost — the debounced autosaves
|
|
735
|
+
* every few keystrokes are the real persistence, the flush is a bonus.
|
|
736
|
+
*/
|
|
737
|
+
saveDraft(draftId: string, token: string, body: {
|
|
738
|
+
data: ResponseData;
|
|
739
|
+
page?: number;
|
|
740
|
+
}, opts?: {
|
|
741
|
+
keepalive?: boolean;
|
|
742
|
+
}): Promise<void>;
|
|
743
|
+
/** Discard a draft (the "Start over" path). */
|
|
744
|
+
deleteDraft(draftId: string, token: string): Promise<void>;
|
|
566
745
|
/** Current state of an upload session — used to resume after interruption. */
|
|
567
746
|
getUploadSession(sessionId: string, token?: string): Promise<UploadSession>;
|
|
568
747
|
/**
|
|
@@ -680,6 +859,13 @@ interface FormControllerOptions {
|
|
|
680
859
|
* respondent can retry.
|
|
681
860
|
*/
|
|
682
861
|
resolveFormId?: () => Promise<string>;
|
|
862
|
+
/**
|
|
863
|
+
* Host-app account context (identify()): who is filling this form, by your
|
|
864
|
+
* own user id. Sent with the submission and recorded as an unverified
|
|
865
|
+
* claim — it changes what the dashboard/webhooks can tell you, never what
|
|
866
|
+
* the respondent can do.
|
|
867
|
+
*/
|
|
868
|
+
respondent?: FilloRespondent;
|
|
683
869
|
}
|
|
684
870
|
interface FormControllerState {
|
|
685
871
|
data: ResponseData;
|
|
@@ -697,6 +883,27 @@ interface FormControllerState {
|
|
|
697
883
|
uploading: boolean;
|
|
698
884
|
/** Human-readable message for the last failed submit; cleared on edit/retry. */
|
|
699
885
|
submitError?: string;
|
|
886
|
+
/**
|
|
887
|
+
* True when `status` is "submitted" because the once-per-visitor gate
|
|
888
|
+
* restored a previous visit's response, not because a submit happened in
|
|
889
|
+
* this controller instance. Renderers use it to skip one-time "just
|
|
890
|
+
* submitted" reactions (moving focus to the success screen, redirecting) —
|
|
891
|
+
* otherwise every remount of an already-answered form replays them.
|
|
892
|
+
*/
|
|
893
|
+
restoredSubmission: boolean;
|
|
894
|
+
/**
|
|
895
|
+
* True when a saved-progress draft (settings.saveProgress) was restored
|
|
896
|
+
* into this fill — answers and/or page position came from a previous
|
|
897
|
+
* visit. Renderers use it to show a "continuing where you left off"
|
|
898
|
+
* notice with a Start over action (resetDraft).
|
|
899
|
+
*/
|
|
900
|
+
resumedDraft: boolean;
|
|
901
|
+
/**
|
|
902
|
+
* True when an update-in-place limit prefilled the VERIFIED respondent's own
|
|
903
|
+
* previous answers — submitting updates that response in place. Renderers
|
|
904
|
+
* show an "updating your earlier response" notice.
|
|
905
|
+
*/
|
|
906
|
+
editingPrevious: boolean;
|
|
700
907
|
}
|
|
701
908
|
interface FormController {
|
|
702
909
|
/** Stable snapshot — same reference until something changes (safe for useSyncExternalStore). */
|
|
@@ -721,7 +928,21 @@ interface FormController {
|
|
|
721
928
|
form?: FormSchema;
|
|
722
929
|
formId?: string;
|
|
723
930
|
client?: FilloClient;
|
|
931
|
+
/** Late-bind identify() context — host sessions often resolve after mount. */
|
|
932
|
+
respondent?: FilloRespondent;
|
|
724
933
|
}): void;
|
|
934
|
+
/**
|
|
935
|
+
* Persist any unsaved draft progress right now (settings.saveProgress
|
|
936
|
+
* forms). Renderers call this on pagehide/visibility-hidden so the last
|
|
937
|
+
* keystrokes survive a tab close; a no-op when there's nothing to save.
|
|
938
|
+
*/
|
|
939
|
+
flushDraft(): void;
|
|
940
|
+
/**
|
|
941
|
+
* Discard the saved draft and reset to a fresh fill: answers back to
|
|
942
|
+
* initialData + URL prefill, first page, errors cleared. The "Start over"
|
|
943
|
+
* action next to the resume notice.
|
|
944
|
+
*/
|
|
945
|
+
resetDraft(): void;
|
|
725
946
|
/** Drop all listeners. */
|
|
726
947
|
destroy(): void;
|
|
727
948
|
}
|
|
@@ -753,7 +974,7 @@ declare function shouldAutoSubmit(field: Field, value: FieldValue, ctx: AutoSubm
|
|
|
753
974
|
* (Tailwind `data-[invalid]:…`) can react to it. Slot names and attribute
|
|
754
975
|
* names are public API — renames are breaking; additions are minors.
|
|
755
976
|
*/
|
|
756
|
-
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success"];
|
|
977
|
+
declare const FILLO_SLOTS: readonly ["root", "header", "title", "description", "pageTitle", "progress", "progressFill", "blocks", "field", "label", "fieldDescription", "control", "options", "option", "optionLabel", "error", "footer", "button", "success", "resume"];
|
|
757
978
|
type FilloSlot = (typeof FILLO_SLOTS)[number];
|
|
758
979
|
/** data-* names emitted alongside the stable fillo-* classes. */
|
|
759
980
|
declare const FILLO_DATA_ATTRS: {
|
|
@@ -870,6 +1091,12 @@ interface FilloStrings {
|
|
|
870
1091
|
loadFailedNetwork: string;
|
|
871
1092
|
loadFailed: string;
|
|
872
1093
|
renderFailed: string;
|
|
1094
|
+
/** Saved-progress notice when a draft restored earlier answers. */
|
|
1095
|
+
resumeNotice: string;
|
|
1096
|
+
/** Upsert-mode notice when the person's previous response was prefilled. */
|
|
1097
|
+
editNotice: string;
|
|
1098
|
+
/** The discard action next to the resume notice. */
|
|
1099
|
+
resumeStartOver: string;
|
|
873
1100
|
}
|
|
874
1101
|
declare const DEFAULT_STRINGS: FilloStrings;
|
|
875
1102
|
declare function resolveStrings(overrides?: Partial<FilloStrings>): FilloStrings;
|
|
@@ -1036,4 +1263,4 @@ declare class Sha1 {
|
|
|
1036
1263
|
}
|
|
1037
1264
|
declare const sha1Base64: (bytes: Uint8Array) => string;
|
|
1038
1265
|
|
|
1039
|
-
export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CustomField, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, FilloJsxError, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, when };
|
|
1266
|
+
export { type AutoSubmitContext, BLOCK_KIND_META, type BaseField, type Block, type BlockKind, CONTENT_KINDS, type CheckboxField, type ChoiceField, type CodeForm, type Condition, type ConditionOp, type ContentBlock, type ContentKind, type CreatedDraft, type CustomField, DEFAULT_STRINGS, DRAFT_KINDS, type DateField, type DividerBlock, FILLO_DATA_ATTRS, FILLO_SCHEMA_VERSION, FILLO_SDK_VERSION, FILLO_SLOTS, FILLO_THEME_VARS, type Field, type FieldKind, type FieldSpec, type FieldValue, type FileUploadField, type FileValue, type FilloAppearance, FilloClient, type FilloClientOptions, FilloError, FilloJsxError, type FilloRespondent, type FilloSlot, type FilloStrings, type FormBranding, type FormController, type FormControllerOptions, type FormControllerState, type FormDraftSpec, type FormPage, type FormSchema, type FormSettings, type FormStatus, type FormTheme, type HeadingBlock, type HiddenField, JSX_BLOCK_COMPONENTS, JSX_BLOCK_SPECS, type JsonValue, type JsxFormMeta, type LinearScaleField, type MatrixField, type NumberField, PHONE_COUNTRIES, type ParagraphBlock, type ParsedPhone, type PhoneCountry, type PhoneField, type ProvisionWorkspaceResult, type PublishedForm, type RankingField, type RatingField, type ResponseData, type ResponseDraft, type ResponseLimit, type SchemaValidationResult, type SelectOption, Sha1, type SignatureField, type SlotClass, type SlotState, type SubmitMeta, type SubmitResult, type SyncFormResult, type SyncedForm, type TextField, type UploadFileOptions, type UploadProgress, type UploadSession, type UploadStatus, type UploadTransport, type ValidationResult, type WhenBuilder, allFields, assembleForm, codeFormFromJsx, contentHash, countryByDialCode, countryByIso, countryByTimeZone, createBlock, createClient, createEmptyForm, createFormController, createId, defineForm, digitsOnly, flagEmoji, formatAnswer, formatNational, isAutoSubmitBlock, isBlockVisible, isCodeForm, isField, isFilloError, isPossiblePhone, needsExplicitSubmit, normalizeFormSchema, normalizeFormTheme, normalizeSettings, parsePhone, pipeBlock, prefillFromParams, provisionWorkspace, resolveSlotClass, resolveStrings, resolveText, responseScopeValue, schemaFromJsx, sha1Base64, shouldAutoSubmit, slotClass, syncCodeForm, toE164, validateField, validateFormSchema, validateResponse, visibleBlocks, visibleFields, when };
|
package/dist/index.js
CHANGED
|
@@ -77,6 +77,12 @@ function visibleBlocks(page, data) {
|
|
|
77
77
|
function allFields(form) {
|
|
78
78
|
return form.pages.flatMap((p) => p.blocks.filter(isField));
|
|
79
79
|
}
|
|
80
|
+
function responseScopeValue(settings, data) {
|
|
81
|
+
const field2 = settings.responseLimit?.scopeField;
|
|
82
|
+
if (!field2) return null;
|
|
83
|
+
const value = data[field2];
|
|
84
|
+
return typeof value === "string" || typeof value === "number" ? String(value) : null;
|
|
85
|
+
}
|
|
80
86
|
function visibleFields(form, data) {
|
|
81
87
|
const fields = allFields(form);
|
|
82
88
|
const visible = visibleFieldIds(fields, data);
|
|
@@ -594,7 +600,7 @@ var schemaShape = z2.object({
|
|
|
594
600
|
});
|
|
595
601
|
var MAX_SCHEMA_VERSION = 1;
|
|
596
602
|
var FILLO_SCHEMA_VERSION = 1;
|
|
597
|
-
var FILLO_SDK_VERSION = true ? "0.
|
|
603
|
+
var FILLO_SDK_VERSION = true ? "0.7.0" : "0.0.0-dev";
|
|
598
604
|
function str(value, max, fallback = "") {
|
|
599
605
|
return typeof value === "string" ? value.trim().slice(0, max) : fallback;
|
|
600
606
|
}
|
|
@@ -668,10 +674,25 @@ function normalizeUrl(value) {
|
|
|
668
674
|
return void 0;
|
|
669
675
|
}
|
|
670
676
|
}
|
|
677
|
+
function normalizeResponseLimit(value) {
|
|
678
|
+
if (!value || typeof value !== "object") return void 0;
|
|
679
|
+
const rec = value;
|
|
680
|
+
const by = rec.by === "browser" || rec.by === "field" || rec.by === "identify" ? rec.by : void 0;
|
|
681
|
+
if (!by) return void 0;
|
|
682
|
+
const field2 = by === "field" ? optionalStr(rec.field, 128) : void 0;
|
|
683
|
+
if (by === "field" && !field2) return void 0;
|
|
684
|
+
const scopeField = optionalStr(rec.scopeField, 128);
|
|
685
|
+
const onRepeat = rec.onRepeat === "update" && by === "identify" ? "update" : "keep";
|
|
686
|
+
return {
|
|
687
|
+
by,
|
|
688
|
+
...field2 ? { field: field2 } : {},
|
|
689
|
+
...scopeField ? { scopeField } : {},
|
|
690
|
+
onRepeat
|
|
691
|
+
};
|
|
692
|
+
}
|
|
671
693
|
function normalizeSettings(value) {
|
|
672
694
|
const rec = value && typeof value === "object" ? value : {};
|
|
673
695
|
const submitMode = rec.submitMode === "button" || rec.submitMode === "auto" ? rec.submitMode : void 0;
|
|
674
|
-
const submissionLimit = rec.submissionLimit === "once_per_visitor" || rec.submissionLimit === "multiple" ? rec.submissionLimit : void 0;
|
|
675
696
|
return {
|
|
676
697
|
submitMode,
|
|
677
698
|
submitLabel: optionalStr(rec.submitLabel, 100),
|
|
@@ -679,9 +700,14 @@ function normalizeSettings(value) {
|
|
|
679
700
|
successMessage: optionalStr(rec.successMessage, 5e3),
|
|
680
701
|
redirectUrl: normalizeUrl(rec.redirectUrl),
|
|
681
702
|
showProgress: bool(rec.showProgress),
|
|
682
|
-
|
|
703
|
+
responseLimit: normalizeResponseLimit(rec.responseLimit),
|
|
683
704
|
notifyEmail: z2.email().check(z2.maxLength(254)).safeParse(rec.notifyEmail).success ? rec.notifyEmail : void 0,
|
|
684
|
-
sendReceipt: bool(rec.sendReceipt)
|
|
705
|
+
sendReceipt: bool(rec.sendReceipt),
|
|
706
|
+
saveProgress: bool(rec.saveProgress),
|
|
707
|
+
draftAnswersVisible: bool(rec.draftAnswersVisible),
|
|
708
|
+
resumeEmails: bool(rec.resumeEmails),
|
|
709
|
+
resumeUrl: normalizeUrl(rec.resumeUrl),
|
|
710
|
+
draftDigest: bool(rec.draftDigest)
|
|
685
711
|
};
|
|
686
712
|
}
|
|
687
713
|
function cleanObject(obj) {
|
|
@@ -1049,8 +1075,7 @@ function createEmptyForm(title = "Untitled form") {
|
|
|
1049
1075
|
submitLabel: "Submit",
|
|
1050
1076
|
successTitle: "Thanks!",
|
|
1051
1077
|
successMessage: "Your response has been recorded.",
|
|
1052
|
-
showProgress: true
|
|
1053
|
-
submissionLimit: "multiple"
|
|
1078
|
+
showProgress: true
|
|
1054
1079
|
}
|
|
1055
1080
|
};
|
|
1056
1081
|
}
|
|
@@ -1413,7 +1438,24 @@ var FilloClient = class {
|
|
|
1413
1438
|
throw new FilloError(message, res.status, retryAfterSec(res));
|
|
1414
1439
|
}
|
|
1415
1440
|
const body = await res.json();
|
|
1416
|
-
return { ok: true, responseId: body.id, duplicate: body.duplicate };
|
|
1441
|
+
return { ok: true, responseId: body.id, duplicate: body.duplicate, updated: body.updated };
|
|
1442
|
+
}
|
|
1443
|
+
/**
|
|
1444
|
+
* The identified person's own living response on an upsert-mode form —
|
|
1445
|
+
* used to prefill their answers for editing. Requires a VERIFIED identity
|
|
1446
|
+
* ({id, hash}); anything else 404s (returned as null), because an
|
|
1447
|
+
* unverified read would hand anyone's answers to any page script.
|
|
1448
|
+
*/
|
|
1449
|
+
async fetchOwnResponse(formId, respondent) {
|
|
1450
|
+
try {
|
|
1451
|
+
return await this.json(
|
|
1452
|
+
`/forms/${encodeURIComponent(formId)}/respondent-response`,
|
|
1453
|
+
{ method: "POST", body: JSON.stringify({ respondent }) }
|
|
1454
|
+
);
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
if (isFilloError(err) && (err.status === 404 || err.status === 403)) return null;
|
|
1457
|
+
throw err;
|
|
1458
|
+
}
|
|
1417
1459
|
}
|
|
1418
1460
|
/**
|
|
1419
1461
|
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
@@ -1448,6 +1490,45 @@ var FilloClient = class {
|
|
|
1448
1490
|
} catch {
|
|
1449
1491
|
}
|
|
1450
1492
|
}
|
|
1493
|
+
/**
|
|
1494
|
+
* Create a saved-progress draft (forms with settings.saveProgress). Returns
|
|
1495
|
+
* the draft id and its ownership token — the token is shown only once.
|
|
1496
|
+
*/
|
|
1497
|
+
createDraft(formId, body) {
|
|
1498
|
+
return this.json(`/forms/${encodeURIComponent(formId)}/drafts`, {
|
|
1499
|
+
method: "POST",
|
|
1500
|
+
body: JSON.stringify(body)
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
/** Fetch a draft to restore. 404s once expired, consumed, or deleted. When
|
|
1504
|
+
* `adopt` is set (resume-link adoption), the server rotates the bearer and
|
|
1505
|
+
* returns the new `token` — the URL token becomes single-use. */
|
|
1506
|
+
getDraft(draftId, token, adopt) {
|
|
1507
|
+
return this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1508
|
+
headers: adopt ? { "X-Fillo-Draft-Token": token, "X-Fillo-Draft-Adopt": "1" } : { "X-Fillo-Draft-Token": token }
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Overwrite a draft's answers/page and slide its expiry. `keepalive` lets a
|
|
1513
|
+
* tab-close flush outlive the page; browsers reject keepalive bodies over
|
|
1514
|
+
* ~64KB, in which case this save is simply lost — the debounced autosaves
|
|
1515
|
+
* every few keystrokes are the real persistence, the flush is a bonus.
|
|
1516
|
+
*/
|
|
1517
|
+
async saveDraft(draftId, token, body, opts) {
|
|
1518
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1519
|
+
method: "PATCH",
|
|
1520
|
+
headers: { "X-Fillo-Draft-Token": token },
|
|
1521
|
+
body: JSON.stringify(body),
|
|
1522
|
+
...opts?.keepalive ? { keepalive: true } : {}
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
/** Discard a draft (the "Start over" path). */
|
|
1526
|
+
async deleteDraft(draftId, token) {
|
|
1527
|
+
await this.json(`/drafts/${encodeURIComponent(draftId)}`, {
|
|
1528
|
+
method: "DELETE",
|
|
1529
|
+
headers: { "X-Fillo-Draft-Token": token }
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1451
1532
|
/** Current state of an upload session — used to resume after interruption. */
|
|
1452
1533
|
getUploadSession(sessionId, token) {
|
|
1453
1534
|
return this.json(
|
|
@@ -1842,25 +1923,36 @@ function randomSubmissionKey() {
|
|
|
1842
1923
|
}
|
|
1843
1924
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
1844
1925
|
}
|
|
1845
|
-
function
|
|
1926
|
+
function readStoredJson(memory, storageKey2, memoryKey, isValid) {
|
|
1846
1927
|
const store = browserStorage();
|
|
1847
|
-
if (!store) return
|
|
1928
|
+
if (!store) return memory.get(memoryKey) ?? null;
|
|
1848
1929
|
try {
|
|
1849
|
-
const parsed = JSON.parse(store.getItem(
|
|
1850
|
-
if (parsed &&
|
|
1930
|
+
const parsed = JSON.parse(store.getItem(storageKey2) ?? "null");
|
|
1931
|
+
if (parsed && isValid(parsed)) return parsed;
|
|
1851
1932
|
} catch {
|
|
1852
1933
|
}
|
|
1853
1934
|
return null;
|
|
1854
1935
|
}
|
|
1855
|
-
function
|
|
1856
|
-
|
|
1936
|
+
function writeStoredJson(memory, storageKey2, memoryKey, value) {
|
|
1937
|
+
memory.set(memoryKey, value);
|
|
1857
1938
|
const store = browserStorage();
|
|
1858
1939
|
if (!store) return;
|
|
1859
1940
|
try {
|
|
1860
|
-
store.setItem(
|
|
1941
|
+
store.setItem(storageKey2, JSON.stringify(value));
|
|
1861
1942
|
} catch {
|
|
1862
1943
|
}
|
|
1863
1944
|
}
|
|
1945
|
+
function readStoredSubmission(formId) {
|
|
1946
|
+
return readStoredJson(
|
|
1947
|
+
memorySubmissionKeys,
|
|
1948
|
+
submissionStorageKey(formId),
|
|
1949
|
+
formId,
|
|
1950
|
+
(p) => typeof p.key === "string"
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
function writeStoredSubmission(formId, value) {
|
|
1954
|
+
writeStoredJson(memorySubmissionKeys, submissionStorageKey(formId), formId, value);
|
|
1955
|
+
}
|
|
1864
1956
|
function ensureSubmissionKey(formId) {
|
|
1865
1957
|
const existing = readStoredSubmission(formId);
|
|
1866
1958
|
if (existing?.key) return existing.key;
|
|
@@ -1876,10 +1968,40 @@ function markSubmitted(formId, responseId) {
|
|
|
1876
1968
|
responseId
|
|
1877
1969
|
});
|
|
1878
1970
|
}
|
|
1879
|
-
function
|
|
1880
|
-
|
|
1881
|
-
return
|
|
1971
|
+
function visitorSubmissionKeyId(form, formId, data) {
|
|
1972
|
+
const scope = responseScopeValue(form.settings, data);
|
|
1973
|
+
return scope === null ? formId : `${formId}::${scope}`;
|
|
1974
|
+
}
|
|
1975
|
+
function hasSubmittedOnce(form, formId, data) {
|
|
1976
|
+
if (form.settings.responseLimit?.by !== "browser" || !formId) return false;
|
|
1977
|
+
return Boolean(readStoredSubmission(visitorSubmissionKeyId(form, formId, data))?.submittedAt);
|
|
1978
|
+
}
|
|
1979
|
+
var memoryDraftRefs = /* @__PURE__ */ new Map();
|
|
1980
|
+
function draftStorageKey(formId) {
|
|
1981
|
+
return `fillo:draft:${formId}`;
|
|
1982
|
+
}
|
|
1983
|
+
function readDraftRef(formId) {
|
|
1984
|
+
return readStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, (p) => {
|
|
1985
|
+
const ref = p;
|
|
1986
|
+
return typeof ref.id === "string" && typeof ref.token === "string";
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
function writeDraftRef(formId, ref) {
|
|
1990
|
+
writeStoredJson(memoryDraftRefs, draftStorageKey(formId), formId, ref);
|
|
1882
1991
|
}
|
|
1992
|
+
function clearDraftRef(formId) {
|
|
1993
|
+
memoryDraftRefs.delete(formId);
|
|
1994
|
+
const store = browserStorage();
|
|
1995
|
+
if (!store) return;
|
|
1996
|
+
try {
|
|
1997
|
+
store.removeItem(draftStorageKey(formId));
|
|
1998
|
+
} catch {
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
function isDraftGone(err) {
|
|
2002
|
+
return isFilloError(err) && (err.status === 401 || err.status === 403 || err.status === 404);
|
|
2003
|
+
}
|
|
2004
|
+
var DRAFT_DEBOUNCE_MS = 1500;
|
|
1883
2005
|
function submitFailureMessage(err) {
|
|
1884
2006
|
if (isFilloError(err) && err.status && err.status > 0 && err.message) return err.message;
|
|
1885
2007
|
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.";
|
|
@@ -1891,15 +2013,13 @@ function createFormController(options2) {
|
|
|
1891
2013
|
let form = initial.schema;
|
|
1892
2014
|
let formId = options2.formId;
|
|
1893
2015
|
let client = options2.client;
|
|
2016
|
+
let respondent = options2.respondent;
|
|
1894
2017
|
let data = { ...options2.initialData ?? {} };
|
|
1895
2018
|
let errors = {};
|
|
1896
2019
|
let pageIndex = 0;
|
|
1897
2020
|
let status = "idle";
|
|
2021
|
+
let restoredSubmission = false;
|
|
1898
2022
|
queueMicrotask(() => {
|
|
1899
|
-
if (status === "idle" && hasSubmittedOnce(form, formId)) {
|
|
1900
|
-
status = "submitted";
|
|
1901
|
-
notify();
|
|
1902
|
-
}
|
|
1903
2023
|
if (typeof location !== "undefined" && location.search) {
|
|
1904
2024
|
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
1905
2025
|
let merged = null;
|
|
@@ -1914,6 +2034,31 @@ function createFormController(options2) {
|
|
|
1914
2034
|
notify();
|
|
1915
2035
|
}
|
|
1916
2036
|
}
|
|
2037
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2038
|
+
status = "submitted";
|
|
2039
|
+
restoredSubmission = true;
|
|
2040
|
+
notify();
|
|
2041
|
+
}
|
|
2042
|
+
let adopting = false;
|
|
2043
|
+
if (form.settings.saveProgress && formId && client && typeof location !== "undefined" && location.hash.includes("fillo-draft=")) {
|
|
2044
|
+
const match = /fillo-draft=([^&]+)/.exec(location.hash);
|
|
2045
|
+
const raw = match?.[1] ? decodeURIComponent(match[1]) : "";
|
|
2046
|
+
const dot = raw.indexOf(".");
|
|
2047
|
+
if (dot > 0) {
|
|
2048
|
+
adopting = true;
|
|
2049
|
+
const did = raw.slice(0, dot);
|
|
2050
|
+
const dtok = raw.slice(dot + 1);
|
|
2051
|
+
client.getDraft(did, dtok, true).then((d) => {
|
|
2052
|
+
writeDraftRef(formId, { id: did, token: d.token ?? dtok });
|
|
2053
|
+
try {
|
|
2054
|
+
history.replaceState(null, "", location.pathname + location.search);
|
|
2055
|
+
} catch {
|
|
2056
|
+
}
|
|
2057
|
+
}).catch(() => {
|
|
2058
|
+
}).finally(() => maybeRestoreDraft());
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
if (!adopting) maybeRestoreDraft();
|
|
1917
2062
|
});
|
|
1918
2063
|
let submitError;
|
|
1919
2064
|
const uploadingFields = /* @__PURE__ */ new Set();
|
|
@@ -1922,6 +2067,16 @@ function createFormController(options2) {
|
|
|
1922
2067
|
const idempotencyKey = randomSubmissionKey();
|
|
1923
2068
|
let sessionId = null;
|
|
1924
2069
|
let sessionStarted = false;
|
|
2070
|
+
let resumedDraft = false;
|
|
2071
|
+
let editingPrevious = false;
|
|
2072
|
+
let editAttempted = false;
|
|
2073
|
+
let draftRef = null;
|
|
2074
|
+
let draftTimer = null;
|
|
2075
|
+
let draftChain = Promise.resolve();
|
|
2076
|
+
let draftDirty = false;
|
|
2077
|
+
let draftDisabled = false;
|
|
2078
|
+
let draftRestoreAttempted = false;
|
|
2079
|
+
let draftAffinityAttempted = false;
|
|
1925
2080
|
function computeSnapshot() {
|
|
1926
2081
|
const pageCount = form.pages.length;
|
|
1927
2082
|
const clamped = Math.min(pageIndex, Math.max(pageCount - 1, 0));
|
|
@@ -1937,7 +2092,10 @@ function createFormController(options2) {
|
|
|
1937
2092
|
isFirstPage: clamped === 0,
|
|
1938
2093
|
isLastPage: clamped === pageCount - 1,
|
|
1939
2094
|
uploading: uploadingFields.size > 0,
|
|
1940
|
-
submitError
|
|
2095
|
+
submitError,
|
|
2096
|
+
restoredSubmission,
|
|
2097
|
+
resumedDraft,
|
|
2098
|
+
editingPrevious
|
|
1941
2099
|
};
|
|
1942
2100
|
}
|
|
1943
2101
|
let snapshot = computeSnapshot();
|
|
@@ -1963,6 +2121,153 @@ function createFormController(options2) {
|
|
|
1963
2121
|
}
|
|
1964
2122
|
return pageErrors;
|
|
1965
2123
|
}
|
|
2124
|
+
function draftsEnabled() {
|
|
2125
|
+
return Boolean(form.settings.saveProgress && client && formId && !draftDisabled);
|
|
2126
|
+
}
|
|
2127
|
+
function knownFieldIds() {
|
|
2128
|
+
const ids = /* @__PURE__ */ new Set();
|
|
2129
|
+
for (const page of form.pages) {
|
|
2130
|
+
for (const block of page.blocks) if (isField(block)) ids.add(block.id);
|
|
2131
|
+
}
|
|
2132
|
+
return ids;
|
|
2133
|
+
}
|
|
2134
|
+
function applyDraftSnapshot(draft) {
|
|
2135
|
+
if (status !== "idle" || sessionStarted) return;
|
|
2136
|
+
const ids = knownFieldIds();
|
|
2137
|
+
let merged = null;
|
|
2138
|
+
for (const [key, value] of Object.entries(draft.data ?? {})) {
|
|
2139
|
+
if (data[key] === void 0 && ids.has(key)) {
|
|
2140
|
+
merged = merged ?? { ...data };
|
|
2141
|
+
merged[key] = value;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
const target = typeof draft.page === "number" && Number.isFinite(draft.page) ? Math.max(0, Math.min(Math.floor(draft.page), form.pages.length - 1)) : 0;
|
|
2145
|
+
const movePage = target > 0 && pageIndex === 0;
|
|
2146
|
+
if (!merged && !movePage) return;
|
|
2147
|
+
if (merged) data = merged;
|
|
2148
|
+
if (movePage) pageIndex = target;
|
|
2149
|
+
resumedDraft = true;
|
|
2150
|
+
notify();
|
|
2151
|
+
}
|
|
2152
|
+
function maybeRestoreDraft() {
|
|
2153
|
+
if (status !== "idle") return;
|
|
2154
|
+
if (!draftsEnabled()) {
|
|
2155
|
+
maybePrefillOwnResponse();
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const fid = formId;
|
|
2159
|
+
const c = client;
|
|
2160
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2161
|
+
if (ref && !draftRestoreAttempted) {
|
|
2162
|
+
draftRestoreAttempted = true;
|
|
2163
|
+
draftRef = ref;
|
|
2164
|
+
void c.getDraft(ref.id, ref.token).then(applyDraftSnapshot).catch((err) => {
|
|
2165
|
+
if (isDraftGone(err)) {
|
|
2166
|
+
clearDraftRef(fid);
|
|
2167
|
+
draftRef = null;
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
return;
|
|
2171
|
+
}
|
|
2172
|
+
if (!ref && !draftAffinityAttempted && respondent?.hash) {
|
|
2173
|
+
draftAffinityAttempted = true;
|
|
2174
|
+
void c.createDraft(fid, { data: {}, page: 0, respondent }).then((created) => {
|
|
2175
|
+
if (draftRef) return;
|
|
2176
|
+
draftRef = { id: created.id, token: created.token };
|
|
2177
|
+
writeDraftRef(fid, draftRef);
|
|
2178
|
+
if (created.existing) {
|
|
2179
|
+
return c.getDraft(created.id, created.token).then(applyDraftSnapshot);
|
|
2180
|
+
}
|
|
2181
|
+
maybePrefillOwnResponse();
|
|
2182
|
+
}).catch((err) => {
|
|
2183
|
+
if (!(isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500)) {
|
|
2184
|
+
draftAffinityAttempted = false;
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
function maybePrefillOwnResponse() {
|
|
2190
|
+
if (editAttempted || form.settings.responseLimit?.onRepeat !== "update") return;
|
|
2191
|
+
if (!client || !formId || !respondent?.hash || status !== "idle") return;
|
|
2192
|
+
editAttempted = true;
|
|
2193
|
+
const c = client;
|
|
2194
|
+
void c.fetchOwnResponse(formId, respondent).then((own) => {
|
|
2195
|
+
if (!own || status !== "idle" || sessionStarted || resumedDraft) return;
|
|
2196
|
+
applyDraftSnapshot({ data: own.data, page: 0 });
|
|
2197
|
+
if (resumedDraft) {
|
|
2198
|
+
resumedDraft = false;
|
|
2199
|
+
editingPrevious = true;
|
|
2200
|
+
notify();
|
|
2201
|
+
}
|
|
2202
|
+
}).catch(() => {
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
function persistDraft(keepalive = false) {
|
|
2206
|
+
if (draftTimer) {
|
|
2207
|
+
clearTimeout(draftTimer);
|
|
2208
|
+
draftTimer = null;
|
|
2209
|
+
}
|
|
2210
|
+
if (!draftsEnabled() || status !== "idle" || !draftDirty) return;
|
|
2211
|
+
const fid = formId;
|
|
2212
|
+
const c = client;
|
|
2213
|
+
draftDirty = false;
|
|
2214
|
+
draftChain = draftChain.then(async () => {
|
|
2215
|
+
const payload = {
|
|
2216
|
+
data,
|
|
2217
|
+
page: Math.max(0, Math.min(pageIndex, form.pages.length - 1))
|
|
2218
|
+
};
|
|
2219
|
+
const ref = draftRef ?? readDraftRef(fid);
|
|
2220
|
+
try {
|
|
2221
|
+
if (ref) {
|
|
2222
|
+
try {
|
|
2223
|
+
await c.saveDraft(ref.id, ref.token, payload, keepalive ? { keepalive: true } : void 0);
|
|
2224
|
+
draftRef = ref;
|
|
2225
|
+
return;
|
|
2226
|
+
} catch (err) {
|
|
2227
|
+
if (!isDraftGone(err)) throw err;
|
|
2228
|
+
clearDraftRef(fid);
|
|
2229
|
+
draftRef = null;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const created = await c.createDraft(
|
|
2233
|
+
fid,
|
|
2234
|
+
respondent?.hash ? { ...payload, respondent } : payload
|
|
2235
|
+
);
|
|
2236
|
+
draftRef = { id: created.id, token: created.token };
|
|
2237
|
+
writeDraftRef(fid, draftRef);
|
|
2238
|
+
if (created.existing) draftDirty = true;
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
if (isFilloError(err) && err.status !== void 0 && err.status >= 400 && err.status < 500 && err.status !== 429) {
|
|
2241
|
+
draftDisabled = true;
|
|
2242
|
+
} else {
|
|
2243
|
+
draftDirty = true;
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
function scheduleDraftSave() {
|
|
2249
|
+
if (!draftsEnabled() || status !== "idle") return;
|
|
2250
|
+
draftDirty = true;
|
|
2251
|
+
if (draftTimer) clearTimeout(draftTimer);
|
|
2252
|
+
draftTimer = setTimeout(() => {
|
|
2253
|
+
draftTimer = null;
|
|
2254
|
+
persistDraft();
|
|
2255
|
+
}, DRAFT_DEBOUNCE_MS);
|
|
2256
|
+
}
|
|
2257
|
+
function checkpointDraft() {
|
|
2258
|
+
if (!draftsEnabled() || !sessionStarted && !draftRef) return;
|
|
2259
|
+
draftDirty = true;
|
|
2260
|
+
persistDraft();
|
|
2261
|
+
}
|
|
2262
|
+
function discardDraftState() {
|
|
2263
|
+
if (draftTimer) {
|
|
2264
|
+
clearTimeout(draftTimer);
|
|
2265
|
+
draftTimer = null;
|
|
2266
|
+
}
|
|
2267
|
+
draftDirty = false;
|
|
2268
|
+
if (formId) clearDraftRef(formId);
|
|
2269
|
+
draftRef = null;
|
|
2270
|
+
}
|
|
1966
2271
|
function setValue(fieldId, value) {
|
|
1967
2272
|
if (!sessionStarted && client && formId) {
|
|
1968
2273
|
sessionStarted = true;
|
|
@@ -1975,6 +2280,7 @@ function createFormController(options2) {
|
|
|
1975
2280
|
errors = Object.fromEntries(Object.entries(errors).filter(([k]) => k !== fieldId));
|
|
1976
2281
|
}
|
|
1977
2282
|
notify();
|
|
2283
|
+
scheduleDraftSave();
|
|
1978
2284
|
}
|
|
1979
2285
|
function setUploading(fieldId, busy) {
|
|
1980
2286
|
if (busy) uploadingFields.add(fieldId);
|
|
@@ -1995,11 +2301,13 @@ function createFormController(options2) {
|
|
|
1995
2301
|
pageIndex = Math.min(clamped + 1, pageCount - 1);
|
|
1996
2302
|
if (sessionId && client) client.reportProgress(sessionId, { furthestPage: pageIndex });
|
|
1997
2303
|
notify();
|
|
2304
|
+
checkpointDraft();
|
|
1998
2305
|
}
|
|
1999
2306
|
function back() {
|
|
2000
2307
|
errors = {};
|
|
2001
2308
|
pageIndex = Math.max(Math.min(pageIndex, form.pages.length - 1) - 1, 0);
|
|
2002
2309
|
notify();
|
|
2310
|
+
checkpointDraft();
|
|
2003
2311
|
}
|
|
2004
2312
|
async function submit() {
|
|
2005
2313
|
if (status === "submitting" || status === "submitted") return;
|
|
@@ -2027,8 +2335,9 @@ function createFormController(options2) {
|
|
|
2027
2335
|
}
|
|
2028
2336
|
}
|
|
2029
2337
|
if (client && formId) {
|
|
2030
|
-
const visitorKey = form.settings.
|
|
2338
|
+
const visitorKey = form.settings.responseLimit?.by === "browser" ? ensureSubmissionKey(visitorSubmissionKeyId(form, formId, result.data)) : void 0;
|
|
2031
2339
|
const submissionKey = visitorKey ?? idempotencyKey;
|
|
2340
|
+
const submitDraft = form.settings.saveProgress ? draftRef ?? readDraftRef(formId) : null;
|
|
2032
2341
|
status = "submitting";
|
|
2033
2342
|
submitError = void 0;
|
|
2034
2343
|
notify();
|
|
@@ -2038,7 +2347,9 @@ function createFormController(options2) {
|
|
|
2038
2347
|
hp: options2.getHoneypot?.() ?? "",
|
|
2039
2348
|
elapsedMs: Date.now() - mountedAt,
|
|
2040
2349
|
surface: options2.surface ?? "headless",
|
|
2041
|
-
submissionKey
|
|
2350
|
+
submissionKey,
|
|
2351
|
+
draft: submitDraft ? { id: submitDraft.id, token: submitDraft.token } : void 0,
|
|
2352
|
+
respondent
|
|
2042
2353
|
});
|
|
2043
2354
|
} catch (err) {
|
|
2044
2355
|
status = "idle";
|
|
@@ -2053,7 +2364,8 @@ function createFormController(options2) {
|
|
|
2053
2364
|
return;
|
|
2054
2365
|
}
|
|
2055
2366
|
if (sessionId) client.reportProgress(sessionId, { completed: true });
|
|
2056
|
-
if (visitorKey) markSubmitted(formId, res.responseId);
|
|
2367
|
+
if (visitorKey) markSubmitted(visitorSubmissionKeyId(form, formId, result.data), res.responseId);
|
|
2368
|
+
discardDraftState();
|
|
2057
2369
|
status = "submitted";
|
|
2058
2370
|
notify();
|
|
2059
2371
|
emitSubmitted(res.responseId, result.data);
|
|
@@ -2086,15 +2398,47 @@ function createFormController(options2) {
|
|
|
2086
2398
|
setContext(ctx) {
|
|
2087
2399
|
if ("formId" in ctx) formId = ctx.formId;
|
|
2088
2400
|
if ("client" in ctx) client = ctx.client;
|
|
2401
|
+
if ("respondent" in ctx) respondent = ctx.respondent;
|
|
2089
2402
|
if (ctx.form && ctx.form !== form) {
|
|
2090
2403
|
const next2 = normalizeFormSchema(ctx.form);
|
|
2091
2404
|
if (!next2.ok) throw new Error(`Invalid form schema: ${next2.error}`);
|
|
2092
2405
|
form = next2.schema;
|
|
2093
2406
|
}
|
|
2094
|
-
if (status === "idle" && hasSubmittedOnce(form, formId))
|
|
2407
|
+
if (status === "idle" && hasSubmittedOnce(form, formId, data)) {
|
|
2408
|
+
status = "submitted";
|
|
2409
|
+
restoredSubmission = true;
|
|
2410
|
+
}
|
|
2411
|
+
maybeRestoreDraft();
|
|
2412
|
+
notify();
|
|
2413
|
+
},
|
|
2414
|
+
flushDraft() {
|
|
2415
|
+
persistDraft(true);
|
|
2416
|
+
},
|
|
2417
|
+
resetDraft() {
|
|
2418
|
+
if (status === "submitting") return;
|
|
2419
|
+
const ref = draftRef ?? (formId ? readDraftRef(formId) : null);
|
|
2420
|
+
discardDraftState();
|
|
2421
|
+
if (ref && client) void client.deleteDraft(ref.id, ref.token).catch(() => {
|
|
2422
|
+
});
|
|
2423
|
+
data = { ...options2.initialData ?? {} };
|
|
2424
|
+
if (typeof location !== "undefined" && location.search) {
|
|
2425
|
+
const params = Object.fromEntries(new URLSearchParams(location.search));
|
|
2426
|
+
for (const [key, value] of Object.entries(prefillFromParams(form, params))) {
|
|
2427
|
+
if (data[key] === void 0) data[key] = value;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
errors = {};
|
|
2431
|
+
pageIndex = 0;
|
|
2432
|
+
submitError = void 0;
|
|
2433
|
+
resumedDraft = false;
|
|
2434
|
+
editingPrevious = false;
|
|
2095
2435
|
notify();
|
|
2096
2436
|
},
|
|
2097
2437
|
destroy() {
|
|
2438
|
+
if (draftTimer) {
|
|
2439
|
+
clearTimeout(draftTimer);
|
|
2440
|
+
draftTimer = null;
|
|
2441
|
+
}
|
|
2098
2442
|
listeners.clear();
|
|
2099
2443
|
}
|
|
2100
2444
|
};
|
|
@@ -2155,7 +2499,8 @@ var FILLO_SLOTS = [
|
|
|
2155
2499
|
"error",
|
|
2156
2500
|
"footer",
|
|
2157
2501
|
"button",
|
|
2158
|
-
"success"
|
|
2502
|
+
"success",
|
|
2503
|
+
"resume"
|
|
2159
2504
|
];
|
|
2160
2505
|
var FILLO_DATA_ATTRS = {
|
|
2161
2506
|
/** Slot name, on every slot element: `data-fillo="control"`. */
|
|
@@ -2224,7 +2569,10 @@ var DEFAULT_STRINGS = {
|
|
|
2224
2569
|
loadFailedNotFound: "Form not found \u2014 check the form id and that it's published.",
|
|
2225
2570
|
loadFailedNetwork: "Couldn't reach the server \u2014 check your connection or CORS.",
|
|
2226
2571
|
loadFailed: "This form could not be loaded.",
|
|
2227
|
-
renderFailed: "This form could not be rendered."
|
|
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"
|
|
2228
2576
|
};
|
|
2229
2577
|
function resolveStrings(overrides) {
|
|
2230
2578
|
return overrides ? { ...DEFAULT_STRINGS, ...overrides } : DEFAULT_STRINGS;
|
|
@@ -2691,6 +3039,7 @@ export {
|
|
|
2691
3039
|
needsExplicitSubmit,
|
|
2692
3040
|
normalizeFormSchema,
|
|
2693
3041
|
normalizeFormTheme,
|
|
3042
|
+
normalizeSettings,
|
|
2694
3043
|
parsePhone,
|
|
2695
3044
|
pipeBlock,
|
|
2696
3045
|
prefillFromParams,
|
|
@@ -2698,6 +3047,7 @@ export {
|
|
|
2698
3047
|
resolveSlotClass,
|
|
2699
3048
|
resolveStrings,
|
|
2700
3049
|
resolveText,
|
|
3050
|
+
responseScopeValue,
|
|
2701
3051
|
schemaFromJsx,
|
|
2702
3052
|
sha1Base64,
|
|
2703
3053
|
shouldAutoSubmit,
|