@usefillo/core 0.1.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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/index.d.ts +478 -0
- package/dist/index.js +976 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var CONTENT_KINDS = ["heading", "paragraph", "divider"];
|
|
3
|
+
function isField(block) {
|
|
4
|
+
return !CONTENT_KINDS.includes(block.kind);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// src/logic.ts
|
|
8
|
+
function isAnswered(value) {
|
|
9
|
+
if (value === null || value === void 0) return false;
|
|
10
|
+
if (typeof value === "string") return value.trim() !== "";
|
|
11
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
12
|
+
if (typeof value === "boolean") return value;
|
|
13
|
+
if (typeof value === "object") return Object.keys(value).length > 0;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
function evalCondition(cond, data) {
|
|
17
|
+
const value = data[cond.fieldId];
|
|
18
|
+
switch (cond.op) {
|
|
19
|
+
case "answered":
|
|
20
|
+
return isAnswered(value);
|
|
21
|
+
case "not_answered":
|
|
22
|
+
return !isAnswered(value);
|
|
23
|
+
case "eq":
|
|
24
|
+
if (Array.isArray(value)) return value.some((v) => v === cond.value);
|
|
25
|
+
return value === cond.value;
|
|
26
|
+
case "neq":
|
|
27
|
+
if (Array.isArray(value)) return !value.some((v) => v === cond.value);
|
|
28
|
+
return value !== cond.value;
|
|
29
|
+
case "contains":
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
return value.toLowerCase().includes(String(cond.value ?? "").toLowerCase());
|
|
32
|
+
if (Array.isArray(value)) return value.some((v) => v === cond.value);
|
|
33
|
+
return false;
|
|
34
|
+
case "gt":
|
|
35
|
+
return typeof value === "number" && value > Number(cond.value);
|
|
36
|
+
case "lt":
|
|
37
|
+
return typeof value === "number" && value < Number(cond.value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function isBlockVisible(block, data) {
|
|
41
|
+
if (!block.visibleIf || block.visibleIf.length === 0) return true;
|
|
42
|
+
return block.visibleIf.every((cond) => evalCondition(cond, data));
|
|
43
|
+
}
|
|
44
|
+
function visibleBlocks(page, data) {
|
|
45
|
+
return page.blocks.filter((b) => isBlockVisible(b, data));
|
|
46
|
+
}
|
|
47
|
+
function allFields(form) {
|
|
48
|
+
return form.pages.flatMap((p) => p.blocks.filter(isField));
|
|
49
|
+
}
|
|
50
|
+
function visibleFields(form, data) {
|
|
51
|
+
return form.pages.flatMap((p) => visibleBlocks(p, data).filter(isField));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/validation.ts
|
|
55
|
+
import { z } from "zod";
|
|
56
|
+
var fileValueSchema = z.object({
|
|
57
|
+
fileId: z.string().min(1),
|
|
58
|
+
name: z.string(),
|
|
59
|
+
size: z.number().nonnegative(),
|
|
60
|
+
mime: z.string(),
|
|
61
|
+
url: z.string().optional()
|
|
62
|
+
});
|
|
63
|
+
function isEmpty(value) {
|
|
64
|
+
if (value === null || value === void 0) return true;
|
|
65
|
+
if (typeof value === "string") return value.trim() === "";
|
|
66
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
67
|
+
if (typeof value === "boolean") return value === false;
|
|
68
|
+
if (typeof value === "object") return Object.keys(value).length === 0;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
function validateField(field, value) {
|
|
72
|
+
if (isEmpty(value)) {
|
|
73
|
+
return field.required ? "This field is required" : null;
|
|
74
|
+
}
|
|
75
|
+
switch (field.kind) {
|
|
76
|
+
case "short_text":
|
|
77
|
+
case "long_text": {
|
|
78
|
+
if (typeof value !== "string") return "Expected text";
|
|
79
|
+
if (field.maxLength && value.length > field.maxLength)
|
|
80
|
+
return `Must be at most ${field.maxLength} characters`;
|
|
81
|
+
const hardCap = field.kind === "long_text" ? 2e4 : 2e3;
|
|
82
|
+
if (!field.maxLength && value.length > hardCap) return "Answer is too long";
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
case "email": {
|
|
86
|
+
const r = z.string().email().safeParse(value);
|
|
87
|
+
return r.success ? null : "Enter a valid email address";
|
|
88
|
+
}
|
|
89
|
+
case "url": {
|
|
90
|
+
if (typeof value !== "string") return "Enter a valid URL (including https://)";
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(value);
|
|
93
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
94
|
+
return "Enter an http(s) URL";
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
return "Enter a valid URL (including https://)";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
case "phone": {
|
|
102
|
+
if (typeof value !== "string" || !/^[+\d][\d\s\-().]{4,}$/.test(value))
|
|
103
|
+
return "Enter a valid phone number";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
case "number": {
|
|
107
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
108
|
+
if (typeof n !== "number" || Number.isNaN(n)) return "Enter a number";
|
|
109
|
+
if (field.min !== void 0 && n < field.min) return `Must be at least ${field.min}`;
|
|
110
|
+
if (field.max !== void 0 && n > field.max) return `Must be at most ${field.max}`;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
case "select":
|
|
114
|
+
case "dropdown": {
|
|
115
|
+
if (typeof value !== "string" || value === "") return "Pick an option";
|
|
116
|
+
if (field.options.some((o) => o.id === value)) return null;
|
|
117
|
+
if (field.allowOther && value.length <= 500) return null;
|
|
118
|
+
return "Pick a valid option";
|
|
119
|
+
}
|
|
120
|
+
case "multi_select": {
|
|
121
|
+
if (!Array.isArray(value)) return "Pick at least one option";
|
|
122
|
+
const ids = new Set(field.options.map((o) => o.id));
|
|
123
|
+
const arr = value;
|
|
124
|
+
let others = 0;
|
|
125
|
+
for (const v of arr) {
|
|
126
|
+
if (ids.has(v)) continue;
|
|
127
|
+
if (field.allowOther !== true || typeof v !== "string" || v.length === 0 || v.length > 500)
|
|
128
|
+
return "Pick valid options";
|
|
129
|
+
if (++others > 1) return "Only one 'Other' answer is allowed";
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
case "checkbox": {
|
|
134
|
+
return typeof value === "boolean" ? null : "Invalid value";
|
|
135
|
+
}
|
|
136
|
+
case "rating": {
|
|
137
|
+
const max = field.max ?? 5;
|
|
138
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
139
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > max)
|
|
140
|
+
return `Pick a rating between 1 and ${max}`;
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
case "linear_scale": {
|
|
144
|
+
const min = field.min ?? 1;
|
|
145
|
+
const max = field.max ?? 10;
|
|
146
|
+
const n = typeof value === "string" ? Number(value) : value;
|
|
147
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < min || n > max)
|
|
148
|
+
return `Pick a value between ${min} and ${max}`;
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
case "ranking": {
|
|
152
|
+
if (!Array.isArray(value)) return "Drag the options into order";
|
|
153
|
+
const ids = field.options.map((o) => o.id).sort();
|
|
154
|
+
const got = [...value].sort();
|
|
155
|
+
if (ids.length !== got.length || ids.some((id, i) => id !== got[i]))
|
|
156
|
+
return "Rank every option";
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
case "matrix": {
|
|
160
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
161
|
+
return "Answer the grid";
|
|
162
|
+
const answers = value;
|
|
163
|
+
const columnIds = new Set(field.columns.map((c) => c.id));
|
|
164
|
+
const rowIds = new Set(field.rows.map((r) => r.id));
|
|
165
|
+
for (const [rowId, columnId] of Object.entries(answers)) {
|
|
166
|
+
if (!rowIds.has(rowId) || !columnIds.has(columnId)) return "Invalid grid answer";
|
|
167
|
+
}
|
|
168
|
+
if (field.required && Object.keys(answers).length < field.rows.length)
|
|
169
|
+
return "Answer every row";
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
case "signature": {
|
|
173
|
+
if (typeof value !== "string" || !value.startsWith("data:image/"))
|
|
174
|
+
return "Sign in the box";
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
case "hidden": {
|
|
178
|
+
return typeof value === "string" ? null : "Invalid value";
|
|
179
|
+
}
|
|
180
|
+
case "custom": {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
case "date": {
|
|
184
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
185
|
+
return "Enter a valid date";
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
case "file_upload": {
|
|
189
|
+
const r = z.array(fileValueSchema).safeParse(value);
|
|
190
|
+
if (!r.success) return "Upload did not complete";
|
|
191
|
+
const maxFiles = field.maxFiles ?? 1;
|
|
192
|
+
if (r.data.length > maxFiles)
|
|
193
|
+
return `At most ${maxFiles} file${maxFiles === 1 ? "" : "s"}`;
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function validateResponse(form, data) {
|
|
199
|
+
const fields = visibleFields(form, data);
|
|
200
|
+
const errors = {};
|
|
201
|
+
const cleaned = {};
|
|
202
|
+
for (const field of fields) {
|
|
203
|
+
const value = normalizeValue(field, data[field.id]);
|
|
204
|
+
const error = validateField(field, value);
|
|
205
|
+
if (error) {
|
|
206
|
+
errors[field.id] = error;
|
|
207
|
+
} else if (!isEmpty(value)) {
|
|
208
|
+
cleaned[field.id] = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { ok: Object.keys(errors).length === 0, errors, data: cleaned };
|
|
212
|
+
}
|
|
213
|
+
function normalizeValue(field, value) {
|
|
214
|
+
if (value === void 0 || value === null) return value;
|
|
215
|
+
if (field.kind === "number" && typeof value === "string" && value.trim() !== "") {
|
|
216
|
+
const n = Number(value);
|
|
217
|
+
return Number.isNaN(n) ? value : n;
|
|
218
|
+
}
|
|
219
|
+
if (field.kind === "rating" && typeof value === "string") {
|
|
220
|
+
const n = Number(value);
|
|
221
|
+
return Number.isNaN(n) ? value : n;
|
|
222
|
+
}
|
|
223
|
+
if (typeof value === "string") return value.trim();
|
|
224
|
+
return value;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/schema-validation.ts
|
|
228
|
+
import { z as z2 } from "zod";
|
|
229
|
+
var FIELD_KINDS = [
|
|
230
|
+
"short_text",
|
|
231
|
+
"long_text",
|
|
232
|
+
"email",
|
|
233
|
+
"url",
|
|
234
|
+
"phone",
|
|
235
|
+
"number",
|
|
236
|
+
"select",
|
|
237
|
+
"multi_select",
|
|
238
|
+
"dropdown",
|
|
239
|
+
"checkbox",
|
|
240
|
+
"rating",
|
|
241
|
+
"linear_scale",
|
|
242
|
+
"ranking",
|
|
243
|
+
"matrix",
|
|
244
|
+
"signature",
|
|
245
|
+
"date",
|
|
246
|
+
"file_upload",
|
|
247
|
+
"hidden",
|
|
248
|
+
"custom"
|
|
249
|
+
];
|
|
250
|
+
var CONTENT_KINDS2 = ["heading", "paragraph", "divider"];
|
|
251
|
+
var ALL_KINDS = [...FIELD_KINDS, ...CONTENT_KINDS2];
|
|
252
|
+
var idSchema = z2.string().min(1).max(128);
|
|
253
|
+
var optionSchema = z2.object({ id: idSchema, label: z2.string().max(1e3) }).passthrough();
|
|
254
|
+
var blockSchema = z2.object({
|
|
255
|
+
id: idSchema,
|
|
256
|
+
kind: z2.enum(ALL_KINDS),
|
|
257
|
+
label: z2.string().max(2e3).optional(),
|
|
258
|
+
options: z2.array(optionSchema).max(200).optional(),
|
|
259
|
+
rows: z2.array(optionSchema).max(200).optional(),
|
|
260
|
+
columns: z2.array(optionSchema).max(200).optional()
|
|
261
|
+
}).passthrough();
|
|
262
|
+
var pageSchema = z2.object({
|
|
263
|
+
id: idSchema,
|
|
264
|
+
title: z2.string().max(500).optional(),
|
|
265
|
+
blocks: z2.array(blockSchema).max(500)
|
|
266
|
+
});
|
|
267
|
+
var schemaShape = z2.object({
|
|
268
|
+
version: z2.literal(1),
|
|
269
|
+
title: z2.string().max(2e3).optional().default(""),
|
|
270
|
+
description: z2.string().max(5e3).optional(),
|
|
271
|
+
pages: z2.array(pageSchema).min(1).max(50),
|
|
272
|
+
settings: z2.record(z2.unknown()).optional().default({})
|
|
273
|
+
});
|
|
274
|
+
function validateFormSchema(input) {
|
|
275
|
+
const parsed = schemaShape.safeParse(input);
|
|
276
|
+
if (!parsed.success) {
|
|
277
|
+
const first = parsed.error.issues[0];
|
|
278
|
+
const path = first?.path.join(".");
|
|
279
|
+
return { ok: false, error: first ? `${path ? `${path}: ` : ""}${first.message}` : "Invalid schema" };
|
|
280
|
+
}
|
|
281
|
+
const seen = /* @__PURE__ */ new Set();
|
|
282
|
+
for (const page of parsed.data.pages) {
|
|
283
|
+
for (const block of page.blocks) {
|
|
284
|
+
if (seen.has(block.id)) {
|
|
285
|
+
return { ok: false, error: `Duplicate block id: ${block.id}` };
|
|
286
|
+
}
|
|
287
|
+
seen.add(block.id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, schema: parsed.data };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/format.ts
|
|
294
|
+
function assertNever(x) {
|
|
295
|
+
throw new Error(`Unhandled field kind: ${JSON.stringify(x)}`);
|
|
296
|
+
}
|
|
297
|
+
function formatAnswer(field, value) {
|
|
298
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
299
|
+
switch (field.kind) {
|
|
300
|
+
case "file_upload":
|
|
301
|
+
return value.map((f) => f.name).join(", ");
|
|
302
|
+
case "select":
|
|
303
|
+
case "dropdown":
|
|
304
|
+
return field.options.find((o) => o.id === value)?.label ?? String(value);
|
|
305
|
+
case "multi_select":
|
|
306
|
+
return value.map((id) => field.options.find((o) => o.id === id)?.label ?? id).join(", ");
|
|
307
|
+
case "ranking":
|
|
308
|
+
return value.map((id, i) => `${i + 1}. ${field.options.find((o) => o.id === id)?.label ?? id}`).join(", ");
|
|
309
|
+
case "matrix": {
|
|
310
|
+
const answers = value;
|
|
311
|
+
return field.rows.filter((row) => answers[row.id]).map((row) => `${row.label}: ${field.columns.find((c) => c.id === answers[row.id])?.label ?? ""}`).join(", ");
|
|
312
|
+
}
|
|
313
|
+
case "signature":
|
|
314
|
+
return typeof value === "string" && value.startsWith("data:image/") ? "\u270D signed" : "";
|
|
315
|
+
case "checkbox":
|
|
316
|
+
return value === true ? "Yes" : "No";
|
|
317
|
+
case "rating":
|
|
318
|
+
case "linear_scale":
|
|
319
|
+
case "number":
|
|
320
|
+
return typeof value === "number" ? String(value) : String(value);
|
|
321
|
+
case "short_text":
|
|
322
|
+
case "long_text":
|
|
323
|
+
case "email":
|
|
324
|
+
case "url":
|
|
325
|
+
case "phone":
|
|
326
|
+
case "date":
|
|
327
|
+
case "hidden":
|
|
328
|
+
return String(value);
|
|
329
|
+
case "custom":
|
|
330
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
331
|
+
default:
|
|
332
|
+
return assertNever(field);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/ids.ts
|
|
337
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
338
|
+
function createId(size = 12) {
|
|
339
|
+
const bytes = new Uint8Array(size);
|
|
340
|
+
if (typeof globalThis.crypto !== "undefined") {
|
|
341
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
342
|
+
} else {
|
|
343
|
+
for (let i = 0; i < size; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
344
|
+
}
|
|
345
|
+
let id = "";
|
|
346
|
+
for (let i = 0; i < size; i++) id += ALPHABET[bytes[i] % ALPHABET.length];
|
|
347
|
+
return id;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/fields.ts
|
|
351
|
+
var BLOCK_KIND_META = {
|
|
352
|
+
short_text: { label: "Short text", hint: "Single line answer" },
|
|
353
|
+
long_text: { label: "Long text", hint: "Multi-line answer" },
|
|
354
|
+
email: { label: "Email", hint: "Validated email address" },
|
|
355
|
+
url: { label: "Link", hint: "Validated URL" },
|
|
356
|
+
phone: { label: "Phone", hint: "Phone number" },
|
|
357
|
+
number: { label: "Number", hint: "Numeric answer with min/max" },
|
|
358
|
+
select: { label: "Single choice", hint: "Radio buttons" },
|
|
359
|
+
multi_select: { label: "Multiple choice", hint: "Checkboxes" },
|
|
360
|
+
dropdown: { label: "Dropdown", hint: "Select from a list" },
|
|
361
|
+
checkbox: { label: "Yes / No", hint: "Single checkbox" },
|
|
362
|
+
rating: { label: "Rating", hint: "1\u20135 stars" },
|
|
363
|
+
linear_scale: { label: "Linear scale", hint: "1\u201310 with end labels" },
|
|
364
|
+
ranking: { label: "Ranking", hint: "Order options by preference" },
|
|
365
|
+
matrix: { label: "Matrix", hint: "Grid of rows \xD7 choices" },
|
|
366
|
+
signature: { label: "Signature", hint: "Draw to sign" },
|
|
367
|
+
date: { label: "Date", hint: "Date picker" },
|
|
368
|
+
file_upload: { label: "File upload", hint: "Huge files, resumable" },
|
|
369
|
+
hidden: { label: "Hidden field", hint: "Filled from the URL" },
|
|
370
|
+
custom: { label: "Custom", hint: "Your own component (code only)" },
|
|
371
|
+
heading: { label: "Heading", hint: "Section title" },
|
|
372
|
+
paragraph: { label: "Paragraph", hint: "Explanatory text" },
|
|
373
|
+
divider: { label: "Divider", hint: "Visual break" }
|
|
374
|
+
};
|
|
375
|
+
function createBlock(kind) {
|
|
376
|
+
const id = createId(8);
|
|
377
|
+
switch (kind) {
|
|
378
|
+
case "heading":
|
|
379
|
+
return { id, kind, text: "Section heading" };
|
|
380
|
+
case "paragraph":
|
|
381
|
+
return { id, kind, text: "Add some context for your respondents." };
|
|
382
|
+
case "divider":
|
|
383
|
+
return { id, kind };
|
|
384
|
+
case "select":
|
|
385
|
+
case "multi_select":
|
|
386
|
+
case "dropdown":
|
|
387
|
+
return {
|
|
388
|
+
id,
|
|
389
|
+
kind,
|
|
390
|
+
label: BLOCK_KIND_META[kind].label,
|
|
391
|
+
options: [
|
|
392
|
+
{ id: createId(6), label: "Option 1" },
|
|
393
|
+
{ id: createId(6), label: "Option 2" }
|
|
394
|
+
]
|
|
395
|
+
};
|
|
396
|
+
case "rating":
|
|
397
|
+
return { id, kind, label: "How would you rate it?", max: 5 };
|
|
398
|
+
case "linear_scale":
|
|
399
|
+
return {
|
|
400
|
+
id,
|
|
401
|
+
kind,
|
|
402
|
+
label: "How likely are you to recommend us?",
|
|
403
|
+
min: 1,
|
|
404
|
+
max: 10,
|
|
405
|
+
minLabel: "Not likely",
|
|
406
|
+
maxLabel: "Very likely"
|
|
407
|
+
};
|
|
408
|
+
case "ranking":
|
|
409
|
+
return {
|
|
410
|
+
id,
|
|
411
|
+
kind,
|
|
412
|
+
label: "Rank these options",
|
|
413
|
+
options: [
|
|
414
|
+
{ id: createId(6), label: "Option 1" },
|
|
415
|
+
{ id: createId(6), label: "Option 2" },
|
|
416
|
+
{ id: createId(6), label: "Option 3" }
|
|
417
|
+
]
|
|
418
|
+
};
|
|
419
|
+
case "matrix":
|
|
420
|
+
return {
|
|
421
|
+
id,
|
|
422
|
+
kind,
|
|
423
|
+
label: "Rate each item",
|
|
424
|
+
rows: [
|
|
425
|
+
{ id: createId(6), label: "Quality" },
|
|
426
|
+
{ id: createId(6), label: "Price" }
|
|
427
|
+
],
|
|
428
|
+
columns: [
|
|
429
|
+
{ id: createId(6), label: "Poor" },
|
|
430
|
+
{ id: createId(6), label: "Fair" },
|
|
431
|
+
{ id: createId(6), label: "Good" }
|
|
432
|
+
]
|
|
433
|
+
};
|
|
434
|
+
case "signature":
|
|
435
|
+
return { id, kind, label: "Sign here" };
|
|
436
|
+
case "hidden":
|
|
437
|
+
return { id, kind, label: "Hidden field" };
|
|
438
|
+
case "custom":
|
|
439
|
+
return { id, kind, label: "Custom field", component: "" };
|
|
440
|
+
case "file_upload":
|
|
441
|
+
return { id, kind, label: "Upload a file", maxFiles: 1, maxFileSizeMb: 500 };
|
|
442
|
+
case "checkbox":
|
|
443
|
+
return { id, kind, label: "I agree" };
|
|
444
|
+
default:
|
|
445
|
+
return { id, kind, label: BLOCK_KIND_META[kind].label };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function createEmptyForm(title = "Untitled form") {
|
|
449
|
+
return {
|
|
450
|
+
version: 1,
|
|
451
|
+
title,
|
|
452
|
+
pages: [{ id: createId(8), blocks: [] }],
|
|
453
|
+
settings: {
|
|
454
|
+
submitLabel: "Submit",
|
|
455
|
+
successTitle: "Thanks!",
|
|
456
|
+
successMessage: "Your response has been recorded.",
|
|
457
|
+
showProgress: true
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/draft.ts
|
|
463
|
+
var DRAFT_KINDS = [
|
|
464
|
+
"short_text",
|
|
465
|
+
"long_text",
|
|
466
|
+
"email",
|
|
467
|
+
"url",
|
|
468
|
+
"phone",
|
|
469
|
+
"number",
|
|
470
|
+
"select",
|
|
471
|
+
"multi_select",
|
|
472
|
+
"dropdown",
|
|
473
|
+
"checkbox",
|
|
474
|
+
"rating",
|
|
475
|
+
"linear_scale",
|
|
476
|
+
"ranking",
|
|
477
|
+
"matrix",
|
|
478
|
+
"date",
|
|
479
|
+
"file_upload",
|
|
480
|
+
"heading",
|
|
481
|
+
"paragraph",
|
|
482
|
+
"divider"
|
|
483
|
+
];
|
|
484
|
+
function str(value, max) {
|
|
485
|
+
return typeof value === "string" ? value.trim().slice(0, max) : "";
|
|
486
|
+
}
|
|
487
|
+
function clampInt(value, lo, hi, fallback) {
|
|
488
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
489
|
+
const n = Math.round(value);
|
|
490
|
+
return n < lo ? lo : n > hi ? hi : n;
|
|
491
|
+
}
|
|
492
|
+
function toOptions(labels, fallback) {
|
|
493
|
+
const cleaned = (Array.isArray(labels) ? labels : []).map((l) => str(l, 200)).filter(Boolean);
|
|
494
|
+
if (cleaned.length < 2) return fallback;
|
|
495
|
+
return cleaned.slice(0, 100).map((label) => ({ id: createId(6), label }));
|
|
496
|
+
}
|
|
497
|
+
function assembleBlock(spec) {
|
|
498
|
+
const kind = spec.kind;
|
|
499
|
+
if (!DRAFT_KINDS.includes(kind)) return null;
|
|
500
|
+
const block = createBlock(kind);
|
|
501
|
+
const label = str(spec.label, 500);
|
|
502
|
+
if (kind === "heading" || kind === "paragraph") {
|
|
503
|
+
block.text = str(spec.text, 2e3) || label || block.text;
|
|
504
|
+
return block;
|
|
505
|
+
}
|
|
506
|
+
if (kind === "divider") return block;
|
|
507
|
+
if (label) block.label = label;
|
|
508
|
+
if (spec.required) block.required = true;
|
|
509
|
+
if (spec.placeholder) block.placeholder = str(spec.placeholder, 200);
|
|
510
|
+
switch (kind) {
|
|
511
|
+
case "select":
|
|
512
|
+
case "multi_select":
|
|
513
|
+
case "dropdown":
|
|
514
|
+
case "ranking":
|
|
515
|
+
block.options = toOptions(spec.options, block.options);
|
|
516
|
+
break;
|
|
517
|
+
case "rating":
|
|
518
|
+
block.max = clampInt(spec.max, 3, 10, 5);
|
|
519
|
+
break;
|
|
520
|
+
case "linear_scale":
|
|
521
|
+
block.min = spec.min === 0 ? 0 : 1;
|
|
522
|
+
block.max = clampInt(spec.max, 2, 10, 10);
|
|
523
|
+
if (spec.minLabel) block.minLabel = str(spec.minLabel, 100);
|
|
524
|
+
if (spec.maxLabel) block.maxLabel = str(spec.maxLabel, 100);
|
|
525
|
+
break;
|
|
526
|
+
case "number":
|
|
527
|
+
if (typeof spec.min === "number") block.min = spec.min;
|
|
528
|
+
if (typeof spec.max === "number") block.max = spec.max;
|
|
529
|
+
break;
|
|
530
|
+
case "matrix":
|
|
531
|
+
block.rows = toOptions(spec.rows, block.rows);
|
|
532
|
+
block.columns = toOptions(spec.columns, block.columns);
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
return block;
|
|
536
|
+
}
|
|
537
|
+
function assembleForm(spec) {
|
|
538
|
+
const form = createEmptyForm(str(spec.title, 200) || "Untitled form");
|
|
539
|
+
const description = str(spec.description, 1e3);
|
|
540
|
+
if (description) form.description = description;
|
|
541
|
+
const pages = (Array.isArray(spec.pages) ? spec.pages : []).slice(0, 20).map((page) => ({
|
|
542
|
+
id: createId(8),
|
|
543
|
+
title: str(page.title, 200) || void 0,
|
|
544
|
+
blocks: (Array.isArray(page.fields) ? page.fields : []).slice(0, 100).map(assembleBlock).filter((b) => b !== null)
|
|
545
|
+
})).filter((page) => page.blocks.length > 0);
|
|
546
|
+
if (pages.length > 0) form.pages = pages;
|
|
547
|
+
return form;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// src/client.ts
|
|
551
|
+
var FilloError = class extends Error {
|
|
552
|
+
constructor(message, status) {
|
|
553
|
+
super(message);
|
|
554
|
+
this.status = status;
|
|
555
|
+
this.name = "FilloError";
|
|
556
|
+
}
|
|
557
|
+
status;
|
|
558
|
+
};
|
|
559
|
+
var MAX_CHUNK_RETRIES = 3;
|
|
560
|
+
function makeReporter(totalBytes, options) {
|
|
561
|
+
return (uploadedBytes) => options.onProgress?.({
|
|
562
|
+
uploadedBytes,
|
|
563
|
+
totalBytes,
|
|
564
|
+
fraction: totalBytes === 0 ? 1 : uploadedBytes / totalBytes
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
var FilloClient = class {
|
|
568
|
+
/** Server origin this client targets, normalized (no trailing slash). */
|
|
569
|
+
baseUrl;
|
|
570
|
+
fetch;
|
|
571
|
+
/** Publishable workspace key, when configured. */
|
|
572
|
+
key;
|
|
573
|
+
constructor(options) {
|
|
574
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
575
|
+
this.key = options.key;
|
|
576
|
+
this.fetch = options.fetch ?? ((...args) => globalThis.fetch(...args));
|
|
577
|
+
}
|
|
578
|
+
url(path) {
|
|
579
|
+
return `${this.baseUrl}/api/v1${path}`;
|
|
580
|
+
}
|
|
581
|
+
async json(path, init) {
|
|
582
|
+
const res = await this.fetch(this.url(path), {
|
|
583
|
+
...init,
|
|
584
|
+
headers: { "Content-Type": "application/json", ...init?.headers }
|
|
585
|
+
});
|
|
586
|
+
if (!res.ok) {
|
|
587
|
+
const body = await res.text().catch(() => "");
|
|
588
|
+
throw new FilloError(body || `Request failed: ${res.status}`, res.status);
|
|
589
|
+
}
|
|
590
|
+
return await res.json();
|
|
591
|
+
}
|
|
592
|
+
/** Fetch a published form definition by id or slug. */
|
|
593
|
+
getForm(idOrSlug) {
|
|
594
|
+
return this.json(`/forms/${encodeURIComponent(idOrSlug)}`);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Upsert a code-defined form into the workspace identified by the client's
|
|
598
|
+
* publishable key. Returns the canonical form id used for submissions.
|
|
599
|
+
*/
|
|
600
|
+
syncForm(handle, schema, theme) {
|
|
601
|
+
if (!this.key) {
|
|
602
|
+
return Promise.reject(
|
|
603
|
+
new FilloError("createClient needs a `key` (pk_\u2026) to sync code-defined forms", 401)
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
return this.json("/forms/sync", {
|
|
607
|
+
method: "POST",
|
|
608
|
+
body: JSON.stringify({ key: this.key, id: handle, schema, theme })
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/** Submit a response. Returns per-field errors instead of throwing on validation failure. */
|
|
612
|
+
async submit(formId, data, meta) {
|
|
613
|
+
const res = await this.fetch(this.url(`/forms/${encodeURIComponent(formId)}/responses`), {
|
|
614
|
+
method: "POST",
|
|
615
|
+
headers: { "Content-Type": "application/json" },
|
|
616
|
+
body: JSON.stringify({ data, meta })
|
|
617
|
+
});
|
|
618
|
+
if (res.status === 422) {
|
|
619
|
+
const body2 = await res.json();
|
|
620
|
+
return { ok: false, errors: body2.errors };
|
|
621
|
+
}
|
|
622
|
+
if (!res.ok) {
|
|
623
|
+
throw new FilloError(`Submit failed: ${res.status}`, res.status);
|
|
624
|
+
}
|
|
625
|
+
const body = await res.json();
|
|
626
|
+
return { ok: true, responseId: body.id };
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Open a respondent session for funnel analysis. Fire-and-forget: returns the
|
|
630
|
+
* session id, or null if tracking is unavailable (never blocks the form).
|
|
631
|
+
*/
|
|
632
|
+
async startSession(formId, pageCount) {
|
|
633
|
+
try {
|
|
634
|
+
const res = await this.fetch(this.url(`/forms/${encodeURIComponent(formId)}/sessions`), {
|
|
635
|
+
method: "POST",
|
|
636
|
+
headers: { "Content-Type": "application/json" },
|
|
637
|
+
body: JSON.stringify({ pageCount })
|
|
638
|
+
});
|
|
639
|
+
if (!res.ok) return null;
|
|
640
|
+
const body = await res.json();
|
|
641
|
+
return body.id;
|
|
642
|
+
} catch {
|
|
643
|
+
return null;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Report progress on a session (furthest page reached, or completion). */
|
|
647
|
+
reportProgress(sessionId, data) {
|
|
648
|
+
try {
|
|
649
|
+
void this.fetch(this.url(`/sessions/${encodeURIComponent(sessionId)}/progress`), {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: { "Content-Type": "application/json" },
|
|
652
|
+
body: JSON.stringify(data),
|
|
653
|
+
keepalive: true
|
|
654
|
+
}).catch(() => {
|
|
655
|
+
});
|
|
656
|
+
} catch {
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/** Current state of an upload session — used to resume after interruption. */
|
|
660
|
+
getUploadSession(sessionId) {
|
|
661
|
+
return this.json(`/uploads/${encodeURIComponent(sessionId)}`);
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Resumable chunked upload. Creates (or resumes) a session, streams the file
|
|
665
|
+
* chunk by chunk with progress callbacks, and finalizes into a FileValue that
|
|
666
|
+
* goes into the response data. Survives flaky connections: each chunk is
|
|
667
|
+
* retried, and on repeated failure the true offset is re-queried so no byte
|
|
668
|
+
* is sent twice or skipped.
|
|
669
|
+
*
|
|
670
|
+
* Depending on the form's storage settings the server picks a transport:
|
|
671
|
+
* Fillo's own chunk protocol, or direct-to-Google-Drive resumable
|
|
672
|
+
* sessions — in which case the bytes go straight from this browser to the
|
|
673
|
+
* form owner's Drive and never touch the Fillo server.
|
|
674
|
+
*/
|
|
675
|
+
async uploadFile(formId, file, options) {
|
|
676
|
+
const name = file instanceof File ? file.name : "upload.bin";
|
|
677
|
+
const mime = file.type || "application/octet-stream";
|
|
678
|
+
let session;
|
|
679
|
+
if (options.sessionId) {
|
|
680
|
+
session = await this.getUploadSession(options.sessionId);
|
|
681
|
+
} else {
|
|
682
|
+
session = await this.json(
|
|
683
|
+
`/forms/${encodeURIComponent(formId)}/uploads`,
|
|
684
|
+
{
|
|
685
|
+
method: "POST",
|
|
686
|
+
body: JSON.stringify({
|
|
687
|
+
fieldId: options.fieldId,
|
|
688
|
+
fileName: name,
|
|
689
|
+
size: file.size,
|
|
690
|
+
mime
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (session.transport?.type === "gdrive") {
|
|
696
|
+
await this.driveUploadLoop(session, session.transport.uploadUrl, file, options);
|
|
697
|
+
} else if (session.transport?.type === "s3-put") {
|
|
698
|
+
await this.s3PutUpload(session.transport.uploadUrl, file, options);
|
|
699
|
+
} else {
|
|
700
|
+
await this.formworkUploadLoop(session, file, options);
|
|
701
|
+
}
|
|
702
|
+
const done = await this.json(
|
|
703
|
+
`/uploads/${encodeURIComponent(session.id)}/complete`,
|
|
704
|
+
{ method: "POST" }
|
|
705
|
+
);
|
|
706
|
+
if (!done.file) throw new FilloError("Upload completed but no file was returned");
|
|
707
|
+
return done.file;
|
|
708
|
+
}
|
|
709
|
+
async formworkUploadLoop(session, file, options) {
|
|
710
|
+
let offset = session.uploadedBytes;
|
|
711
|
+
const report = makeReporter(file.size, options);
|
|
712
|
+
report(offset);
|
|
713
|
+
while (offset < file.size) {
|
|
714
|
+
options.signal?.throwIfAborted();
|
|
715
|
+
const chunk = file.slice(offset, offset + session.chunkSize);
|
|
716
|
+
let attempt = 0;
|
|
717
|
+
for (; ; ) {
|
|
718
|
+
try {
|
|
719
|
+
const res = await this.fetch(
|
|
720
|
+
this.url(`/uploads/${encodeURIComponent(session.id)}/chunk`),
|
|
721
|
+
{
|
|
722
|
+
method: "PUT",
|
|
723
|
+
headers: {
|
|
724
|
+
"Content-Type": "application/octet-stream",
|
|
725
|
+
"X-Upload-Offset": String(offset)
|
|
726
|
+
},
|
|
727
|
+
body: chunk,
|
|
728
|
+
signal: options.signal
|
|
729
|
+
}
|
|
730
|
+
);
|
|
731
|
+
if (res.status === 409) {
|
|
732
|
+
const body2 = await res.json();
|
|
733
|
+
offset = body2.uploadedBytes;
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
736
|
+
if (!res.ok) throw new FilloError(`Chunk failed: ${res.status}`, res.status);
|
|
737
|
+
const body = await res.json();
|
|
738
|
+
offset = body.uploadedBytes;
|
|
739
|
+
break;
|
|
740
|
+
} catch (err) {
|
|
741
|
+
if (options.signal?.aborted) throw err;
|
|
742
|
+
attempt += 1;
|
|
743
|
+
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
744
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
745
|
+
const fresh = await this.getUploadSession(session.id);
|
|
746
|
+
offset = fresh.uploadedBytes;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
report(offset);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Google Drive resumable protocol: PUT chunks with Content-Range; Drive
|
|
754
|
+
* answers 308 + a Range header while incomplete, 200/201 when done. On
|
|
755
|
+
* repeated failure we re-sync through our session endpoint (the server
|
|
756
|
+
* queries Drive for the authoritative offset).
|
|
757
|
+
*/
|
|
758
|
+
async driveUploadLoop(session, uploadUrl, file, options) {
|
|
759
|
+
let offset = session.uploadedBytes;
|
|
760
|
+
const report = makeReporter(file.size, options);
|
|
761
|
+
report(offset);
|
|
762
|
+
while (offset < file.size) {
|
|
763
|
+
options.signal?.throwIfAborted();
|
|
764
|
+
const end = Math.min(offset + session.chunkSize, file.size);
|
|
765
|
+
const chunk = file.slice(offset, end);
|
|
766
|
+
let attempt = 0;
|
|
767
|
+
for (; ; ) {
|
|
768
|
+
try {
|
|
769
|
+
const res = await this.fetch(uploadUrl, {
|
|
770
|
+
method: "PUT",
|
|
771
|
+
headers: {
|
|
772
|
+
"Content-Range": `bytes ${offset}-${end - 1}/${file.size}`
|
|
773
|
+
},
|
|
774
|
+
body: chunk,
|
|
775
|
+
signal: options.signal
|
|
776
|
+
});
|
|
777
|
+
if (res.status === 308) {
|
|
778
|
+
const range = res.headers.get("Range") ?? res.headers.get("range");
|
|
779
|
+
const match = range?.match(/bytes=0-(\d+)/);
|
|
780
|
+
offset = match ? Number(match[1]) + 1 : 0;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
if (res.ok) {
|
|
784
|
+
offset = file.size;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
throw new FilloError(`Drive chunk failed: ${res.status}`, res.status);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
if (options.signal?.aborted) throw err;
|
|
790
|
+
attempt += 1;
|
|
791
|
+
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
792
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
793
|
+
const fresh = await this.getUploadSession(session.id);
|
|
794
|
+
offset = fresh.uploadedBytes;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
report(offset);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* S3-compatible single PUT to a presigned URL — bytes go straight to the
|
|
802
|
+
* bucket. Not resumable (S3 single PUT is atomic), but retried on failure.
|
|
803
|
+
*/
|
|
804
|
+
async s3PutUpload(uploadUrl, file, options) {
|
|
805
|
+
const report = makeReporter(file.size, options);
|
|
806
|
+
report(0);
|
|
807
|
+
let attempt = 0;
|
|
808
|
+
for (; ; ) {
|
|
809
|
+
try {
|
|
810
|
+
const res = await this.fetch(uploadUrl, {
|
|
811
|
+
method: "PUT",
|
|
812
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
813
|
+
body: file,
|
|
814
|
+
signal: options.signal
|
|
815
|
+
});
|
|
816
|
+
if (!res.ok) throw new FilloError(`S3 upload failed: ${res.status}`, res.status);
|
|
817
|
+
report(file.size);
|
|
818
|
+
return;
|
|
819
|
+
} catch (err) {
|
|
820
|
+
if (options.signal?.aborted) throw err;
|
|
821
|
+
attempt += 1;
|
|
822
|
+
if (attempt >= MAX_CHUNK_RETRIES) throw err;
|
|
823
|
+
await new Promise((r) => setTimeout(r, 500 * 2 ** attempt));
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
function createClient(options) {
|
|
829
|
+
return new FilloClient(options);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// src/define.ts
|
|
833
|
+
function defineForm(def) {
|
|
834
|
+
return {
|
|
835
|
+
id: def.id,
|
|
836
|
+
theme: def.theme,
|
|
837
|
+
schema: {
|
|
838
|
+
version: 1,
|
|
839
|
+
title: def.title ?? "",
|
|
840
|
+
description: def.description,
|
|
841
|
+
pages: def.pages,
|
|
842
|
+
settings: def.settings ?? {}
|
|
843
|
+
},
|
|
844
|
+
__formworkCodeForm: true
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function isCodeForm(form) {
|
|
848
|
+
return typeof form === "object" && form !== null && "__formworkCodeForm" in form;
|
|
849
|
+
}
|
|
850
|
+
var syncCache = /* @__PURE__ */ new Map();
|
|
851
|
+
function contentHash(input) {
|
|
852
|
+
let h = 5381;
|
|
853
|
+
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) | 0;
|
|
854
|
+
return (h >>> 0).toString(36);
|
|
855
|
+
}
|
|
856
|
+
function syncCodeForm(client, form) {
|
|
857
|
+
const content = JSON.stringify(form.schema) + JSON.stringify(form.theme ?? null);
|
|
858
|
+
const key = `${client.baseUrl}:${client.key}:${form.id}:${contentHash(content)}`;
|
|
859
|
+
let pending = syncCache.get(key);
|
|
860
|
+
if (!pending) {
|
|
861
|
+
pending = client.syncForm(form.id, form.schema, form.theme ?? null);
|
|
862
|
+
syncCache.set(key, pending);
|
|
863
|
+
pending.catch(() => syncCache.delete(key));
|
|
864
|
+
}
|
|
865
|
+
return pending;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/prefill.ts
|
|
869
|
+
function prefillFromParams(form, params) {
|
|
870
|
+
const data = {};
|
|
871
|
+
for (const field of allFields(form)) {
|
|
872
|
+
const raw = field.kind === "hidden" ? params[field.paramName ?? field.id] ?? field.defaultValue : params[field.id];
|
|
873
|
+
if (raw === void 0 || raw === "") continue;
|
|
874
|
+
switch (field.kind) {
|
|
875
|
+
case "number": {
|
|
876
|
+
const n = Number(raw);
|
|
877
|
+
if (Number.isNaN(n)) break;
|
|
878
|
+
if (field.min !== void 0 && n < field.min) break;
|
|
879
|
+
if (field.max !== void 0 && n > field.max) break;
|
|
880
|
+
data[field.id] = n;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
case "rating": {
|
|
884
|
+
const n = Number(raw);
|
|
885
|
+
if (Number.isInteger(n) && n >= 1 && n <= (field.max ?? 5)) data[field.id] = n;
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
case "linear_scale": {
|
|
889
|
+
const n = Number(raw);
|
|
890
|
+
const min = field.min ?? 1;
|
|
891
|
+
const max = field.max ?? 10;
|
|
892
|
+
if (Number.isInteger(n) && n >= min && n <= max) data[field.id] = n;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
case "checkbox":
|
|
896
|
+
data[field.id] = raw === "true" || raw === "1" || raw === "yes";
|
|
897
|
+
break;
|
|
898
|
+
case "select":
|
|
899
|
+
case "dropdown": {
|
|
900
|
+
if (field.options.some((o) => o.id === raw)) data[field.id] = raw;
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
case "multi_select": {
|
|
904
|
+
const ids = new Set(field.options.map((o) => o.id));
|
|
905
|
+
const picked = raw.split(",").filter((v) => ids.has(v));
|
|
906
|
+
if (picked.length > 0) data[field.id] = picked;
|
|
907
|
+
break;
|
|
908
|
+
}
|
|
909
|
+
case "file_upload":
|
|
910
|
+
case "ranking":
|
|
911
|
+
case "matrix":
|
|
912
|
+
case "signature":
|
|
913
|
+
case "custom":
|
|
914
|
+
break;
|
|
915
|
+
// not prefillable from a URL
|
|
916
|
+
default:
|
|
917
|
+
data[field.id] = raw;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return data;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/piping.ts
|
|
924
|
+
var TOKEN = /\{\{\s*([\w-]+)\s*\}\}/g;
|
|
925
|
+
function resolveText(text, data, form) {
|
|
926
|
+
if (!text.includes("{{")) return text;
|
|
927
|
+
const fields = allFields(form);
|
|
928
|
+
return text.replace(TOKEN, (_, id) => {
|
|
929
|
+
const field = fields.find((f) => f.id === id);
|
|
930
|
+
const value = data[id];
|
|
931
|
+
if (value === null || value === void 0 || value === "") return "";
|
|
932
|
+
const label = (optionId) => field && "options" in field ? field.options.find((o) => o.id === optionId)?.label ?? optionId : optionId;
|
|
933
|
+
if (Array.isArray(value)) return value.map((v) => label(String(v))).join(", ");
|
|
934
|
+
if (typeof value === "string") return label(value);
|
|
935
|
+
return String(value);
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
function pipeBlock(block, data, form) {
|
|
939
|
+
const pipe = (s) => s === void 0 ? s : resolveText(s, data, form);
|
|
940
|
+
switch (block.kind) {
|
|
941
|
+
case "heading":
|
|
942
|
+
case "paragraph":
|
|
943
|
+
return { ...block, text: resolveText(block.text, data, form) };
|
|
944
|
+
case "divider":
|
|
945
|
+
return block;
|
|
946
|
+
default:
|
|
947
|
+
return { ...block, label: resolveText(block.label, data, form), description: pipe(block.description) };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
export {
|
|
951
|
+
BLOCK_KIND_META,
|
|
952
|
+
CONTENT_KINDS,
|
|
953
|
+
DRAFT_KINDS,
|
|
954
|
+
FilloClient,
|
|
955
|
+
FilloError,
|
|
956
|
+
allFields,
|
|
957
|
+
assembleForm,
|
|
958
|
+
createBlock,
|
|
959
|
+
createClient,
|
|
960
|
+
createEmptyForm,
|
|
961
|
+
createId,
|
|
962
|
+
defineForm,
|
|
963
|
+
formatAnswer,
|
|
964
|
+
isBlockVisible,
|
|
965
|
+
isCodeForm,
|
|
966
|
+
isField,
|
|
967
|
+
pipeBlock,
|
|
968
|
+
prefillFromParams,
|
|
969
|
+
resolveText,
|
|
970
|
+
syncCodeForm,
|
|
971
|
+
validateField,
|
|
972
|
+
validateFormSchema,
|
|
973
|
+
validateResponse,
|
|
974
|
+
visibleBlocks,
|
|
975
|
+
visibleFields
|
|
976
|
+
};
|