@embeddables/forms 0.0.5 → 0.2.1
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 +116 -33
- package/dist/{form-9Tp91g6a.js → form-7wmC3G_q.js} +652 -221
- package/dist/form-7wmC3G_q.js.map +1 -0
- package/dist/{form-DzfCc5X2.cjs → form-CdIfHqNc.cjs} +675 -220
- package/dist/form-CdIfHqNc.cjs.map +1 -0
- package/dist/{index-qfBhdk5L.d.ts → form-x4EJcR9A.d.ts} +114 -122
- package/dist/form-x4EJcR9A.d.ts.map +1 -0
- package/dist/index-CmcgIxTH.d.ts +78 -0
- package/dist/index-CmcgIxTH.d.ts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/react.cjs +136 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +49 -7
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +136 -16
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +50 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +49 -0
- package/dist/server.js.map +1 -0
- package/package.json +8 -3
- package/dist/form-9Tp91g6a.js.map +0 -1
- package/dist/form-DzfCc5X2.cjs.map +0 -1
- package/dist/index-qfBhdk5L.d.ts.map +0 -1
|
@@ -31,119 +31,128 @@ var ValidatorError = class extends FormsError {
|
|
|
31
31
|
this.name = "ValidatorError";
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
core,
|
|
50
|
-
projectId,
|
|
51
|
-
appUserId,
|
|
52
|
-
publishableKey,
|
|
53
|
-
baseUrl: config.baseUrl ?? DEVELOPMENT_BASE_URL ?? "https://backend-worker.heysavvy.workers.dev",
|
|
54
|
-
fetch: fetchImpl,
|
|
55
|
-
timeoutMs: config.timeoutMs ?? 1e4
|
|
56
|
-
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/types/form-file.ts
|
|
36
|
+
function contentTypeMatchesAccept({ contentType, accept }) {
|
|
37
|
+
const normalized = contentType.split(";", 1)[0]?.trim().toLowerCase() || "";
|
|
38
|
+
return accept.some((entry) => {
|
|
39
|
+
const pattern = entry.trim().toLowerCase();
|
|
40
|
+
if (pattern.endsWith("/*")) return normalized.startsWith(pattern.slice(0, -1));
|
|
41
|
+
return normalized === pattern;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function isFormFileRef(value) {
|
|
45
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
46
|
+
const record = value;
|
|
47
|
+
return typeof record.file_id === "string" && typeof record.name === "string" && typeof record.content_type === "string" && typeof record.size === "number" && Number.isFinite(record.size) && (record.status === "uploading" || record.status === "done" || record.status === "error");
|
|
57
48
|
}
|
|
58
49
|
//#endregion
|
|
59
|
-
//#region src/
|
|
50
|
+
//#region src/core/validation.ts
|
|
51
|
+
/** Runtime counterpart to `FieldType`. Exhaustive by construction. */
|
|
52
|
+
const FIELD_TYPE_PREDICATES = {
|
|
53
|
+
text: (value) => typeof value === "string",
|
|
54
|
+
email: (value) => typeof value === "string",
|
|
55
|
+
number: (value) => typeof value === "number",
|
|
56
|
+
boolean: (value) => typeof value === "boolean",
|
|
57
|
+
select: (value) => typeof value === "string",
|
|
58
|
+
multiselect: (value) => Array.isArray(value),
|
|
59
|
+
json: () => true,
|
|
60
|
+
file: (value) => value === null || isFormFileRef(value)
|
|
61
|
+
};
|
|
62
|
+
const EMAIL_PATTERN = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?(?:\.[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?)*$/;
|
|
60
63
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
64
|
+
* Every message a single field's value earns. Empty means valid. Not generic:
|
|
65
|
+
* the per-field types live at the instance boundary, and the cast down to
|
|
66
|
+
* `JsonValue` happens once, in `initForm`.
|
|
63
67
|
*/
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
68
|
+
function validateValue({ field, value, values, pattern, validator }) {
|
|
69
|
+
const rules = field.validations;
|
|
70
|
+
const messages = [];
|
|
71
|
+
const isAbsent = value === void 0 || value === null;
|
|
72
|
+
const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
|
|
73
|
+
if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
|
|
74
|
+
if (isAbsent) return messages;
|
|
75
|
+
if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
|
|
76
|
+
if (typeof value === "string") {
|
|
77
|
+
if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
|
|
78
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
|
|
79
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
|
|
80
|
+
if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
|
|
84
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof value === "number") {
|
|
87
|
+
if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
|
|
88
|
+
if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
|
|
89
|
+
}
|
|
90
|
+
if (field.type === "select" || field.type === "multiselect") {
|
|
91
|
+
const options = field.options;
|
|
92
|
+
if (options) {
|
|
93
|
+
const allowed = new Set(options.map((option) => option.value));
|
|
94
|
+
if (field.type === "select" && typeof value === "string" && !allowed.has(value)) messages.push(`${field.label} must be one of the allowed options`);
|
|
95
|
+
if (field.type === "multiselect" && Array.isArray(value)) {
|
|
96
|
+
if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) messages.push(`${field.label} has values that are not allowed options`);
|
|
97
|
+
}
|
|
91
98
|
}
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
async function rpcCall(fn) {
|
|
95
|
-
const res = await fn();
|
|
96
|
-
if (!res.ok) {
|
|
97
|
-
const problem = await res.json().catch(() => null);
|
|
98
|
-
const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
|
|
99
|
-
throw new Error(message);
|
|
100
99
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
if (rules?.oneOf) {
|
|
101
|
+
const encoded = canonicalize(value);
|
|
102
|
+
if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
|
|
103
|
+
}
|
|
104
|
+
if (field.type === "file" && isFormFileRef(value)) {
|
|
105
|
+
if (value.status !== "done") messages.push(`${field.label} upload is not complete`);
|
|
106
|
+
if (rules?.maxSize !== void 0 && value.size > rules.maxSize) messages.push(`${field.label} must be at most ${rules.maxSize} bytes`);
|
|
107
|
+
if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
|
|
108
|
+
contentType: value.content_type,
|
|
109
|
+
accept: rules.accept
|
|
110
|
+
})) messages.push(`${field.label} must be one of the allowed file types`);
|
|
111
|
+
}
|
|
112
|
+
if (!validator || messages.length > 0) return messages;
|
|
113
|
+
return normalizeValidatorResult({
|
|
114
|
+
result: validator({
|
|
115
|
+
value,
|
|
116
|
+
values
|
|
117
|
+
}),
|
|
118
|
+
field
|
|
108
119
|
});
|
|
109
|
-
return {
|
|
110
|
-
savePartial({ formKey, values }) {
|
|
111
|
-
return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
|
|
112
|
-
project_id: config.projectId,
|
|
113
|
-
app_user_id: config.appUserId,
|
|
114
|
-
form_id: formKey,
|
|
115
|
-
data: values
|
|
116
|
-
} })).then(() => void 0);
|
|
117
|
-
},
|
|
118
|
-
saveFields: () => void 0,
|
|
119
|
-
saveSubmission({ formKey, values }) {
|
|
120
|
-
return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
|
|
121
|
-
project_id: config.projectId,
|
|
122
|
-
app_user_id: config.appUserId,
|
|
123
|
-
form_id: formKey,
|
|
124
|
-
values
|
|
125
|
-
} })).then(() => void 0);
|
|
126
|
-
},
|
|
127
|
-
recoverRegistryFields: () => ({})
|
|
128
|
-
};
|
|
129
120
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
121
|
+
function normalizeValidatorResult({ result, field }) {
|
|
122
|
+
if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
|
|
123
|
+
if (result === null || result === void 0) return [];
|
|
124
|
+
if (typeof result === "string") return [result];
|
|
125
|
+
if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
|
|
126
|
+
throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
|
|
127
|
+
}
|
|
128
|
+
function isThenable(value) {
|
|
129
|
+
return typeof value?.then === "function";
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* JSON encoding with object keys sorted at every depth, so two values compare
|
|
133
|
+
* by content rather than by insertion order.
|
|
134
|
+
*/
|
|
135
|
+
function canonicalize(value) {
|
|
136
|
+
const walk = (input) => {
|
|
137
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
138
|
+
if (input !== null && typeof input === "object") return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => [key, walk(item)]));
|
|
139
|
+
return input;
|
|
140
|
+
};
|
|
141
|
+
return JSON.stringify(walk(value));
|
|
134
142
|
}
|
|
135
143
|
//#endregion
|
|
136
144
|
//#region src/storage/storage.ts
|
|
137
145
|
/** Every form on the origin shares this one entry, indexed by form ID. */
|
|
138
146
|
const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
|
|
139
|
-
const FIELD_TYPES$
|
|
147
|
+
const FIELD_TYPES$2 = [
|
|
140
148
|
"text",
|
|
141
149
|
"email",
|
|
142
150
|
"number",
|
|
143
151
|
"boolean",
|
|
144
152
|
"select",
|
|
145
153
|
"multiselect",
|
|
146
|
-
"json"
|
|
154
|
+
"json",
|
|
155
|
+
"file"
|
|
147
156
|
];
|
|
148
157
|
const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
|
|
149
158
|
function readDocumentFromStorage({ storage }) {
|
|
@@ -151,7 +160,7 @@ function readDocumentFromStorage({ storage }) {
|
|
|
151
160
|
const raw = storage.getItem(FORM_DATA_KEY);
|
|
152
161
|
if (raw === null) return {};
|
|
153
162
|
const parsed = JSON.parse(raw);
|
|
154
|
-
if (!isObjectLike$
|
|
163
|
+
if (!isObjectLike$2(parsed)) return {};
|
|
155
164
|
return parsed;
|
|
156
165
|
} catch {
|
|
157
166
|
return {};
|
|
@@ -178,7 +187,7 @@ function resolveStorage({ storage }) {
|
|
|
178
187
|
candidate.getItem(FORM_DATA_KEY);
|
|
179
188
|
return candidate;
|
|
180
189
|
} catch {
|
|
181
|
-
return
|
|
190
|
+
return createMemoryFormsStorage();
|
|
182
191
|
}
|
|
183
192
|
}
|
|
184
193
|
/**
|
|
@@ -186,7 +195,7 @@ function resolveStorage({ storage }) {
|
|
|
186
195
|
* whose real storage started throwing mid-session.
|
|
187
196
|
*/
|
|
188
197
|
function degradeToMemory({ storage }) {
|
|
189
|
-
const shim =
|
|
198
|
+
const shim = createMemoryFormsStorage();
|
|
190
199
|
const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
|
|
191
200
|
shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
|
|
192
201
|
LIVE_DOCUMENTS.set(shim, snapshot);
|
|
@@ -194,18 +203,18 @@ function degradeToMemory({ storage }) {
|
|
|
194
203
|
}
|
|
195
204
|
function readFields({ storage, formKey, fieldDefinitions }) {
|
|
196
205
|
const bag = loadDocument({ storage })[formKey];
|
|
197
|
-
if (!isObjectLike$
|
|
206
|
+
if (!isObjectLike$2(bag)) return {};
|
|
198
207
|
const declared = new Set(fieldDefinitions.map((field) => field.key));
|
|
199
208
|
const values = {};
|
|
200
209
|
for (const [key, entry] of Object.entries(bag)) {
|
|
201
210
|
if (!declared.has(key)) continue;
|
|
202
|
-
if (isStoredField(entry)) values[key] = entry.value;
|
|
211
|
+
if (isStoredField$1(entry)) values[key] = entry.value;
|
|
203
212
|
}
|
|
204
213
|
return values;
|
|
205
214
|
}
|
|
206
215
|
function writeFields({ storage, formKey, fields, fieldDefinitions }) {
|
|
207
216
|
const current = loadDocument({ storage });
|
|
208
|
-
const currentForm = isObjectLike$
|
|
217
|
+
const currentForm = isObjectLike$2(current[formKey]) ? current[formKey] : {};
|
|
209
218
|
const declaredKeys = new Set(fieldDefinitions.map((field) => field.key));
|
|
210
219
|
const nextForm = {};
|
|
211
220
|
for (const [key, entry] of Object.entries(currentForm)) if (!declaredKeys.has(key)) nextForm[key] = entry;
|
|
@@ -250,7 +259,8 @@ function isSerializable({ value }) {
|
|
|
250
259
|
return false;
|
|
251
260
|
}
|
|
252
261
|
}
|
|
253
|
-
|
|
262
|
+
/** In-memory storage for server-side form init and tests that inject a store. */
|
|
263
|
+
function createMemoryFormsStorage() {
|
|
254
264
|
const entries = /* @__PURE__ */ new Map();
|
|
255
265
|
return {
|
|
256
266
|
getItem: (key) => entries.get(key) ?? null,
|
|
@@ -262,6 +272,47 @@ function createMemoryStorage() {
|
|
|
262
272
|
}
|
|
263
273
|
};
|
|
264
274
|
}
|
|
275
|
+
function isObjectLike$2(value) {
|
|
276
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
277
|
+
}
|
|
278
|
+
function isStoredField$1(value) {
|
|
279
|
+
if (!isObjectLike$2(value) || !Object.hasOwn(value, "value")) return false;
|
|
280
|
+
if (!isSerializable({ value: value["value"] })) return false;
|
|
281
|
+
if (typeof value["type"] !== "string" || !FIELD_TYPES$2.includes(value["type"]) || typeof value["label"] !== "string") return false;
|
|
282
|
+
if (value["registryId"] !== void 0 && typeof value["registryId"] !== "string") return false;
|
|
283
|
+
if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/storage/cookie-form-data.ts
|
|
288
|
+
const FIELD_TYPES$1 = [
|
|
289
|
+
"text",
|
|
290
|
+
"email",
|
|
291
|
+
"number",
|
|
292
|
+
"boolean",
|
|
293
|
+
"select",
|
|
294
|
+
"multiselect",
|
|
295
|
+
"json"
|
|
296
|
+
];
|
|
297
|
+
function formatFormCookieDataKey({ projectId, formId }) {
|
|
298
|
+
return `EMBEDDABLES--${projectId}--COOKIES-FORM-DATA--${formId}`;
|
|
299
|
+
}
|
|
300
|
+
function buildCookiePayloadFromBag({ bag, fieldDefinitions }) {
|
|
301
|
+
const payload = {};
|
|
302
|
+
for (const field of fieldDefinitions) {
|
|
303
|
+
if (field.includeInCookies !== true) continue;
|
|
304
|
+
const value = bag[field.key];
|
|
305
|
+
if (value === void 0) continue;
|
|
306
|
+
payload[field.key] = {
|
|
307
|
+
value,
|
|
308
|
+
type: field.type,
|
|
309
|
+
label: field.label,
|
|
310
|
+
...field.registryId === void 0 ? {} : { registryId: field.registryId },
|
|
311
|
+
...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return payload;
|
|
315
|
+
}
|
|
265
316
|
function isObjectLike$1(value) {
|
|
266
317
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
267
318
|
}
|
|
@@ -273,6 +324,265 @@ function isStoredField(value) {
|
|
|
273
324
|
if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
|
|
274
325
|
return true;
|
|
275
326
|
}
|
|
327
|
+
function entryMatchesFieldDefinition(entry, field) {
|
|
328
|
+
if (entry.type !== field.type || entry.label !== field.label) return false;
|
|
329
|
+
if ((entry.registryId ?? void 0) !== (field.registryId ?? void 0)) return false;
|
|
330
|
+
if ((entry.protocolFieldId ?? void 0) !== (field.protocolFieldId ?? void 0)) return false;
|
|
331
|
+
return FIELD_TYPE_PREDICATES[field.type](entry.value);
|
|
332
|
+
}
|
|
333
|
+
function serializeBrowserCookie(key, value) {
|
|
334
|
+
const locationRef = globalThis.location;
|
|
335
|
+
const parts = [
|
|
336
|
+
`${key}=${encodeURIComponent(value)}`,
|
|
337
|
+
"Path=/",
|
|
338
|
+
"SameSite=Lax"
|
|
339
|
+
];
|
|
340
|
+
if (locationRef?.protocol === "https:") parts.push("Secure");
|
|
341
|
+
return parts.join("; ");
|
|
342
|
+
}
|
|
343
|
+
function writeBrowserCookie(key, value) {
|
|
344
|
+
const documentRef = globalThis.document;
|
|
345
|
+
if (!documentRef) return;
|
|
346
|
+
try {
|
|
347
|
+
documentRef.cookie = serializeBrowserCookie(key, value);
|
|
348
|
+
} catch {}
|
|
349
|
+
}
|
|
350
|
+
function clearBrowserCookie(key) {
|
|
351
|
+
const documentRef = globalThis.document;
|
|
352
|
+
if (!documentRef) return;
|
|
353
|
+
try {
|
|
354
|
+
const locationRef = globalThis.location;
|
|
355
|
+
const parts = [
|
|
356
|
+
`${key}=`,
|
|
357
|
+
"Path=/",
|
|
358
|
+
"Max-Age=0",
|
|
359
|
+
"SameSite=Lax"
|
|
360
|
+
];
|
|
361
|
+
if (locationRef?.protocol === "https:") parts.push("Secure");
|
|
362
|
+
documentRef.cookie = parts.join("; ");
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
function writeFormCookieData({ projectId, formId, bag, fieldDefinitions }) {
|
|
366
|
+
try {
|
|
367
|
+
const payload = buildCookiePayloadFromBag({
|
|
368
|
+
bag,
|
|
369
|
+
fieldDefinitions
|
|
370
|
+
});
|
|
371
|
+
writeBrowserCookie(formatFormCookieDataKey({
|
|
372
|
+
projectId,
|
|
373
|
+
formId
|
|
374
|
+
}), JSON.stringify(payload));
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
function clearFormCookieData({ projectId, formId }) {
|
|
378
|
+
try {
|
|
379
|
+
clearBrowserCookie(formatFormCookieDataKey({
|
|
380
|
+
projectId,
|
|
381
|
+
formId
|
|
382
|
+
}));
|
|
383
|
+
} catch {}
|
|
384
|
+
}
|
|
385
|
+
function readFormCookieStoredForm({ projectId, formId, getCookie, fieldDefinitions }) {
|
|
386
|
+
try {
|
|
387
|
+
const raw = getCookie(formatFormCookieDataKey({
|
|
388
|
+
projectId,
|
|
389
|
+
formId
|
|
390
|
+
}));
|
|
391
|
+
if (raw === null || raw === "") return {};
|
|
392
|
+
const parsed = JSON.parse(decodeURIComponent(raw));
|
|
393
|
+
if (!isObjectLike$1(parsed)) return {};
|
|
394
|
+
const optedInFields = new Map(fieldDefinitions.filter((field) => field.includeInCookies === true).map((field) => [field.key, field]));
|
|
395
|
+
const storedForm = {};
|
|
396
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
397
|
+
const field = optedInFields.get(key);
|
|
398
|
+
if (!field || !isStoredField(entry)) continue;
|
|
399
|
+
if (!entryMatchesFieldDefinition(entry, field)) continue;
|
|
400
|
+
storedForm[key] = entry;
|
|
401
|
+
}
|
|
402
|
+
return storedForm;
|
|
403
|
+
} catch {
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function seedServerFormsStorageFromCookies({ storage, projectId, formIds, getFormSchema, getCookie }) {
|
|
408
|
+
const document = {};
|
|
409
|
+
for (const formId of formIds) {
|
|
410
|
+
const rawSchema = getFormSchema(formId);
|
|
411
|
+
if (typeof rawSchema !== "object" || rawSchema === null) continue;
|
|
412
|
+
const storedForm = readFormCookieStoredForm({
|
|
413
|
+
projectId,
|
|
414
|
+
formId,
|
|
415
|
+
getCookie,
|
|
416
|
+
fieldDefinitions: rawSchema.fields
|
|
417
|
+
});
|
|
418
|
+
if (Object.keys(storedForm).length === 0) continue;
|
|
419
|
+
document[formId] = storedForm;
|
|
420
|
+
}
|
|
421
|
+
if (Object.keys(document).length === 0) return;
|
|
422
|
+
storage.setItem(FORM_DATA_KEY, JSON.stringify(document));
|
|
423
|
+
}
|
|
424
|
+
const DEVELOPMENT_BASE_URL = void 0;
|
|
425
|
+
/**
|
|
426
|
+
* Returns null when persistence cannot be configured (missing publishable key
|
|
427
|
+
* or fetch). The SDK keeps the no-op default in that case.
|
|
428
|
+
*/
|
|
429
|
+
function resolvePersistenceConfig(config) {
|
|
430
|
+
const core = config.core;
|
|
431
|
+
const publishableKey = config.publishableKey ?? core.getPublishableKey();
|
|
432
|
+
if (!publishableKey || !isValidPublishableKey({ value: publishableKey })) return null;
|
|
433
|
+
const projectId = core.getProjectId();
|
|
434
|
+
const appUserId = core.getAppUserId();
|
|
435
|
+
if (!projectId || !appUserId) return null;
|
|
436
|
+
const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
|
|
437
|
+
if (!fetchImpl) return null;
|
|
438
|
+
return {
|
|
439
|
+
core,
|
|
440
|
+
projectId,
|
|
441
|
+
appUserId,
|
|
442
|
+
publishableKey,
|
|
443
|
+
baseUrl: config.baseUrl ?? DEVELOPMENT_BASE_URL ?? "https://backend-worker.heysavvy.workers.dev",
|
|
444
|
+
fetch: fetchImpl,
|
|
445
|
+
timeoutMs: config.timeoutMs ?? 1e4
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
//#endregion
|
|
449
|
+
//#region src/storage/persistence.ts
|
|
450
|
+
/**
|
|
451
|
+
* The default: does nothing, never throws, and recovers nothing. With this in
|
|
452
|
+
* place a form is pure local state, exactly as before the port existed.
|
|
453
|
+
*/
|
|
454
|
+
function createNoopPersistence() {
|
|
455
|
+
return {
|
|
456
|
+
savePartial: () => void 0,
|
|
457
|
+
saveFields: () => void 0,
|
|
458
|
+
saveSubmission: () => void 0,
|
|
459
|
+
recoverRegistryFields: () => ({})
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/storage/persistence-client.ts
|
|
464
|
+
const PUBLISHABLE_KEY_HEADER$1 = "x-publishable-key";
|
|
465
|
+
const hcWithType = (...args) => hc(...args);
|
|
466
|
+
function withTimeout(fetchImpl, timeoutMs) {
|
|
467
|
+
return async (input, init) => {
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
470
|
+
const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
471
|
+
try {
|
|
472
|
+
return await fetchImpl(input, {
|
|
473
|
+
...init,
|
|
474
|
+
signal: controller.signal
|
|
475
|
+
});
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
|
|
478
|
+
throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
|
|
479
|
+
} finally {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
async function rpcCall(fn) {
|
|
485
|
+
const res = await fn();
|
|
486
|
+
if (!res.ok) {
|
|
487
|
+
const problem = await res.json().catch(() => null);
|
|
488
|
+
const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
|
|
489
|
+
throw new Error(message);
|
|
490
|
+
}
|
|
491
|
+
return res.json();
|
|
492
|
+
}
|
|
493
|
+
function createApiPersistence(config) {
|
|
494
|
+
const root = config.baseUrl.replace(/\/+$/, "");
|
|
495
|
+
const rpc = hcWithType(`${root}/forms`, {
|
|
496
|
+
fetch: withTimeout(config.fetch, config.timeoutMs),
|
|
497
|
+
headers: { [PUBLISHABLE_KEY_HEADER$1]: config.publishableKey }
|
|
498
|
+
});
|
|
499
|
+
return {
|
|
500
|
+
savePartial({ formKey, values }) {
|
|
501
|
+
return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
|
|
502
|
+
project_id: config.projectId,
|
|
503
|
+
app_user_id: config.appUserId,
|
|
504
|
+
form_id: formKey,
|
|
505
|
+
data: values
|
|
506
|
+
} })).then(() => void 0);
|
|
507
|
+
},
|
|
508
|
+
saveFields: () => void 0,
|
|
509
|
+
saveSubmission({ formKey, values }) {
|
|
510
|
+
return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
|
|
511
|
+
project_id: config.projectId,
|
|
512
|
+
app_user_id: config.appUserId,
|
|
513
|
+
form_id: formKey,
|
|
514
|
+
values
|
|
515
|
+
} })).then(() => void 0);
|
|
516
|
+
},
|
|
517
|
+
recoverRegistryFields: () => ({})
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
function resolveDefaultPersistence(config) {
|
|
521
|
+
const resolved = resolvePersistenceConfig(config);
|
|
522
|
+
if (!resolved) return createNoopPersistence();
|
|
523
|
+
return createApiPersistence(resolved);
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/storage/upload-client.ts
|
|
527
|
+
const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
|
|
528
|
+
function mapUploadErrorMessage(problem, status) {
|
|
529
|
+
const code = problem?.code;
|
|
530
|
+
if (code === "validation.file_too_large") return problem?.detail ?? "Maximum upload size is 25 MiB.";
|
|
531
|
+
if (code === "validation.unsupported_content_type") return problem?.detail ?? "Unsupported file type.";
|
|
532
|
+
if (code === "validation.upload_failed") return problem?.detail ?? "Upload could not be completed.";
|
|
533
|
+
if (code === "service.form_uploads_not_configured") return problem?.detail ?? "File uploads are not configured for this project.";
|
|
534
|
+
if (code === "validation.failed") return problem?.detail ?? "Upload metadata is invalid.";
|
|
535
|
+
return problem?.detail ?? problem?.title ?? `File upload failed (${status}).`;
|
|
536
|
+
}
|
|
537
|
+
function uploadResultToFormFileRef(result) {
|
|
538
|
+
return {
|
|
539
|
+
file_id: result.file_id,
|
|
540
|
+
name: result.name,
|
|
541
|
+
content_type: result.content_type,
|
|
542
|
+
size: result.size,
|
|
543
|
+
status: "done",
|
|
544
|
+
uploaded_at: result.uploaded_at
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function toUploadFetchError(error, path, timeoutMs) {
|
|
548
|
+
if (error instanceof FormsError) return error;
|
|
549
|
+
if (error instanceof Error && error.name === "AbortError") return new FormsError(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
|
|
550
|
+
return new FormsError(`Request to ${path} failed to reach the API`, { cause: error });
|
|
551
|
+
}
|
|
552
|
+
function toUploadDecodeError(error, path) {
|
|
553
|
+
return new FormsError(`Upload response from ${path} could not be decoded`, { cause: error });
|
|
554
|
+
}
|
|
555
|
+
async function uploadFormFile(config, input) {
|
|
556
|
+
const url = `${config.baseUrl.replace(/\/+$/, "")}/forms/v1/public/uploads`;
|
|
557
|
+
const formData = new FormData();
|
|
558
|
+
formData.append("project_id", config.projectId);
|
|
559
|
+
formData.append("app_user_id", config.appUserId);
|
|
560
|
+
formData.append("form_id", input.formId);
|
|
561
|
+
formData.append("field_key", input.fieldKey);
|
|
562
|
+
const fileName = input.fileName ?? (typeof File !== "undefined" && input.file instanceof File ? input.file.name : "upload");
|
|
563
|
+
formData.append("file", input.file, fileName);
|
|
564
|
+
const controller = new AbortController();
|
|
565
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
566
|
+
try {
|
|
567
|
+
const res = await config.fetch(url, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey },
|
|
570
|
+
body: formData,
|
|
571
|
+
signal: controller.signal
|
|
572
|
+
});
|
|
573
|
+
clearTimeout(timer);
|
|
574
|
+
if (!res.ok) throw new FormsError(mapUploadErrorMessage(await res.json().catch(() => null), res.status));
|
|
575
|
+
try {
|
|
576
|
+
return uploadResultToFormFileRef(await res.json());
|
|
577
|
+
} catch (error) {
|
|
578
|
+
throw toUploadDecodeError(error, url);
|
|
579
|
+
}
|
|
580
|
+
} catch (error) {
|
|
581
|
+
throw toUploadFetchError(error, url, config.timeoutMs);
|
|
582
|
+
} finally {
|
|
583
|
+
clearTimeout(timer);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
276
586
|
//#endregion
|
|
277
587
|
//#region src/core/analytics.ts
|
|
278
588
|
/** The ingest bound on a `data:updated` entry's `value`. */
|
|
@@ -288,10 +598,11 @@ function mapFieldUpdatedType(type) {
|
|
|
288
598
|
* raw `field_value`; only the batch event caps and stringifies for ingest.
|
|
289
599
|
*/
|
|
290
600
|
function formatFieldValue({ value }) {
|
|
601
|
+
if (value === void 0) return "";
|
|
291
602
|
return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
|
|
292
603
|
}
|
|
293
604
|
/** One event carrying every key in one `.set()` call. */
|
|
294
|
-
function buildDataUpdatedEvent({ fields, patch }) {
|
|
605
|
+
function buildDataUpdatedEvent({ fields, patch, formId }) {
|
|
295
606
|
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
296
607
|
return {
|
|
297
608
|
event_name: "data:updated",
|
|
@@ -304,11 +615,12 @@ function buildDataUpdatedEvent({ fields, patch }) {
|
|
|
304
615
|
}),
|
|
305
616
|
label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
|
|
306
617
|
}];
|
|
307
|
-
}))
|
|
618
|
+
})),
|
|
619
|
+
form_id: formId
|
|
308
620
|
};
|
|
309
621
|
}
|
|
310
622
|
/** One `field:updated` per changed key, emitted alongside `data:updated`. */
|
|
311
|
-
function buildFieldUpdatedEvents({ fields, patch }) {
|
|
623
|
+
function buildFieldUpdatedEvents({ fields, patch, formId }) {
|
|
312
624
|
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
313
625
|
return Object.entries(patch).map(([key, value]) => {
|
|
314
626
|
const field = byKey.get(key);
|
|
@@ -316,14 +628,33 @@ function buildFieldUpdatedEvents({ fields, patch }) {
|
|
|
316
628
|
event_name: "field:updated",
|
|
317
629
|
field_key: key,
|
|
318
630
|
field_type: mapFieldUpdatedType(field?.type ?? "text"),
|
|
319
|
-
|
|
631
|
+
form_id: formId
|
|
320
632
|
};
|
|
633
|
+
if (value !== void 0) event.field_value = value;
|
|
321
634
|
if (field?.registryId !== void 0) event.registry_field_id = field.registryId;
|
|
322
635
|
if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
|
|
323
636
|
return event;
|
|
324
637
|
});
|
|
325
638
|
}
|
|
326
639
|
//#endregion
|
|
640
|
+
//#region src/core/options.ts
|
|
641
|
+
/**
|
|
642
|
+
* The array a `multiselect` patch should actually store, given what it added
|
|
643
|
+
* relative to the previous value. An added exclusive option wins outright; an
|
|
644
|
+
* added regular option evicts every exclusive value; a patch that only removed
|
|
645
|
+
* values passes through.
|
|
646
|
+
*/
|
|
647
|
+
function normalizeExclusiveSelection({ next, previous, options }) {
|
|
648
|
+
const exclusive = new Set(options.filter((option) => option.exclusive === true).map((option) => option.value));
|
|
649
|
+
if (exclusive.size === 0) return next;
|
|
650
|
+
const previousValues = new Set(previous);
|
|
651
|
+
const added = next.filter((entry) => !previousValues.has(entry));
|
|
652
|
+
const addedExclusive = added.filter((entry) => exclusive.has(entry));
|
|
653
|
+
if (addedExclusive.length > 0) return [addedExclusive[addedExclusive.length - 1]];
|
|
654
|
+
if (added.length > 0) return next.filter((entry) => !exclusive.has(entry));
|
|
655
|
+
return next;
|
|
656
|
+
}
|
|
657
|
+
//#endregion
|
|
327
658
|
//#region src/core/resolve.ts
|
|
328
659
|
const FIELD_TYPES = [
|
|
329
660
|
"text",
|
|
@@ -332,7 +663,8 @@ const FIELD_TYPES = [
|
|
|
332
663
|
"boolean",
|
|
333
664
|
"select",
|
|
334
665
|
"multiselect",
|
|
335
|
-
"json"
|
|
666
|
+
"json",
|
|
667
|
+
"file"
|
|
336
668
|
];
|
|
337
669
|
const VALIDATION_RULES = [
|
|
338
670
|
"required",
|
|
@@ -341,21 +673,31 @@ const VALIDATION_RULES = [
|
|
|
341
673
|
"min",
|
|
342
674
|
"max",
|
|
343
675
|
"pattern",
|
|
344
|
-
"patternFlags",
|
|
345
676
|
"oneOf",
|
|
677
|
+
"accept",
|
|
678
|
+
"maxSize",
|
|
346
679
|
"custom"
|
|
347
680
|
];
|
|
348
681
|
const NUMERIC_RULES = [
|
|
349
682
|
"minLength",
|
|
350
683
|
"maxLength",
|
|
351
684
|
"min",
|
|
352
|
-
"max"
|
|
685
|
+
"max",
|
|
686
|
+
"maxSize"
|
|
687
|
+
];
|
|
688
|
+
const FILE_ONLY_RULES = ["accept", "maxSize"];
|
|
689
|
+
const OPTION_KEYS = [
|
|
690
|
+
"value",
|
|
691
|
+
"label",
|
|
692
|
+
"exclusive"
|
|
353
693
|
];
|
|
694
|
+
/** The two field types whose choices are declared through a field-level `options`. */
|
|
695
|
+
const OPTION_FIELD_TYPES = ["select", "multiselect"];
|
|
354
696
|
/** The ingest `z.string().max(128)` bound on a `data:updated` key. */
|
|
355
697
|
const MAX_FIELD_KEY_LENGTH = 128;
|
|
356
698
|
/** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
|
|
357
699
|
const MAX_FORM_KEY_LENGTH = 128;
|
|
358
|
-
const
|
|
700
|
+
const INLINE_PATTERN_FLAGS = /^\(\?([dgimsuvy]+)\)/;
|
|
359
701
|
const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
|
|
360
702
|
function resolveForm({ schema }) {
|
|
361
703
|
const memoized = RESOLVED_SCHEMAS.get(schema);
|
|
@@ -411,25 +753,56 @@ function validateField({ field, path, seenKeys, patterns }) {
|
|
|
411
753
|
if (registryId !== void 0 && typeof registryId !== "string") throw new SchemaError(`${path}.registryId: must be a string`);
|
|
412
754
|
const protocolFieldId = field["protocolFieldId"];
|
|
413
755
|
if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
|
|
756
|
+
const includeInCookies = field["includeInCookies"];
|
|
757
|
+
if (includeInCookies !== void 0 && typeof includeInCookies !== "boolean") throw new SchemaError(`${path}.includeInCookies: must be a boolean`);
|
|
758
|
+
validateOptions({
|
|
759
|
+
field,
|
|
760
|
+
type,
|
|
761
|
+
path
|
|
762
|
+
});
|
|
414
763
|
const validations = field["validations"];
|
|
415
764
|
if (validations === void 0) return;
|
|
416
765
|
validateValidations({
|
|
417
766
|
validations,
|
|
767
|
+
type,
|
|
418
768
|
path: `${path}.validations`
|
|
419
769
|
});
|
|
420
770
|
if (!isObjectLike(validations)) return;
|
|
421
771
|
const pattern = validations["pattern"];
|
|
422
772
|
if (typeof pattern !== "string") return;
|
|
423
|
-
const flags = validations["patternFlags"];
|
|
424
773
|
patterns.set(key, compilePattern({
|
|
425
774
|
pattern,
|
|
426
|
-
flags: typeof flags === "string" ? flags : "",
|
|
427
775
|
path: `${path}.validations.pattern`
|
|
428
776
|
}));
|
|
429
777
|
}
|
|
430
|
-
function
|
|
778
|
+
function validateOptions({ field, type, path }) {
|
|
779
|
+
const options = field["options"];
|
|
780
|
+
if (options === void 0) return;
|
|
781
|
+
if (!OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.options: only a select or multiselect field may declare options`);
|
|
782
|
+
if (!(Array.isArray(options) && options.length > 0)) throw new SchemaError(`${path}.options: must be a non-empty array`);
|
|
783
|
+
const entries = options;
|
|
784
|
+
const seenValues = /* @__PURE__ */ new Set();
|
|
785
|
+
entries.forEach((option, index) => {
|
|
786
|
+
const at = `${path}.options[${index}]`;
|
|
787
|
+
if (!isObjectLike(option)) throw new SchemaError(`${at}: must be an object`);
|
|
788
|
+
for (const optionKey of Object.keys(option)) if (!OPTION_KEYS.includes(optionKey)) throw new SchemaError(`${at}.${optionKey}: unknown option key; expected one of ${OPTION_KEYS.join(", ")}`);
|
|
789
|
+
const value = option["value"];
|
|
790
|
+
if (typeof value !== "string" || value.trim() === "") throw new SchemaError(`${at}.value: must be a non-empty string`);
|
|
791
|
+
const label = option["label"];
|
|
792
|
+
if (label !== void 0 && typeof label !== "string") throw new SchemaError(`${at}.label: must be a string`);
|
|
793
|
+
const exclusive = option["exclusive"];
|
|
794
|
+
if (exclusive !== void 0 && typeof exclusive !== "boolean") throw new SchemaError(`${at}.exclusive: must be a boolean`);
|
|
795
|
+
if (exclusive === true && type === "select") throw new SchemaError(`${at}.exclusive: only a multiselect field may declare an exclusive option`);
|
|
796
|
+
if (seenValues.has(value)) throw new SchemaError(`${at}.value: duplicate option value "${value}" on this field`);
|
|
797
|
+
seenValues.add(value);
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
function validateValidations({ validations, type, path }) {
|
|
431
801
|
if (!isObjectLike(validations)) throw new SchemaError(`${path}: must be an object`);
|
|
432
|
-
for (const rule of Object.keys(validations))
|
|
802
|
+
for (const rule of Object.keys(validations)) {
|
|
803
|
+
if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
|
|
804
|
+
if (type !== "file" && FILE_ONLY_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: is only valid for file fields`);
|
|
805
|
+
}
|
|
433
806
|
const required = validations["required"];
|
|
434
807
|
if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
|
|
435
808
|
for (const rule of NUMERIC_RULES) {
|
|
@@ -443,17 +816,23 @@ function validateValidations({ validations, path }) {
|
|
|
443
816
|
const max = validations["max"];
|
|
444
817
|
if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
|
|
445
818
|
const oneOf = validations["oneOf"];
|
|
819
|
+
if (oneOf !== void 0 && OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.oneOf: not allowed on a ${type} field; declare choices through the field's options instead`);
|
|
446
820
|
if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
|
|
821
|
+
const accept = validations["accept"];
|
|
822
|
+
if (accept !== void 0 && !(Array.isArray(accept) && accept.length > 0 && accept.every((entry) => typeof entry === "string" && entry.trim() !== ""))) throw new SchemaError(`${path}.accept: must be a non-empty array of strings`);
|
|
823
|
+
const maxSize = validations["maxSize"];
|
|
824
|
+
if (maxSize !== void 0 && !(typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0)) throw new SchemaError(`${path}.maxSize: must be a positive finite number`);
|
|
447
825
|
const pattern = validations["pattern"];
|
|
448
826
|
if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
|
|
449
|
-
const patternFlags = validations["patternFlags"];
|
|
450
|
-
if (patternFlags !== void 0 && !(typeof patternFlags === "string" && VALID_PATTERN_FLAGS.test(patternFlags))) throw new SchemaError(`${path}.patternFlags: must contain only the characters dgimsuvy`);
|
|
451
827
|
const custom = validations["custom"];
|
|
452
828
|
if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
|
|
453
829
|
}
|
|
454
|
-
function compilePattern({ pattern,
|
|
830
|
+
function compilePattern({ pattern, path }) {
|
|
831
|
+
const prefix = INLINE_PATTERN_FLAGS.exec(pattern);
|
|
832
|
+
const source = prefix ? pattern.slice(prefix[0].length) : pattern;
|
|
833
|
+
const flags = prefix?.[1] ?? "";
|
|
455
834
|
try {
|
|
456
|
-
return new RegExp(
|
|
835
|
+
return new RegExp(source, flags.replace(/[gy]/g, ""));
|
|
457
836
|
} catch (error) {
|
|
458
837
|
throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
|
|
459
838
|
}
|
|
@@ -576,81 +955,6 @@ function errorMessage(error) {
|
|
|
576
955
|
return error instanceof Error ? error.message : String(error);
|
|
577
956
|
}
|
|
578
957
|
//#endregion
|
|
579
|
-
//#region src/core/validation.ts
|
|
580
|
-
/** Runtime counterpart to `FieldType`. Exhaustive by construction. */
|
|
581
|
-
const FIELD_TYPE_PREDICATES = {
|
|
582
|
-
text: (value) => typeof value === "string",
|
|
583
|
-
email: (value) => typeof value === "string",
|
|
584
|
-
number: (value) => typeof value === "number",
|
|
585
|
-
boolean: (value) => typeof value === "boolean",
|
|
586
|
-
select: (value) => typeof value === "string",
|
|
587
|
-
multiselect: (value) => Array.isArray(value),
|
|
588
|
-
json: () => true
|
|
589
|
-
};
|
|
590
|
-
const EMAIL_PATTERN = /^[\w.!#$%&'*+/=?^`{|}~-]+@[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?(?:\.[a-zA-Z\d](?:[a-zA-Z\d-]{0,61}[a-zA-Z\d])?)*$/;
|
|
591
|
-
/**
|
|
592
|
-
* Every message a single field's value earns. Empty means valid. Not generic:
|
|
593
|
-
* the per-field types live at the instance boundary, and the cast down to
|
|
594
|
-
* `JsonValue` happens once, in `initForm`.
|
|
595
|
-
*/
|
|
596
|
-
function validateValue({ field, value, values, pattern, validator }) {
|
|
597
|
-
const rules = field.validations;
|
|
598
|
-
const messages = [];
|
|
599
|
-
const isAbsent = value === void 0 || value === null;
|
|
600
|
-
const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
|
|
601
|
-
if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
|
|
602
|
-
if (isAbsent) return messages;
|
|
603
|
-
if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
|
|
604
|
-
if (typeof value === "string") {
|
|
605
|
-
if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
|
|
606
|
-
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
|
|
607
|
-
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
|
|
608
|
-
if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
|
|
609
|
-
}
|
|
610
|
-
if (Array.isArray(value)) {
|
|
611
|
-
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
|
|
612
|
-
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
|
|
613
|
-
}
|
|
614
|
-
if (typeof value === "number") {
|
|
615
|
-
if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
|
|
616
|
-
if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
|
|
617
|
-
}
|
|
618
|
-
if (rules?.oneOf) {
|
|
619
|
-
const encoded = canonicalize(value);
|
|
620
|
-
if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
|
|
621
|
-
}
|
|
622
|
-
if (!validator || messages.length > 0) return messages;
|
|
623
|
-
return normalizeValidatorResult({
|
|
624
|
-
result: validator({
|
|
625
|
-
value,
|
|
626
|
-
values
|
|
627
|
-
}),
|
|
628
|
-
field
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
function normalizeValidatorResult({ result, field }) {
|
|
632
|
-
if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
|
|
633
|
-
if (result === null || result === void 0) return [];
|
|
634
|
-
if (typeof result === "string") return [result];
|
|
635
|
-
if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
|
|
636
|
-
throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
|
|
637
|
-
}
|
|
638
|
-
function isThenable(value) {
|
|
639
|
-
return typeof value?.then === "function";
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* JSON encoding with object keys sorted at every depth, so two values compare
|
|
643
|
-
* by content rather than by insertion order.
|
|
644
|
-
*/
|
|
645
|
-
function canonicalize(value) {
|
|
646
|
-
const walk = (input) => {
|
|
647
|
-
if (Array.isArray(input)) return input.map(walk);
|
|
648
|
-
if (input !== null && typeof input === "object") return Object.fromEntries(Object.entries(input).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, item]) => [key, walk(item)]));
|
|
649
|
-
return input;
|
|
650
|
-
};
|
|
651
|
-
return JSON.stringify(walk(value));
|
|
652
|
-
}
|
|
653
|
-
//#endregion
|
|
654
958
|
//#region src/core/form.ts
|
|
655
959
|
const REQUIRED_CORE_METHODS = [
|
|
656
960
|
"getAppUserId",
|
|
@@ -685,41 +989,117 @@ function mergeCustomValidations({ schema, customValidations }) {
|
|
|
685
989
|
};
|
|
686
990
|
}
|
|
687
991
|
/**
|
|
688
|
-
* Validates the core instance and
|
|
689
|
-
* whose `getForm` builds each form by id.
|
|
992
|
+
* Validates the core instance and registered form schemas once, then returns a
|
|
993
|
+
* client whose `getForm` builds each form by id.
|
|
690
994
|
*/
|
|
691
995
|
function initForms(options) {
|
|
692
996
|
return createFormsClient(options);
|
|
693
997
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
998
|
+
const REQUIRED_CORE_FORM_METHODS = ["getFormIds", "getFormSchema"];
|
|
999
|
+
function hasCoreFormSchemaMethods(value) {
|
|
1000
|
+
return REQUIRED_CORE_FORM_METHODS.every((method) => typeof value[method] === "function");
|
|
1001
|
+
}
|
|
1002
|
+
function toFormSchema(value, formId) {
|
|
1003
|
+
if (typeof value !== "object" || value === null) throw new SchemaError(`Registered form "${formId}": schema must be an object.`);
|
|
1004
|
+
const schema = value;
|
|
1005
|
+
if (typeof schema.id !== "string" || schema.id === "") throw new SchemaError(`Registered form "${formId}": schema.id must be a non-empty string.`);
|
|
1006
|
+
if (schema.id !== formId) throw new SchemaError(`Registered form "${formId}": schema id must match map key.`);
|
|
1007
|
+
return schema;
|
|
1008
|
+
}
|
|
1009
|
+
function indexOverrideEntries({ entries, label, valueKey }) {
|
|
1010
|
+
if (entries === void 0) return /* @__PURE__ */ new Map();
|
|
1011
|
+
const byFormId = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (let index = 0; index < entries.length; index++) {
|
|
1013
|
+
const entry = entries[index];
|
|
1014
|
+
if (typeof entry !== "object" || entry === null) throw new SchemaError(`${label}[${index}]: must be an object.`);
|
|
1015
|
+
const { formId } = entry;
|
|
1016
|
+
if (typeof formId !== "string" || formId === "") throw new SchemaError(`${label}[${index}].formId: must be a non-empty string.`);
|
|
1017
|
+
const value = entry[valueKey];
|
|
1018
|
+
if (value === void 0) throw new SchemaError(`${label}[${index}].${String(valueKey)}: is required.`);
|
|
1019
|
+
if (byFormId.has(formId)) throw new SchemaError(`${label}: duplicate form id "${formId}".`);
|
|
1020
|
+
byFormId.set(formId, value);
|
|
1021
|
+
}
|
|
1022
|
+
return byFormId;
|
|
1023
|
+
}
|
|
1024
|
+
function resolveInitConfig({ core, customValidations, serverFormData }) {
|
|
1025
|
+
if (!hasCoreFormSchemaMethods(core)) throw new FormsError("initForms requires a Core instance with registered form schemas.");
|
|
1026
|
+
const formIds = core.getFormIds();
|
|
1027
|
+
if (formIds.length === 0) throw new FormsError("initForms requires at least one form schema registered on Core.");
|
|
1028
|
+
const customByFormId = indexOverrideEntries({
|
|
1029
|
+
entries: customValidations,
|
|
1030
|
+
label: "customValidations",
|
|
1031
|
+
valueKey: "customValidations"
|
|
1032
|
+
});
|
|
1033
|
+
const serverDataByFormId = indexOverrideEntries({
|
|
1034
|
+
entries: serverFormData,
|
|
1035
|
+
label: "serverFormData",
|
|
1036
|
+
valueKey: "serverFormData"
|
|
1037
|
+
});
|
|
1038
|
+
const registeredIds = new Set(formIds);
|
|
1039
|
+
for (const formId of customByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`customValidations: unknown form id "${formId}".`);
|
|
1040
|
+
for (const formId of serverDataByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`serverFormData: unknown form id "${formId}".`);
|
|
1041
|
+
const registry = /* @__PURE__ */ new Map();
|
|
1042
|
+
for (const formId of formIds) {
|
|
1043
|
+
const schema = toFormSchema(core.getFormSchema(formId), formId);
|
|
1044
|
+
const entryCustomValidations = customByFormId.get(formId);
|
|
1045
|
+
const entryServerFormData = serverDataByFormId.get(formId);
|
|
1046
|
+
mergeCustomValidations({
|
|
1047
|
+
schema,
|
|
1048
|
+
customValidations: entryCustomValidations
|
|
1049
|
+
});
|
|
1050
|
+
registry.set(formId, {
|
|
1051
|
+
schema,
|
|
1052
|
+
customValidations: entryCustomValidations,
|
|
1053
|
+
serverFormData: entryServerFormData
|
|
1054
|
+
});
|
|
700
1055
|
}
|
|
1056
|
+
return registry;
|
|
1057
|
+
}
|
|
1058
|
+
function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, fetch: fetchImpl, storage, persistence }) {
|
|
1059
|
+
if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
|
|
1060
|
+
const registry = resolveInitConfig({
|
|
1061
|
+
core,
|
|
1062
|
+
customValidations,
|
|
1063
|
+
serverFormData
|
|
1064
|
+
});
|
|
1065
|
+
const uploadConfig = resolvePersistenceConfig({
|
|
1066
|
+
core,
|
|
1067
|
+
baseUrl,
|
|
1068
|
+
fetch: fetchImpl
|
|
1069
|
+
});
|
|
701
1070
|
const resolvedPersistence = persistence ?? resolveDefaultPersistence({
|
|
702
1071
|
core,
|
|
703
|
-
baseUrl
|
|
1072
|
+
baseUrl,
|
|
1073
|
+
fetch: fetchImpl
|
|
704
1074
|
});
|
|
705
1075
|
const instances = /* @__PURE__ */ new Map();
|
|
706
|
-
return { getForm
|
|
1076
|
+
return { getForm({ formId }) {
|
|
707
1077
|
const existing = instances.get(formId);
|
|
708
1078
|
if (existing !== void 0) return existing;
|
|
709
|
-
const
|
|
710
|
-
if (
|
|
1079
|
+
const entry = registry.get(formId);
|
|
1080
|
+
if (entry === void 0) throw new FormsError(`Unknown form id "${formId}".`);
|
|
711
1081
|
const instance = createFormInstance({
|
|
712
1082
|
analyticsInstance,
|
|
713
1083
|
storage,
|
|
714
1084
|
persistence: resolvedPersistence,
|
|
715
|
-
|
|
716
|
-
|
|
1085
|
+
uploadConfig,
|
|
1086
|
+
schema: entry.schema,
|
|
1087
|
+
customValidations: entry.customValidations,
|
|
1088
|
+
serverFormData: entry.serverFormData,
|
|
1089
|
+
projectId: core.getProjectId()
|
|
717
1090
|
});
|
|
718
1091
|
instances.set(formId, instance);
|
|
719
1092
|
return instance;
|
|
720
1093
|
} };
|
|
721
1094
|
}
|
|
722
|
-
function
|
|
1095
|
+
function mergeInitialBag({ fromStorage, serverFormData }) {
|
|
1096
|
+
if (serverFormData === void 0) return { ...fromStorage };
|
|
1097
|
+
return {
|
|
1098
|
+
...fromStorage,
|
|
1099
|
+
...serverFormData
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
function createFormInstance({ analyticsInstance, storage, persistence, uploadConfig, schema, customValidations, serverFormData, projectId }) {
|
|
723
1103
|
const resolved = resolveForm({ schema: mergeCustomValidations({
|
|
724
1104
|
schema,
|
|
725
1105
|
customValidations
|
|
@@ -739,11 +1119,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
739
1119
|
};
|
|
740
1120
|
const state = {
|
|
741
1121
|
storage: resolvedStorage,
|
|
742
|
-
bag:
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1122
|
+
bag: mergeInitialBag({
|
|
1123
|
+
fromStorage: readFields({
|
|
1124
|
+
storage: resolvedStorage,
|
|
1125
|
+
formKey: resolved.formKey,
|
|
1126
|
+
fieldDefinitions: resolved.fields
|
|
1127
|
+
}),
|
|
1128
|
+
serverFormData
|
|
1129
|
+
}),
|
|
747
1130
|
errors: /* @__PURE__ */ new Map()
|
|
748
1131
|
};
|
|
749
1132
|
const firePersistence = (run) => {
|
|
@@ -814,12 +1197,24 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
814
1197
|
}
|
|
815
1198
|
};
|
|
816
1199
|
const set = (patch) => {
|
|
817
|
-
const changes = patch;
|
|
818
|
-
|
|
819
|
-
if (entries.length === 0) return Promise.resolve({
|
|
1200
|
+
const changes = { ...patch };
|
|
1201
|
+
if (Object.keys(changes).length === 0) return Promise.resolve({
|
|
820
1202
|
ok: true,
|
|
821
1203
|
errors: noErrors()
|
|
822
1204
|
});
|
|
1205
|
+
const previousBag = readBag();
|
|
1206
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1207
|
+
const field = declared.get(key);
|
|
1208
|
+
if (!field || field.type !== "multiselect" || !field.options) continue;
|
|
1209
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) continue;
|
|
1210
|
+
const stored = previousBag[key];
|
|
1211
|
+
changes[key] = [...normalizeExclusiveSelection({
|
|
1212
|
+
next: value,
|
|
1213
|
+
previous: Array.isArray(stored) ? stored : [],
|
|
1214
|
+
options: field.options
|
|
1215
|
+
})];
|
|
1216
|
+
}
|
|
1217
|
+
const entries = Object.entries(changes);
|
|
823
1218
|
const errors = /* @__PURE__ */ new Map();
|
|
824
1219
|
for (const [key, value] of entries) {
|
|
825
1220
|
const field = declared.get(key);
|
|
@@ -827,12 +1222,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
827
1222
|
errors.set(key, [`Unknown field: ${key}`]);
|
|
828
1223
|
continue;
|
|
829
1224
|
}
|
|
1225
|
+
if (value === void 0) continue;
|
|
830
1226
|
if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
|
|
831
1227
|
}
|
|
832
1228
|
const candidate = {
|
|
833
|
-
...
|
|
1229
|
+
...previousBag,
|
|
834
1230
|
...changes
|
|
835
1231
|
};
|
|
1232
|
+
for (const [key, value] of entries) if (value === void 0) delete candidate[key];
|
|
836
1233
|
const snapshot = narrow(candidate);
|
|
837
1234
|
for (const [key, value] of entries) {
|
|
838
1235
|
const field = declared.get(key);
|
|
@@ -866,12 +1263,18 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
866
1263
|
} catch {
|
|
867
1264
|
return Promise.resolve(degradeAndReport({ entries }));
|
|
868
1265
|
}
|
|
1266
|
+
writeFormCookieData({
|
|
1267
|
+
projectId,
|
|
1268
|
+
formId: resolved.formKey,
|
|
1269
|
+
bag: candidate,
|
|
1270
|
+
fieldDefinitions: resolved.fields
|
|
1271
|
+
});
|
|
869
1272
|
notify();
|
|
870
1273
|
const persistedFields = entries.map(([key, value]) => {
|
|
871
1274
|
const field = declared.get(key);
|
|
872
1275
|
return {
|
|
873
1276
|
key,
|
|
874
|
-
value,
|
|
1277
|
+
value: value === void 0 ? null : value,
|
|
875
1278
|
...field?.registryId === void 0 ? {} : { registryId: field.registryId },
|
|
876
1279
|
...field?.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
|
|
877
1280
|
};
|
|
@@ -890,10 +1293,12 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
890
1293
|
});
|
|
891
1294
|
return analyticsInstance.trackEvent([buildDataUpdatedEvent({
|
|
892
1295
|
fields: resolved.fields,
|
|
893
|
-
patch: changes
|
|
1296
|
+
patch: changes,
|
|
1297
|
+
formId: schema.id
|
|
894
1298
|
}), ...buildFieldUpdatedEvents({
|
|
895
1299
|
fields: resolved.fields,
|
|
896
|
-
patch: changes
|
|
1300
|
+
patch: changes,
|
|
1301
|
+
formId: schema.id
|
|
897
1302
|
})]).then(() => ({
|
|
898
1303
|
ok: true,
|
|
899
1304
|
errors: noErrors()
|
|
@@ -1058,6 +1463,10 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
1058
1463
|
});
|
|
1059
1464
|
state.bag = {};
|
|
1060
1465
|
state.errors.clear();
|
|
1466
|
+
clearFormCookieData({
|
|
1467
|
+
projectId,
|
|
1468
|
+
formId: resolved.formKey
|
|
1469
|
+
});
|
|
1061
1470
|
notify();
|
|
1062
1471
|
};
|
|
1063
1472
|
const subscribe = (listener) => {
|
|
@@ -1066,6 +1475,27 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
1066
1475
|
listeners.delete(listener);
|
|
1067
1476
|
};
|
|
1068
1477
|
};
|
|
1478
|
+
const uploadFile = async ({ key, file, fileName }) => {
|
|
1479
|
+
const field = declared.get(key);
|
|
1480
|
+
if (!field) throw new FormsError(`Unknown field: ${key}`);
|
|
1481
|
+
if (field.type !== "file") throw new FormsError(`Field "${key}" is not a file field`);
|
|
1482
|
+
if (!uploadConfig) throw new FormsError("File uploads require a valid publishable key and initialized Embeddables core instance.");
|
|
1483
|
+
const rules = field.validations;
|
|
1484
|
+
const contentType = file.type || "application/octet-stream";
|
|
1485
|
+
const byteLength = file.size;
|
|
1486
|
+
if (byteLength > 26214400) throw new FormsError("Maximum upload size is 25 MiB.");
|
|
1487
|
+
if (rules?.maxSize !== void 0 && byteLength > rules.maxSize) throw new FormsError(`${field.label} must be at most ${rules.maxSize} bytes`);
|
|
1488
|
+
if (rules?.accept !== void 0 && !contentTypeMatchesAccept({
|
|
1489
|
+
contentType,
|
|
1490
|
+
accept: rules.accept
|
|
1491
|
+
})) throw new FormsError(`${field.label} must be one of the allowed file types`);
|
|
1492
|
+
return uploadFormFile(uploadConfig, {
|
|
1493
|
+
formId: resolved.formKey,
|
|
1494
|
+
fieldKey: key,
|
|
1495
|
+
file,
|
|
1496
|
+
fileName
|
|
1497
|
+
});
|
|
1498
|
+
};
|
|
1069
1499
|
return {
|
|
1070
1500
|
key: schema.id,
|
|
1071
1501
|
set,
|
|
@@ -1076,10 +1506,11 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
1076
1506
|
validate,
|
|
1077
1507
|
errors: () => freeze(state.errors),
|
|
1078
1508
|
clear,
|
|
1509
|
+
uploadFile,
|
|
1079
1510
|
subscribe
|
|
1080
1511
|
};
|
|
1081
1512
|
}
|
|
1082
1513
|
//#endregion
|
|
1083
|
-
export {
|
|
1514
|
+
export { FORM_DATA_KEY as a, SchemaError as c, seedServerFormsStorageFromCookies as i, ValidatorError as l, initForms as n, createMemoryFormsStorage as o, createNoopPersistence as r, FormsError as s, createFormsClient as t };
|
|
1084
1515
|
|
|
1085
|
-
//# sourceMappingURL=form-
|
|
1516
|
+
//# sourceMappingURL=form-7wmC3G_q.js.map
|