@embeddables/forms 0.0.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -31
- package/dist/{form-DzfCc5X2.cjs → form-BOHdxeuO.cjs} +544 -216
- package/dist/form-BOHdxeuO.cjs.map +1 -0
- package/dist/{index-qfBhdk5L.d.ts → form-Br23yPDS.d.ts} +53 -87
- package/dist/form-Br23yPDS.d.ts.map +1 -0
- package/dist/{form-9Tp91g6a.js → form-Bt5pwVP6.js} +521 -217
- package/dist/form-Bt5pwVP6.js.map +1 -0
- package/dist/index-BB698udY.d.ts +78 -0
- package/dist/index-BB698udY.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 +42 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.ts +21 -7
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +42 -15
- 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,112 +31,96 @@ var ValidatorError = class extends FormsError {
|
|
|
31
31
|
this.name = "ValidatorError";
|
|
32
32
|
}
|
|
33
33
|
};
|
|
34
|
-
const DEVELOPMENT_BASE_URL = void 0;
|
|
35
|
-
/**
|
|
36
|
-
* Returns null when persistence cannot be configured (missing publishable key
|
|
37
|
-
* or fetch). The SDK keeps the no-op default in that case.
|
|
38
|
-
*/
|
|
39
|
-
function resolvePersistenceConfig(config) {
|
|
40
|
-
const core = config.core;
|
|
41
|
-
const publishableKey = config.publishableKey ?? core.getPublishableKey();
|
|
42
|
-
if (!publishableKey || !isValidPublishableKey({ value: publishableKey })) return null;
|
|
43
|
-
const projectId = core.getProjectId();
|
|
44
|
-
const appUserId = core.getAppUserId();
|
|
45
|
-
if (!projectId || !appUserId) return null;
|
|
46
|
-
const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
|
|
47
|
-
if (!fetchImpl) return null;
|
|
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
|
-
};
|
|
57
|
-
}
|
|
58
34
|
//#endregion
|
|
59
|
-
//#region src/
|
|
35
|
+
//#region src/core/validation.ts
|
|
36
|
+
/** Runtime counterpart to `FieldType`. Exhaustive by construction. */
|
|
37
|
+
const FIELD_TYPE_PREDICATES = {
|
|
38
|
+
text: (value) => typeof value === "string",
|
|
39
|
+
email: (value) => typeof value === "string",
|
|
40
|
+
number: (value) => typeof value === "number",
|
|
41
|
+
boolean: (value) => typeof value === "boolean",
|
|
42
|
+
select: (value) => typeof value === "string",
|
|
43
|
+
multiselect: (value) => Array.isArray(value),
|
|
44
|
+
json: () => true
|
|
45
|
+
};
|
|
46
|
+
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
47
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
48
|
+
* Every message a single field's value earns. Empty means valid. Not generic:
|
|
49
|
+
* the per-field types live at the instance boundary, and the cast down to
|
|
50
|
+
* `JsonValue` happens once, in `initForm`.
|
|
63
51
|
*/
|
|
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
|
-
|
|
52
|
+
function validateValue({ field, value, values, pattern, validator }) {
|
|
53
|
+
const rules = field.validations;
|
|
54
|
+
const messages = [];
|
|
55
|
+
const isAbsent = value === void 0 || value === null;
|
|
56
|
+
const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
|
|
57
|
+
if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
|
|
58
|
+
if (isAbsent) return messages;
|
|
59
|
+
if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
|
|
60
|
+
if (typeof value === "string") {
|
|
61
|
+
if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
|
|
62
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
|
|
63
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
|
|
64
|
+
if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
|
|
68
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof value === "number") {
|
|
71
|
+
if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
|
|
72
|
+
if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
|
|
73
|
+
}
|
|
74
|
+
if (field.type === "select" || field.type === "multiselect") {
|
|
75
|
+
const options = field.options;
|
|
76
|
+
if (options) {
|
|
77
|
+
const allowed = new Set(options.map((option) => option.value));
|
|
78
|
+
if (field.type === "select" && typeof value === "string" && !allowed.has(value)) messages.push(`${field.label} must be one of the allowed options`);
|
|
79
|
+
if (field.type === "multiselect" && Array.isArray(value)) {
|
|
80
|
+
if (value.some((entry) => typeof entry !== "string" || !allowed.has(entry))) messages.push(`${field.label} has values that are not allowed options`);
|
|
81
|
+
}
|
|
91
82
|
}
|
|
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
83
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
84
|
+
if (rules?.oneOf) {
|
|
85
|
+
const encoded = canonicalize(value);
|
|
86
|
+
if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
|
|
87
|
+
}
|
|
88
|
+
if (!validator || messages.length > 0) return messages;
|
|
89
|
+
return normalizeValidatorResult({
|
|
90
|
+
result: validator({
|
|
91
|
+
value,
|
|
92
|
+
values
|
|
93
|
+
}),
|
|
94
|
+
field
|
|
108
95
|
});
|
|
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
96
|
}
|
|
130
|
-
function
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
97
|
+
function normalizeValidatorResult({ result, field }) {
|
|
98
|
+
if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
|
|
99
|
+
if (result === null || result === void 0) return [];
|
|
100
|
+
if (typeof result === "string") return [result];
|
|
101
|
+
if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
|
|
102
|
+
throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
|
|
103
|
+
}
|
|
104
|
+
function isThenable(value) {
|
|
105
|
+
return typeof value?.then === "function";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* JSON encoding with object keys sorted at every depth, so two values compare
|
|
109
|
+
* by content rather than by insertion order.
|
|
110
|
+
*/
|
|
111
|
+
function canonicalize(value) {
|
|
112
|
+
const walk = (input) => {
|
|
113
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
114
|
+
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)]));
|
|
115
|
+
return input;
|
|
116
|
+
};
|
|
117
|
+
return JSON.stringify(walk(value));
|
|
134
118
|
}
|
|
135
119
|
//#endregion
|
|
136
120
|
//#region src/storage/storage.ts
|
|
137
121
|
/** Every form on the origin shares this one entry, indexed by form ID. */
|
|
138
122
|
const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
|
|
139
|
-
const FIELD_TYPES$
|
|
123
|
+
const FIELD_TYPES$2 = [
|
|
140
124
|
"text",
|
|
141
125
|
"email",
|
|
142
126
|
"number",
|
|
@@ -151,7 +135,7 @@ function readDocumentFromStorage({ storage }) {
|
|
|
151
135
|
const raw = storage.getItem(FORM_DATA_KEY);
|
|
152
136
|
if (raw === null) return {};
|
|
153
137
|
const parsed = JSON.parse(raw);
|
|
154
|
-
if (!isObjectLike$
|
|
138
|
+
if (!isObjectLike$2(parsed)) return {};
|
|
155
139
|
return parsed;
|
|
156
140
|
} catch {
|
|
157
141
|
return {};
|
|
@@ -178,7 +162,7 @@ function resolveStorage({ storage }) {
|
|
|
178
162
|
candidate.getItem(FORM_DATA_KEY);
|
|
179
163
|
return candidate;
|
|
180
164
|
} catch {
|
|
181
|
-
return
|
|
165
|
+
return createMemoryFormsStorage();
|
|
182
166
|
}
|
|
183
167
|
}
|
|
184
168
|
/**
|
|
@@ -186,7 +170,7 @@ function resolveStorage({ storage }) {
|
|
|
186
170
|
* whose real storage started throwing mid-session.
|
|
187
171
|
*/
|
|
188
172
|
function degradeToMemory({ storage }) {
|
|
189
|
-
const shim =
|
|
173
|
+
const shim = createMemoryFormsStorage();
|
|
190
174
|
const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
|
|
191
175
|
shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
|
|
192
176
|
LIVE_DOCUMENTS.set(shim, snapshot);
|
|
@@ -194,18 +178,18 @@ function degradeToMemory({ storage }) {
|
|
|
194
178
|
}
|
|
195
179
|
function readFields({ storage, formKey, fieldDefinitions }) {
|
|
196
180
|
const bag = loadDocument({ storage })[formKey];
|
|
197
|
-
if (!isObjectLike$
|
|
181
|
+
if (!isObjectLike$2(bag)) return {};
|
|
198
182
|
const declared = new Set(fieldDefinitions.map((field) => field.key));
|
|
199
183
|
const values = {};
|
|
200
184
|
for (const [key, entry] of Object.entries(bag)) {
|
|
201
185
|
if (!declared.has(key)) continue;
|
|
202
|
-
if (isStoredField(entry)) values[key] = entry.value;
|
|
186
|
+
if (isStoredField$1(entry)) values[key] = entry.value;
|
|
203
187
|
}
|
|
204
188
|
return values;
|
|
205
189
|
}
|
|
206
190
|
function writeFields({ storage, formKey, fields, fieldDefinitions }) {
|
|
207
191
|
const current = loadDocument({ storage });
|
|
208
|
-
const currentForm = isObjectLike$
|
|
192
|
+
const currentForm = isObjectLike$2(current[formKey]) ? current[formKey] : {};
|
|
209
193
|
const declaredKeys = new Set(fieldDefinitions.map((field) => field.key));
|
|
210
194
|
const nextForm = {};
|
|
211
195
|
for (const [key, entry] of Object.entries(currentForm)) if (!declaredKeys.has(key)) nextForm[key] = entry;
|
|
@@ -250,7 +234,8 @@ function isSerializable({ value }) {
|
|
|
250
234
|
return false;
|
|
251
235
|
}
|
|
252
236
|
}
|
|
253
|
-
|
|
237
|
+
/** In-memory storage for server-side form init and tests that inject a store. */
|
|
238
|
+
function createMemoryFormsStorage() {
|
|
254
239
|
const entries = /* @__PURE__ */ new Map();
|
|
255
240
|
return {
|
|
256
241
|
getItem: (key) => entries.get(key) ?? null,
|
|
@@ -262,6 +247,47 @@ function createMemoryStorage() {
|
|
|
262
247
|
}
|
|
263
248
|
};
|
|
264
249
|
}
|
|
250
|
+
function isObjectLike$2(value) {
|
|
251
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
252
|
+
}
|
|
253
|
+
function isStoredField$1(value) {
|
|
254
|
+
if (!isObjectLike$2(value) || !Object.hasOwn(value, "value")) return false;
|
|
255
|
+
if (!isSerializable({ value: value["value"] })) return false;
|
|
256
|
+
if (typeof value["type"] !== "string" || !FIELD_TYPES$2.includes(value["type"]) || typeof value["label"] !== "string") return false;
|
|
257
|
+
if (value["registryId"] !== void 0 && typeof value["registryId"] !== "string") return false;
|
|
258
|
+
if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/storage/cookie-form-data.ts
|
|
263
|
+
const FIELD_TYPES$1 = [
|
|
264
|
+
"text",
|
|
265
|
+
"email",
|
|
266
|
+
"number",
|
|
267
|
+
"boolean",
|
|
268
|
+
"select",
|
|
269
|
+
"multiselect",
|
|
270
|
+
"json"
|
|
271
|
+
];
|
|
272
|
+
function formatFormCookieDataKey({ projectId, formId }) {
|
|
273
|
+
return `EMBEDDABLES--${projectId}--COOKIES-FORM-DATA--${formId}`;
|
|
274
|
+
}
|
|
275
|
+
function buildCookiePayloadFromBag({ bag, fieldDefinitions }) {
|
|
276
|
+
const payload = {};
|
|
277
|
+
for (const field of fieldDefinitions) {
|
|
278
|
+
if (field.includeInCookies !== true) continue;
|
|
279
|
+
const value = bag[field.key];
|
|
280
|
+
if (value === void 0) continue;
|
|
281
|
+
payload[field.key] = {
|
|
282
|
+
value,
|
|
283
|
+
type: field.type,
|
|
284
|
+
label: field.label,
|
|
285
|
+
...field.registryId === void 0 ? {} : { registryId: field.registryId },
|
|
286
|
+
...field.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return payload;
|
|
290
|
+
}
|
|
265
291
|
function isObjectLike$1(value) {
|
|
266
292
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
267
293
|
}
|
|
@@ -273,6 +299,204 @@ function isStoredField(value) {
|
|
|
273
299
|
if (value["protocolFieldId"] !== void 0 && typeof value["protocolFieldId"] !== "string") return false;
|
|
274
300
|
return true;
|
|
275
301
|
}
|
|
302
|
+
function entryMatchesFieldDefinition(entry, field) {
|
|
303
|
+
if (entry.type !== field.type || entry.label !== field.label) return false;
|
|
304
|
+
if ((entry.registryId ?? void 0) !== (field.registryId ?? void 0)) return false;
|
|
305
|
+
if ((entry.protocolFieldId ?? void 0) !== (field.protocolFieldId ?? void 0)) return false;
|
|
306
|
+
return FIELD_TYPE_PREDICATES[field.type](entry.value);
|
|
307
|
+
}
|
|
308
|
+
function serializeBrowserCookie(key, value) {
|
|
309
|
+
const locationRef = globalThis.location;
|
|
310
|
+
const parts = [
|
|
311
|
+
`${key}=${encodeURIComponent(value)}`,
|
|
312
|
+
"Path=/",
|
|
313
|
+
"SameSite=Lax"
|
|
314
|
+
];
|
|
315
|
+
if (locationRef?.protocol === "https:") parts.push("Secure");
|
|
316
|
+
return parts.join("; ");
|
|
317
|
+
}
|
|
318
|
+
function writeBrowserCookie(key, value) {
|
|
319
|
+
const documentRef = globalThis.document;
|
|
320
|
+
if (!documentRef) return;
|
|
321
|
+
try {
|
|
322
|
+
documentRef.cookie = serializeBrowserCookie(key, value);
|
|
323
|
+
} catch {}
|
|
324
|
+
}
|
|
325
|
+
function clearBrowserCookie(key) {
|
|
326
|
+
const documentRef = globalThis.document;
|
|
327
|
+
if (!documentRef) return;
|
|
328
|
+
try {
|
|
329
|
+
const locationRef = globalThis.location;
|
|
330
|
+
const parts = [
|
|
331
|
+
`${key}=`,
|
|
332
|
+
"Path=/",
|
|
333
|
+
"Max-Age=0",
|
|
334
|
+
"SameSite=Lax"
|
|
335
|
+
];
|
|
336
|
+
if (locationRef?.protocol === "https:") parts.push("Secure");
|
|
337
|
+
documentRef.cookie = parts.join("; ");
|
|
338
|
+
} catch {}
|
|
339
|
+
}
|
|
340
|
+
function writeFormCookieData({ projectId, formId, bag, fieldDefinitions }) {
|
|
341
|
+
try {
|
|
342
|
+
const payload = buildCookiePayloadFromBag({
|
|
343
|
+
bag,
|
|
344
|
+
fieldDefinitions
|
|
345
|
+
});
|
|
346
|
+
writeBrowserCookie(formatFormCookieDataKey({
|
|
347
|
+
projectId,
|
|
348
|
+
formId
|
|
349
|
+
}), JSON.stringify(payload));
|
|
350
|
+
} catch {}
|
|
351
|
+
}
|
|
352
|
+
function clearFormCookieData({ projectId, formId }) {
|
|
353
|
+
try {
|
|
354
|
+
clearBrowserCookie(formatFormCookieDataKey({
|
|
355
|
+
projectId,
|
|
356
|
+
formId
|
|
357
|
+
}));
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
function readFormCookieStoredForm({ projectId, formId, getCookie, fieldDefinitions }) {
|
|
361
|
+
try {
|
|
362
|
+
const raw = getCookie(formatFormCookieDataKey({
|
|
363
|
+
projectId,
|
|
364
|
+
formId
|
|
365
|
+
}));
|
|
366
|
+
if (raw === null || raw === "") return {};
|
|
367
|
+
const parsed = JSON.parse(decodeURIComponent(raw));
|
|
368
|
+
if (!isObjectLike$1(parsed)) return {};
|
|
369
|
+
const optedInFields = new Map(fieldDefinitions.filter((field) => field.includeInCookies === true).map((field) => [field.key, field]));
|
|
370
|
+
const storedForm = {};
|
|
371
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
372
|
+
const field = optedInFields.get(key);
|
|
373
|
+
if (!field || !isStoredField(entry)) continue;
|
|
374
|
+
if (!entryMatchesFieldDefinition(entry, field)) continue;
|
|
375
|
+
storedForm[key] = entry;
|
|
376
|
+
}
|
|
377
|
+
return storedForm;
|
|
378
|
+
} catch {
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function seedServerFormsStorageFromCookies({ storage, projectId, formIds, getFormSchema, getCookie }) {
|
|
383
|
+
const document = {};
|
|
384
|
+
for (const formId of formIds) {
|
|
385
|
+
const rawSchema = getFormSchema(formId);
|
|
386
|
+
if (typeof rawSchema !== "object" || rawSchema === null) continue;
|
|
387
|
+
const storedForm = readFormCookieStoredForm({
|
|
388
|
+
projectId,
|
|
389
|
+
formId,
|
|
390
|
+
getCookie,
|
|
391
|
+
fieldDefinitions: rawSchema.fields
|
|
392
|
+
});
|
|
393
|
+
if (Object.keys(storedForm).length === 0) continue;
|
|
394
|
+
document[formId] = storedForm;
|
|
395
|
+
}
|
|
396
|
+
if (Object.keys(document).length === 0) return;
|
|
397
|
+
storage.setItem(FORM_DATA_KEY, JSON.stringify(document));
|
|
398
|
+
}
|
|
399
|
+
const DEVELOPMENT_BASE_URL = void 0;
|
|
400
|
+
/**
|
|
401
|
+
* Returns null when persistence cannot be configured (missing publishable key
|
|
402
|
+
* or fetch). The SDK keeps the no-op default in that case.
|
|
403
|
+
*/
|
|
404
|
+
function resolvePersistenceConfig(config) {
|
|
405
|
+
const core = config.core;
|
|
406
|
+
const publishableKey = config.publishableKey ?? core.getPublishableKey();
|
|
407
|
+
if (!publishableKey || !isValidPublishableKey({ value: publishableKey })) return null;
|
|
408
|
+
const projectId = core.getProjectId();
|
|
409
|
+
const appUserId = core.getAppUserId();
|
|
410
|
+
if (!projectId || !appUserId) return null;
|
|
411
|
+
const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
|
|
412
|
+
if (!fetchImpl) return null;
|
|
413
|
+
return {
|
|
414
|
+
core,
|
|
415
|
+
projectId,
|
|
416
|
+
appUserId,
|
|
417
|
+
publishableKey,
|
|
418
|
+
baseUrl: config.baseUrl ?? DEVELOPMENT_BASE_URL ?? "https://backend-worker.heysavvy.workers.dev",
|
|
419
|
+
fetch: fetchImpl,
|
|
420
|
+
timeoutMs: config.timeoutMs ?? 1e4
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/storage/persistence.ts
|
|
425
|
+
/**
|
|
426
|
+
* The default: does nothing, never throws, and recovers nothing. With this in
|
|
427
|
+
* place a form is pure local state, exactly as before the port existed.
|
|
428
|
+
*/
|
|
429
|
+
function createNoopPersistence() {
|
|
430
|
+
return {
|
|
431
|
+
savePartial: () => void 0,
|
|
432
|
+
saveFields: () => void 0,
|
|
433
|
+
saveSubmission: () => void 0,
|
|
434
|
+
recoverRegistryFields: () => ({})
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/storage/persistence-client.ts
|
|
439
|
+
const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
|
|
440
|
+
const hcWithType = (...args) => hc(...args);
|
|
441
|
+
function withTimeout(fetchImpl, timeoutMs) {
|
|
442
|
+
return async (input, init) => {
|
|
443
|
+
const controller = new AbortController();
|
|
444
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
445
|
+
const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
446
|
+
try {
|
|
447
|
+
return await fetchImpl(input, {
|
|
448
|
+
...init,
|
|
449
|
+
signal: controller.signal
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
|
|
453
|
+
throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
|
|
454
|
+
} finally {
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function rpcCall(fn) {
|
|
460
|
+
const res = await fn();
|
|
461
|
+
if (!res.ok) {
|
|
462
|
+
const problem = await res.json().catch(() => null);
|
|
463
|
+
const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
|
|
464
|
+
throw new Error(message);
|
|
465
|
+
}
|
|
466
|
+
return res.json();
|
|
467
|
+
}
|
|
468
|
+
function createApiPersistence(config) {
|
|
469
|
+
const root = config.baseUrl.replace(/\/+$/, "");
|
|
470
|
+
const rpc = hcWithType(`${root}/forms`, {
|
|
471
|
+
fetch: withTimeout(config.fetch, config.timeoutMs),
|
|
472
|
+
headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey }
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
savePartial({ formKey, values }) {
|
|
476
|
+
return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
|
|
477
|
+
project_id: config.projectId,
|
|
478
|
+
app_user_id: config.appUserId,
|
|
479
|
+
form_id: formKey,
|
|
480
|
+
data: values
|
|
481
|
+
} })).then(() => void 0);
|
|
482
|
+
},
|
|
483
|
+
saveFields: () => void 0,
|
|
484
|
+
saveSubmission({ formKey, values }) {
|
|
485
|
+
return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
|
|
486
|
+
project_id: config.projectId,
|
|
487
|
+
app_user_id: config.appUserId,
|
|
488
|
+
form_id: formKey,
|
|
489
|
+
values
|
|
490
|
+
} })).then(() => void 0);
|
|
491
|
+
},
|
|
492
|
+
recoverRegistryFields: () => ({})
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
function resolveDefaultPersistence(config) {
|
|
496
|
+
const resolved = resolvePersistenceConfig(config);
|
|
497
|
+
if (!resolved) return createNoopPersistence();
|
|
498
|
+
return createApiPersistence(resolved);
|
|
499
|
+
}
|
|
276
500
|
//#endregion
|
|
277
501
|
//#region src/core/analytics.ts
|
|
278
502
|
/** The ingest bound on a `data:updated` entry's `value`. */
|
|
@@ -288,10 +512,11 @@ function mapFieldUpdatedType(type) {
|
|
|
288
512
|
* raw `field_value`; only the batch event caps and stringifies for ingest.
|
|
289
513
|
*/
|
|
290
514
|
function formatFieldValue({ value }) {
|
|
515
|
+
if (value === void 0) return "";
|
|
291
516
|
return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
|
|
292
517
|
}
|
|
293
518
|
/** One event carrying every key in one `.set()` call. */
|
|
294
|
-
function buildDataUpdatedEvent({ fields, patch }) {
|
|
519
|
+
function buildDataUpdatedEvent({ fields, patch, formId }) {
|
|
295
520
|
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
296
521
|
return {
|
|
297
522
|
event_name: "data:updated",
|
|
@@ -304,11 +529,12 @@ function buildDataUpdatedEvent({ fields, patch }) {
|
|
|
304
529
|
}),
|
|
305
530
|
label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
|
|
306
531
|
}];
|
|
307
|
-
}))
|
|
532
|
+
})),
|
|
533
|
+
form_id: formId
|
|
308
534
|
};
|
|
309
535
|
}
|
|
310
536
|
/** One `field:updated` per changed key, emitted alongside `data:updated`. */
|
|
311
|
-
function buildFieldUpdatedEvents({ fields, patch }) {
|
|
537
|
+
function buildFieldUpdatedEvents({ fields, patch, formId }) {
|
|
312
538
|
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
313
539
|
return Object.entries(patch).map(([key, value]) => {
|
|
314
540
|
const field = byKey.get(key);
|
|
@@ -316,14 +542,33 @@ function buildFieldUpdatedEvents({ fields, patch }) {
|
|
|
316
542
|
event_name: "field:updated",
|
|
317
543
|
field_key: key,
|
|
318
544
|
field_type: mapFieldUpdatedType(field?.type ?? "text"),
|
|
319
|
-
|
|
545
|
+
form_id: formId
|
|
320
546
|
};
|
|
547
|
+
if (value !== void 0) event.field_value = value;
|
|
321
548
|
if (field?.registryId !== void 0) event.registry_field_id = field.registryId;
|
|
322
549
|
if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
|
|
323
550
|
return event;
|
|
324
551
|
});
|
|
325
552
|
}
|
|
326
553
|
//#endregion
|
|
554
|
+
//#region src/core/options.ts
|
|
555
|
+
/**
|
|
556
|
+
* The array a `multiselect` patch should actually store, given what it added
|
|
557
|
+
* relative to the previous value. An added exclusive option wins outright; an
|
|
558
|
+
* added regular option evicts every exclusive value; a patch that only removed
|
|
559
|
+
* values passes through.
|
|
560
|
+
*/
|
|
561
|
+
function normalizeExclusiveSelection({ next, previous, options }) {
|
|
562
|
+
const exclusive = new Set(options.filter((option) => option.exclusive === true).map((option) => option.value));
|
|
563
|
+
if (exclusive.size === 0) return next;
|
|
564
|
+
const previousValues = new Set(previous);
|
|
565
|
+
const added = next.filter((entry) => !previousValues.has(entry));
|
|
566
|
+
const addedExclusive = added.filter((entry) => exclusive.has(entry));
|
|
567
|
+
if (addedExclusive.length > 0) return [addedExclusive[addedExclusive.length - 1]];
|
|
568
|
+
if (added.length > 0) return next.filter((entry) => !exclusive.has(entry));
|
|
569
|
+
return next;
|
|
570
|
+
}
|
|
571
|
+
//#endregion
|
|
327
572
|
//#region src/core/resolve.ts
|
|
328
573
|
const FIELD_TYPES = [
|
|
329
574
|
"text",
|
|
@@ -341,7 +586,6 @@ const VALIDATION_RULES = [
|
|
|
341
586
|
"min",
|
|
342
587
|
"max",
|
|
343
588
|
"pattern",
|
|
344
|
-
"patternFlags",
|
|
345
589
|
"oneOf",
|
|
346
590
|
"custom"
|
|
347
591
|
];
|
|
@@ -351,11 +595,18 @@ const NUMERIC_RULES = [
|
|
|
351
595
|
"min",
|
|
352
596
|
"max"
|
|
353
597
|
];
|
|
598
|
+
const OPTION_KEYS = [
|
|
599
|
+
"value",
|
|
600
|
+
"label",
|
|
601
|
+
"exclusive"
|
|
602
|
+
];
|
|
603
|
+
/** The two field types whose choices are declared through a field-level `options`. */
|
|
604
|
+
const OPTION_FIELD_TYPES = ["select", "multiselect"];
|
|
354
605
|
/** The ingest `z.string().max(128)` bound on a `data:updated` key. */
|
|
355
606
|
const MAX_FIELD_KEY_LENGTH = 128;
|
|
356
607
|
/** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
|
|
357
608
|
const MAX_FORM_KEY_LENGTH = 128;
|
|
358
|
-
const
|
|
609
|
+
const INLINE_PATTERN_FLAGS = /^\(\?([dgimsuvy]+)\)/;
|
|
359
610
|
const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
|
|
360
611
|
function resolveForm({ schema }) {
|
|
361
612
|
const memoized = RESOLVED_SCHEMAS.get(schema);
|
|
@@ -411,23 +662,51 @@ function validateField({ field, path, seenKeys, patterns }) {
|
|
|
411
662
|
if (registryId !== void 0 && typeof registryId !== "string") throw new SchemaError(`${path}.registryId: must be a string`);
|
|
412
663
|
const protocolFieldId = field["protocolFieldId"];
|
|
413
664
|
if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
|
|
665
|
+
const includeInCookies = field["includeInCookies"];
|
|
666
|
+
if (includeInCookies !== void 0 && typeof includeInCookies !== "boolean") throw new SchemaError(`${path}.includeInCookies: must be a boolean`);
|
|
667
|
+
validateOptions({
|
|
668
|
+
field,
|
|
669
|
+
type,
|
|
670
|
+
path
|
|
671
|
+
});
|
|
414
672
|
const validations = field["validations"];
|
|
415
673
|
if (validations === void 0) return;
|
|
416
674
|
validateValidations({
|
|
417
675
|
validations,
|
|
676
|
+
type,
|
|
418
677
|
path: `${path}.validations`
|
|
419
678
|
});
|
|
420
679
|
if (!isObjectLike(validations)) return;
|
|
421
680
|
const pattern = validations["pattern"];
|
|
422
681
|
if (typeof pattern !== "string") return;
|
|
423
|
-
const flags = validations["patternFlags"];
|
|
424
682
|
patterns.set(key, compilePattern({
|
|
425
683
|
pattern,
|
|
426
|
-
flags: typeof flags === "string" ? flags : "",
|
|
427
684
|
path: `${path}.validations.pattern`
|
|
428
685
|
}));
|
|
429
686
|
}
|
|
430
|
-
function
|
|
687
|
+
function validateOptions({ field, type, path }) {
|
|
688
|
+
const options = field["options"];
|
|
689
|
+
if (options === void 0) return;
|
|
690
|
+
if (!OPTION_FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.options: only a select or multiselect field may declare options`);
|
|
691
|
+
if (!(Array.isArray(options) && options.length > 0)) throw new SchemaError(`${path}.options: must be a non-empty array`);
|
|
692
|
+
const entries = options;
|
|
693
|
+
const seenValues = /* @__PURE__ */ new Set();
|
|
694
|
+
entries.forEach((option, index) => {
|
|
695
|
+
const at = `${path}.options[${index}]`;
|
|
696
|
+
if (!isObjectLike(option)) throw new SchemaError(`${at}: must be an object`);
|
|
697
|
+
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(", ")}`);
|
|
698
|
+
const value = option["value"];
|
|
699
|
+
if (typeof value !== "string" || value.trim() === "") throw new SchemaError(`${at}.value: must be a non-empty string`);
|
|
700
|
+
const label = option["label"];
|
|
701
|
+
if (label !== void 0 && typeof label !== "string") throw new SchemaError(`${at}.label: must be a string`);
|
|
702
|
+
const exclusive = option["exclusive"];
|
|
703
|
+
if (exclusive !== void 0 && typeof exclusive !== "boolean") throw new SchemaError(`${at}.exclusive: must be a boolean`);
|
|
704
|
+
if (exclusive === true && type === "select") throw new SchemaError(`${at}.exclusive: only a multiselect field may declare an exclusive option`);
|
|
705
|
+
if (seenValues.has(value)) throw new SchemaError(`${at}.value: duplicate option value "${value}" on this field`);
|
|
706
|
+
seenValues.add(value);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
function validateValidations({ validations, type, path }) {
|
|
431
710
|
if (!isObjectLike(validations)) throw new SchemaError(`${path}: must be an object`);
|
|
432
711
|
for (const rule of Object.keys(validations)) if (!VALIDATION_RULES.includes(rule)) throw new SchemaError(`${path}.${rule}: unknown validation rule; expected one of ${VALIDATION_RULES.join(", ")}`);
|
|
433
712
|
const required = validations["required"];
|
|
@@ -443,17 +722,19 @@ function validateValidations({ validations, path }) {
|
|
|
443
722
|
const max = validations["max"];
|
|
444
723
|
if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
|
|
445
724
|
const oneOf = validations["oneOf"];
|
|
725
|
+
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
726
|
if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
|
|
447
727
|
const pattern = validations["pattern"];
|
|
448
728
|
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
729
|
const custom = validations["custom"];
|
|
452
730
|
if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
|
|
453
731
|
}
|
|
454
|
-
function compilePattern({ pattern,
|
|
732
|
+
function compilePattern({ pattern, path }) {
|
|
733
|
+
const prefix = INLINE_PATTERN_FLAGS.exec(pattern);
|
|
734
|
+
const source = prefix ? pattern.slice(prefix[0].length) : pattern;
|
|
735
|
+
const flags = prefix?.[1] ?? "";
|
|
455
736
|
try {
|
|
456
|
-
return new RegExp(
|
|
737
|
+
return new RegExp(source, flags.replace(/[gy]/g, ""));
|
|
457
738
|
} catch (error) {
|
|
458
739
|
throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
|
|
459
740
|
}
|
|
@@ -576,81 +857,6 @@ function errorMessage(error) {
|
|
|
576
857
|
return error instanceof Error ? error.message : String(error);
|
|
577
858
|
}
|
|
578
859
|
//#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
860
|
//#region src/core/form.ts
|
|
655
861
|
const REQUIRED_CORE_METHODS = [
|
|
656
862
|
"getAppUserId",
|
|
@@ -685,41 +891,110 @@ function mergeCustomValidations({ schema, customValidations }) {
|
|
|
685
891
|
};
|
|
686
892
|
}
|
|
687
893
|
/**
|
|
688
|
-
* Validates the core instance and
|
|
689
|
-
* whose `getForm` builds each form by id.
|
|
894
|
+
* Validates the core instance and registered form schemas once, then returns a
|
|
895
|
+
* client whose `getForm` builds each form by id.
|
|
690
896
|
*/
|
|
691
897
|
function initForms(options) {
|
|
692
898
|
return createFormsClient(options);
|
|
693
899
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
900
|
+
const REQUIRED_CORE_FORM_METHODS = ["getFormIds", "getFormSchema"];
|
|
901
|
+
function hasCoreFormSchemaMethods(value) {
|
|
902
|
+
return REQUIRED_CORE_FORM_METHODS.every((method) => typeof value[method] === "function");
|
|
903
|
+
}
|
|
904
|
+
function toFormSchema(value, formId) {
|
|
905
|
+
if (typeof value !== "object" || value === null) throw new SchemaError(`Registered form "${formId}": schema must be an object.`);
|
|
906
|
+
const schema = value;
|
|
907
|
+
if (typeof schema.id !== "string" || schema.id === "") throw new SchemaError(`Registered form "${formId}": schema.id must be a non-empty string.`);
|
|
908
|
+
if (schema.id !== formId) throw new SchemaError(`Registered form "${formId}": schema id must match map key.`);
|
|
909
|
+
return schema;
|
|
910
|
+
}
|
|
911
|
+
function indexOverrideEntries({ entries, label, valueKey }) {
|
|
912
|
+
if (entries === void 0) return /* @__PURE__ */ new Map();
|
|
913
|
+
const byFormId = /* @__PURE__ */ new Map();
|
|
914
|
+
for (let index = 0; index < entries.length; index++) {
|
|
915
|
+
const entry = entries[index];
|
|
916
|
+
if (typeof entry !== "object" || entry === null) throw new SchemaError(`${label}[${index}]: must be an object.`);
|
|
917
|
+
const { formId } = entry;
|
|
918
|
+
if (typeof formId !== "string" || formId === "") throw new SchemaError(`${label}[${index}].formId: must be a non-empty string.`);
|
|
919
|
+
const value = entry[valueKey];
|
|
920
|
+
if (value === void 0) throw new SchemaError(`${label}[${index}].${String(valueKey)}: is required.`);
|
|
921
|
+
if (byFormId.has(formId)) throw new SchemaError(`${label}: duplicate form id "${formId}".`);
|
|
922
|
+
byFormId.set(formId, value);
|
|
700
923
|
}
|
|
924
|
+
return byFormId;
|
|
925
|
+
}
|
|
926
|
+
function resolveInitConfig({ core, customValidations, serverFormData }) {
|
|
927
|
+
if (!hasCoreFormSchemaMethods(core)) throw new FormsError("initForms requires a Core instance with registered form schemas.");
|
|
928
|
+
const formIds = core.getFormIds();
|
|
929
|
+
if (formIds.length === 0) throw new FormsError("initForms requires at least one form schema registered on Core.");
|
|
930
|
+
const customByFormId = indexOverrideEntries({
|
|
931
|
+
entries: customValidations,
|
|
932
|
+
label: "customValidations",
|
|
933
|
+
valueKey: "customValidations"
|
|
934
|
+
});
|
|
935
|
+
const serverDataByFormId = indexOverrideEntries({
|
|
936
|
+
entries: serverFormData,
|
|
937
|
+
label: "serverFormData",
|
|
938
|
+
valueKey: "serverFormData"
|
|
939
|
+
});
|
|
940
|
+
const registeredIds = new Set(formIds);
|
|
941
|
+
for (const formId of customByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`customValidations: unknown form id "${formId}".`);
|
|
942
|
+
for (const formId of serverDataByFormId.keys()) if (!registeredIds.has(formId)) throw new SchemaError(`serverFormData: unknown form id "${formId}".`);
|
|
943
|
+
const registry = /* @__PURE__ */ new Map();
|
|
944
|
+
for (const formId of formIds) {
|
|
945
|
+
const schema = toFormSchema(core.getFormSchema(formId), formId);
|
|
946
|
+
const entryCustomValidations = customByFormId.get(formId);
|
|
947
|
+
const entryServerFormData = serverDataByFormId.get(formId);
|
|
948
|
+
mergeCustomValidations({
|
|
949
|
+
schema,
|
|
950
|
+
customValidations: entryCustomValidations
|
|
951
|
+
});
|
|
952
|
+
registry.set(formId, {
|
|
953
|
+
schema,
|
|
954
|
+
customValidations: entryCustomValidations,
|
|
955
|
+
serverFormData: entryServerFormData
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
return registry;
|
|
959
|
+
}
|
|
960
|
+
function createFormsClient({ core, customValidations, serverFormData, analyticsInstance, baseUrl, storage, persistence }) {
|
|
961
|
+
if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
|
|
962
|
+
const registry = resolveInitConfig({
|
|
963
|
+
core,
|
|
964
|
+
customValidations,
|
|
965
|
+
serverFormData
|
|
966
|
+
});
|
|
701
967
|
const resolvedPersistence = persistence ?? resolveDefaultPersistence({
|
|
702
968
|
core,
|
|
703
969
|
baseUrl
|
|
704
970
|
});
|
|
705
971
|
const instances = /* @__PURE__ */ new Map();
|
|
706
|
-
return { getForm
|
|
972
|
+
return { getForm({ formId }) {
|
|
707
973
|
const existing = instances.get(formId);
|
|
708
974
|
if (existing !== void 0) return existing;
|
|
709
|
-
const
|
|
710
|
-
if (
|
|
975
|
+
const entry = registry.get(formId);
|
|
976
|
+
if (entry === void 0) throw new FormsError(`Unknown form id "${formId}".`);
|
|
711
977
|
const instance = createFormInstance({
|
|
712
978
|
analyticsInstance,
|
|
713
979
|
storage,
|
|
714
980
|
persistence: resolvedPersistence,
|
|
715
|
-
schema,
|
|
716
|
-
customValidations
|
|
981
|
+
schema: entry.schema,
|
|
982
|
+
customValidations: entry.customValidations,
|
|
983
|
+
serverFormData: entry.serverFormData,
|
|
984
|
+
projectId: core.getProjectId()
|
|
717
985
|
});
|
|
718
986
|
instances.set(formId, instance);
|
|
719
987
|
return instance;
|
|
720
988
|
} };
|
|
721
989
|
}
|
|
722
|
-
function
|
|
990
|
+
function mergeInitialBag({ fromStorage, serverFormData }) {
|
|
991
|
+
if (serverFormData === void 0) return { ...fromStorage };
|
|
992
|
+
return {
|
|
993
|
+
...fromStorage,
|
|
994
|
+
...serverFormData
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations, serverFormData, projectId }) {
|
|
723
998
|
const resolved = resolveForm({ schema: mergeCustomValidations({
|
|
724
999
|
schema,
|
|
725
1000
|
customValidations
|
|
@@ -739,11 +1014,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
739
1014
|
};
|
|
740
1015
|
const state = {
|
|
741
1016
|
storage: resolvedStorage,
|
|
742
|
-
bag:
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
1017
|
+
bag: mergeInitialBag({
|
|
1018
|
+
fromStorage: readFields({
|
|
1019
|
+
storage: resolvedStorage,
|
|
1020
|
+
formKey: resolved.formKey,
|
|
1021
|
+
fieldDefinitions: resolved.fields
|
|
1022
|
+
}),
|
|
1023
|
+
serverFormData
|
|
1024
|
+
}),
|
|
747
1025
|
errors: /* @__PURE__ */ new Map()
|
|
748
1026
|
};
|
|
749
1027
|
const firePersistence = (run) => {
|
|
@@ -814,12 +1092,24 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
814
1092
|
}
|
|
815
1093
|
};
|
|
816
1094
|
const set = (patch) => {
|
|
817
|
-
const changes = patch;
|
|
818
|
-
|
|
819
|
-
if (entries.length === 0) return Promise.resolve({
|
|
1095
|
+
const changes = { ...patch };
|
|
1096
|
+
if (Object.keys(changes).length === 0) return Promise.resolve({
|
|
820
1097
|
ok: true,
|
|
821
1098
|
errors: noErrors()
|
|
822
1099
|
});
|
|
1100
|
+
const previousBag = readBag();
|
|
1101
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1102
|
+
const field = declared.get(key);
|
|
1103
|
+
if (!field || field.type !== "multiselect" || !field.options) continue;
|
|
1104
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) continue;
|
|
1105
|
+
const stored = previousBag[key];
|
|
1106
|
+
changes[key] = [...normalizeExclusiveSelection({
|
|
1107
|
+
next: value,
|
|
1108
|
+
previous: Array.isArray(stored) ? stored : [],
|
|
1109
|
+
options: field.options
|
|
1110
|
+
})];
|
|
1111
|
+
}
|
|
1112
|
+
const entries = Object.entries(changes);
|
|
823
1113
|
const errors = /* @__PURE__ */ new Map();
|
|
824
1114
|
for (const [key, value] of entries) {
|
|
825
1115
|
const field = declared.get(key);
|
|
@@ -827,12 +1117,14 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
827
1117
|
errors.set(key, [`Unknown field: ${key}`]);
|
|
828
1118
|
continue;
|
|
829
1119
|
}
|
|
1120
|
+
if (value === void 0) continue;
|
|
830
1121
|
if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
|
|
831
1122
|
}
|
|
832
1123
|
const candidate = {
|
|
833
|
-
...
|
|
1124
|
+
...previousBag,
|
|
834
1125
|
...changes
|
|
835
1126
|
};
|
|
1127
|
+
for (const [key, value] of entries) if (value === void 0) delete candidate[key];
|
|
836
1128
|
const snapshot = narrow(candidate);
|
|
837
1129
|
for (const [key, value] of entries) {
|
|
838
1130
|
const field = declared.get(key);
|
|
@@ -866,12 +1158,18 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
866
1158
|
} catch {
|
|
867
1159
|
return Promise.resolve(degradeAndReport({ entries }));
|
|
868
1160
|
}
|
|
1161
|
+
writeFormCookieData({
|
|
1162
|
+
projectId,
|
|
1163
|
+
formId: resolved.formKey,
|
|
1164
|
+
bag: candidate,
|
|
1165
|
+
fieldDefinitions: resolved.fields
|
|
1166
|
+
});
|
|
869
1167
|
notify();
|
|
870
1168
|
const persistedFields = entries.map(([key, value]) => {
|
|
871
1169
|
const field = declared.get(key);
|
|
872
1170
|
return {
|
|
873
1171
|
key,
|
|
874
|
-
value,
|
|
1172
|
+
value: value === void 0 ? null : value,
|
|
875
1173
|
...field?.registryId === void 0 ? {} : { registryId: field.registryId },
|
|
876
1174
|
...field?.protocolFieldId === void 0 ? {} : { protocolFieldId: field.protocolFieldId }
|
|
877
1175
|
};
|
|
@@ -890,10 +1188,12 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
890
1188
|
});
|
|
891
1189
|
return analyticsInstance.trackEvent([buildDataUpdatedEvent({
|
|
892
1190
|
fields: resolved.fields,
|
|
893
|
-
patch: changes
|
|
1191
|
+
patch: changes,
|
|
1192
|
+
formId: schema.id
|
|
894
1193
|
}), ...buildFieldUpdatedEvents({
|
|
895
1194
|
fields: resolved.fields,
|
|
896
|
-
patch: changes
|
|
1195
|
+
patch: changes,
|
|
1196
|
+
formId: schema.id
|
|
897
1197
|
})]).then(() => ({
|
|
898
1198
|
ok: true,
|
|
899
1199
|
errors: noErrors()
|
|
@@ -1058,6 +1358,10 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
1058
1358
|
});
|
|
1059
1359
|
state.bag = {};
|
|
1060
1360
|
state.errors.clear();
|
|
1361
|
+
clearFormCookieData({
|
|
1362
|
+
projectId,
|
|
1363
|
+
formId: resolved.formKey
|
|
1364
|
+
});
|
|
1061
1365
|
notify();
|
|
1062
1366
|
};
|
|
1063
1367
|
const subscribe = (listener) => {
|
|
@@ -1080,6 +1384,6 @@ function createFormInstance({ analyticsInstance, storage, persistence, schema, c
|
|
|
1080
1384
|
};
|
|
1081
1385
|
}
|
|
1082
1386
|
//#endregion
|
|
1083
|
-
export {
|
|
1387
|
+
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
1388
|
|
|
1085
|
-
//# sourceMappingURL=form-
|
|
1389
|
+
//# sourceMappingURL=form-Bt5pwVP6.js.map
|