@embeddables/forms 0.0.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 +122 -0
- package/dist/index.cjs +994 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +241 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +989 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
import { hc } from "hono/client";
|
|
2
|
+
import { isValidPublishableKey } from "@embeddables/core";
|
|
3
|
+
//#region src/errors.ts
|
|
4
|
+
/**
|
|
5
|
+
* Typed error hierarchy. Every failure this SDK raises on its own behalf is an
|
|
6
|
+
* instance of one of these, so consumers branch on the type
|
|
7
|
+
* (`if (e instanceof SchemaError) …`) instead of string-matching messages.
|
|
8
|
+
* Catch `FormsError` to handle them all.
|
|
9
|
+
*
|
|
10
|
+
* An error thrown by a consumer's own custom validator is never wrapped in one
|
|
11
|
+
* of these — it propagates with its original type and stack.
|
|
12
|
+
*/
|
|
13
|
+
/** Base class for every error the SDK throws. */
|
|
14
|
+
var FormsError = class extends Error {
|
|
15
|
+
constructor(message, options) {
|
|
16
|
+
super(message, options);
|
|
17
|
+
this.name = "FormsError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
/** The schema is malformed. */
|
|
21
|
+
var SchemaError = class extends FormsError {
|
|
22
|
+
constructor(message, options) {
|
|
23
|
+
super(message, options);
|
|
24
|
+
this.name = "SchemaError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/** A custom validator returned a thenable, or a shape that is not a message. */
|
|
28
|
+
var ValidatorError = class extends FormsError {
|
|
29
|
+
constructor(message, options) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "ValidatorError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/analytics.ts
|
|
36
|
+
/** The ingest bound on a `data:updated` entry's `value`. */
|
|
37
|
+
const MAX_VALUE_LENGTH = 1024;
|
|
38
|
+
/** The ingest bound on a `data:updated` entry's `label`. */
|
|
39
|
+
const MAX_LABEL_LENGTH = 256;
|
|
40
|
+
/** Maps a form field's declared type to the analytics `field:updated` class. */
|
|
41
|
+
function mapFieldUpdatedType(type) {
|
|
42
|
+
return type;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Stringifies values for `data:updated` entries. `field:updated` carries the
|
|
46
|
+
* raw `field_value`; only the batch event caps and stringifies for ingest.
|
|
47
|
+
*/
|
|
48
|
+
function formatFieldValue({ value }) {
|
|
49
|
+
return (typeof value === "string" ? value : JSON.stringify(value)).slice(0, MAX_VALUE_LENGTH);
|
|
50
|
+
}
|
|
51
|
+
/** One event carrying every key in one `.set()` call. */
|
|
52
|
+
function buildDataUpdatedEvent({ fields, patch }) {
|
|
53
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
54
|
+
return {
|
|
55
|
+
event_name: "data:updated",
|
|
56
|
+
data: Object.fromEntries(Object.entries(patch).map(([key, value]) => {
|
|
57
|
+
const field = byKey.get(key);
|
|
58
|
+
return [key, {
|
|
59
|
+
value: formatFieldValue({
|
|
60
|
+
value,
|
|
61
|
+
field
|
|
62
|
+
}),
|
|
63
|
+
label: (field?.label ?? key).slice(0, MAX_LABEL_LENGTH)
|
|
64
|
+
}];
|
|
65
|
+
}))
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** One `field:updated` per changed key, emitted alongside `data:updated`. */
|
|
69
|
+
function buildFieldUpdatedEvents({ fields, patch }) {
|
|
70
|
+
const byKey = new Map(fields.map((field) => [field.key, field]));
|
|
71
|
+
return Object.entries(patch).map(([key, value]) => {
|
|
72
|
+
const field = byKey.get(key);
|
|
73
|
+
const event = {
|
|
74
|
+
event_name: "field:updated",
|
|
75
|
+
field_key: key,
|
|
76
|
+
field_type: mapFieldUpdatedType(field?.type ?? "text"),
|
|
77
|
+
field_value: value
|
|
78
|
+
};
|
|
79
|
+
if (field?.registryFieldId !== void 0) event.registry_field_id = field.registryFieldId;
|
|
80
|
+
if (field?.protocolFieldId !== void 0) event.protocol_field_id = field.protocolFieldId;
|
|
81
|
+
return event;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Returns null when persistence cannot be configured (missing publishable key
|
|
86
|
+
* or fetch). The SDK keeps the no-op default in that case.
|
|
87
|
+
*/
|
|
88
|
+
function resolvePersistenceConfig(config) {
|
|
89
|
+
const core = config.core;
|
|
90
|
+
const publishableKey = config.publishableKey ?? core.getPublishableKey();
|
|
91
|
+
if (!publishableKey || !isValidPublishableKey(publishableKey)) return null;
|
|
92
|
+
const projectId = core.getProjectId();
|
|
93
|
+
const appUserId = core.getAppUserId();
|
|
94
|
+
if (!projectId || !appUserId) return null;
|
|
95
|
+
const fetchImpl = config.fetch ?? (typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0);
|
|
96
|
+
if (!fetchImpl) return null;
|
|
97
|
+
return {
|
|
98
|
+
core,
|
|
99
|
+
projectId,
|
|
100
|
+
appUserId,
|
|
101
|
+
publishableKey,
|
|
102
|
+
baseUrl: config.baseUrl ?? "https://backend-worker.heysavvy.workers.dev",
|
|
103
|
+
fetch: fetchImpl,
|
|
104
|
+
timeoutMs: config.timeoutMs ?? 1e4
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/persistence.ts
|
|
109
|
+
/**
|
|
110
|
+
* The default: does nothing, never throws, and recovers nothing. With this in
|
|
111
|
+
* place a form is pure local state, exactly as before the port existed.
|
|
112
|
+
*/
|
|
113
|
+
function createNoopPersistence() {
|
|
114
|
+
return {
|
|
115
|
+
savePartial: () => void 0,
|
|
116
|
+
saveFields: () => void 0,
|
|
117
|
+
saveSubmission: () => void 0,
|
|
118
|
+
recoverRegistryFields: () => ({})
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/persistence-client.ts
|
|
123
|
+
const PUBLISHABLE_KEY_HEADER = "x-publishable-key";
|
|
124
|
+
const hcWithType = (...args) => hc(...args);
|
|
125
|
+
function withTimeout(fetchImpl, timeoutMs) {
|
|
126
|
+
return async (input, init) => {
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
129
|
+
const path = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
130
|
+
try {
|
|
131
|
+
return await fetchImpl(input, {
|
|
132
|
+
...init,
|
|
133
|
+
signal: controller.signal
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof Error && error.name === "AbortError") throw new Error(`Request to ${path} timed out after ${timeoutMs}ms`, { cause: error });
|
|
137
|
+
throw new Error(`Request to ${path} failed to reach the API`, { cause: error });
|
|
138
|
+
} finally {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function rpcCall(fn) {
|
|
144
|
+
const res = await fn();
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
const problem = await res.json().catch(() => null);
|
|
147
|
+
const message = problem?.detail ?? problem?.title ?? `forms persistence failed: ${res.status}`;
|
|
148
|
+
throw new Error(message);
|
|
149
|
+
}
|
|
150
|
+
return res.json();
|
|
151
|
+
}
|
|
152
|
+
function createApiPersistence(config) {
|
|
153
|
+
const root = config.baseUrl.replace(/\/+$/, "");
|
|
154
|
+
const rpc = hcWithType(`${root}/forms`, {
|
|
155
|
+
fetch: withTimeout(config.fetch, config.timeoutMs),
|
|
156
|
+
headers: { [PUBLISHABLE_KEY_HEADER]: config.publishableKey }
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
savePartial({ formKey, values }) {
|
|
160
|
+
return rpcCall(() => rpc.v1.public.sessions.$post({ json: {
|
|
161
|
+
project_id: config.projectId,
|
|
162
|
+
app_user_id: config.appUserId,
|
|
163
|
+
form_id: formKey,
|
|
164
|
+
data: values
|
|
165
|
+
} })).then(() => void 0);
|
|
166
|
+
},
|
|
167
|
+
saveFields: () => void 0,
|
|
168
|
+
saveSubmission({ formKey, values }) {
|
|
169
|
+
return rpcCall(() => rpc.v1.public.submissions.$post({ json: {
|
|
170
|
+
project_id: config.projectId,
|
|
171
|
+
app_user_id: config.appUserId,
|
|
172
|
+
form_id: formKey,
|
|
173
|
+
values
|
|
174
|
+
} })).then(() => void 0);
|
|
175
|
+
},
|
|
176
|
+
recoverRegistryFields: () => ({})
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function resolveDefaultPersistence(config) {
|
|
180
|
+
const resolved = resolvePersistenceConfig(config);
|
|
181
|
+
if (!resolved) return createNoopPersistence();
|
|
182
|
+
return createApiPersistence(resolved);
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/resolve.ts
|
|
186
|
+
const FIELD_TYPES = [
|
|
187
|
+
"text",
|
|
188
|
+
"email",
|
|
189
|
+
"number",
|
|
190
|
+
"boolean",
|
|
191
|
+
"select",
|
|
192
|
+
"multiselect",
|
|
193
|
+
"json"
|
|
194
|
+
];
|
|
195
|
+
const VALIDATION_RULES = [
|
|
196
|
+
"required",
|
|
197
|
+
"minLength",
|
|
198
|
+
"maxLength",
|
|
199
|
+
"min",
|
|
200
|
+
"max",
|
|
201
|
+
"pattern",
|
|
202
|
+
"patternFlags",
|
|
203
|
+
"oneOf",
|
|
204
|
+
"custom"
|
|
205
|
+
];
|
|
206
|
+
const NUMERIC_RULES = [
|
|
207
|
+
"minLength",
|
|
208
|
+
"maxLength",
|
|
209
|
+
"min",
|
|
210
|
+
"max"
|
|
211
|
+
];
|
|
212
|
+
/** The ingest `z.string().max(128)` bound on a `data:updated` key. */
|
|
213
|
+
const MAX_FIELD_KEY_LENGTH = 128;
|
|
214
|
+
/** The ingest `z.string().max(128)` bound on a `form:submitted` key. */
|
|
215
|
+
const MAX_FORM_KEY_LENGTH = 128;
|
|
216
|
+
const VALID_PATTERN_FLAGS = /^[dgimsuvy]*$/;
|
|
217
|
+
const RESOLVED_SCHEMAS = /* @__PURE__ */ new WeakMap();
|
|
218
|
+
function resolveForm({ schema }) {
|
|
219
|
+
const memoized = RESOLVED_SCHEMAS.get(schema);
|
|
220
|
+
if (memoized) return memoized;
|
|
221
|
+
const resolved = validateAndCompile({ schema });
|
|
222
|
+
RESOLVED_SCHEMAS.set(schema, resolved);
|
|
223
|
+
return resolved;
|
|
224
|
+
}
|
|
225
|
+
function validateAndCompile({ schema }) {
|
|
226
|
+
const root = schema;
|
|
227
|
+
assertJsonRepresentable({ root });
|
|
228
|
+
if (!isObjectLike$1(root)) throw new SchemaError("schema: must be an object");
|
|
229
|
+
const formKey = root["id"];
|
|
230
|
+
if (typeof formKey !== "string" || formKey.trim() === "") throw new SchemaError("schema.id: must be a non-empty string");
|
|
231
|
+
if (formKey !== formKey.trim()) throw new SchemaError("schema.id: must not have leading or trailing whitespace");
|
|
232
|
+
if (formKey.length > MAX_FORM_KEY_LENGTH) throw new SchemaError(`schema.id: must be at most ${MAX_FORM_KEY_LENGTH} characters (received ${formKey.length})`);
|
|
233
|
+
const name = root["name"];
|
|
234
|
+
if (name !== void 0) {
|
|
235
|
+
if (typeof name !== "string") throw new SchemaError("schema.name: must be a string");
|
|
236
|
+
if (name.trim() === "") throw new SchemaError("schema.name: must be a non-empty string");
|
|
237
|
+
}
|
|
238
|
+
if (!Array.isArray(root["fields"])) throw new SchemaError("schema.fields: must be an array");
|
|
239
|
+
const fields = root["fields"];
|
|
240
|
+
if (fields.length === 0) throw new SchemaError("schema.fields: must declare at least one field");
|
|
241
|
+
const patterns = /* @__PURE__ */ new Map();
|
|
242
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
243
|
+
fields.forEach((field, index) => {
|
|
244
|
+
validateField({
|
|
245
|
+
field,
|
|
246
|
+
path: `schema.fields[${index}]`,
|
|
247
|
+
seenKeys,
|
|
248
|
+
patterns
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
formKey,
|
|
253
|
+
fields: [...fields],
|
|
254
|
+
patterns
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function validateField({ field, path, seenKeys, patterns }) {
|
|
258
|
+
if (!isObjectLike$1(field)) throw new SchemaError(`${path}: must be an object`);
|
|
259
|
+
const key = field["key"];
|
|
260
|
+
if (typeof key !== "string" || key.trim() === "") throw new SchemaError(`${path}.key: must be a non-empty string`);
|
|
261
|
+
if (key.length > MAX_FIELD_KEY_LENGTH) throw new SchemaError(`${path}.key: must be at most ${MAX_FIELD_KEY_LENGTH} characters (received ${key.length})`);
|
|
262
|
+
if (seenKeys.has(key)) throw new SchemaError(`${path}.key: duplicate field key "${key}" in this form`);
|
|
263
|
+
seenKeys.add(key);
|
|
264
|
+
const label = field["label"];
|
|
265
|
+
if (typeof label !== "string" || label.trim() === "") throw new SchemaError(`${path}.label: must be a non-empty string`);
|
|
266
|
+
const type = field["type"];
|
|
267
|
+
if (typeof type !== "string" || !FIELD_TYPES.includes(type)) throw new SchemaError(`${path}.type: must be one of ${FIELD_TYPES.join(", ")}`);
|
|
268
|
+
const registryFieldId = field["registryFieldId"];
|
|
269
|
+
if (registryFieldId !== void 0 && typeof registryFieldId !== "string") throw new SchemaError(`${path}.registryFieldId: must be a string`);
|
|
270
|
+
const protocolFieldId = field["protocolFieldId"];
|
|
271
|
+
if (protocolFieldId !== void 0 && typeof protocolFieldId !== "string") throw new SchemaError(`${path}.protocolFieldId: must be a string`);
|
|
272
|
+
const validations = field["validations"];
|
|
273
|
+
if (validations === void 0) return;
|
|
274
|
+
validateValidations({
|
|
275
|
+
validations,
|
|
276
|
+
path: `${path}.validations`
|
|
277
|
+
});
|
|
278
|
+
if (!isObjectLike$1(validations)) return;
|
|
279
|
+
const pattern = validations["pattern"];
|
|
280
|
+
if (typeof pattern !== "string") return;
|
|
281
|
+
const flags = validations["patternFlags"];
|
|
282
|
+
patterns.set(key, compilePattern({
|
|
283
|
+
pattern,
|
|
284
|
+
flags: typeof flags === "string" ? flags : "",
|
|
285
|
+
path: `${path}.validations.pattern`
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
function validateValidations({ validations, path }) {
|
|
289
|
+
if (!isObjectLike$1(validations)) throw new SchemaError(`${path}: must be an object`);
|
|
290
|
+
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(", ")}`);
|
|
291
|
+
const required = validations["required"];
|
|
292
|
+
if (required !== void 0 && typeof required !== "boolean") throw new SchemaError(`${path}.required: must be a boolean`);
|
|
293
|
+
for (const rule of NUMERIC_RULES) {
|
|
294
|
+
const value = validations[rule];
|
|
295
|
+
if (value !== void 0 && !(typeof value === "number" && Number.isFinite(value))) throw new SchemaError(`${path}.${rule}: must be a finite number`);
|
|
296
|
+
}
|
|
297
|
+
const minLength = validations["minLength"];
|
|
298
|
+
const maxLength = validations["maxLength"];
|
|
299
|
+
if (typeof minLength === "number" && typeof maxLength === "number" && maxLength < minLength) throw new SchemaError(`${path}.maxLength: must be greater than or equal to minLength`);
|
|
300
|
+
const min = validations["min"];
|
|
301
|
+
const max = validations["max"];
|
|
302
|
+
if (typeof min === "number" && typeof max === "number" && max < min) throw new SchemaError(`${path}.max: must be greater than or equal to min`);
|
|
303
|
+
const oneOf = validations["oneOf"];
|
|
304
|
+
if (oneOf !== void 0 && !(Array.isArray(oneOf) && oneOf.length > 0)) throw new SchemaError(`${path}.oneOf: must be a non-empty array`);
|
|
305
|
+
const pattern = validations["pattern"];
|
|
306
|
+
if (pattern !== void 0 && typeof pattern !== "string") throw new SchemaError(`${path}.pattern: must be a string`);
|
|
307
|
+
const patternFlags = validations["patternFlags"];
|
|
308
|
+
if (patternFlags !== void 0 && !(typeof patternFlags === "string" && VALID_PATTERN_FLAGS.test(patternFlags))) throw new SchemaError(`${path}.patternFlags: must contain only the characters dgimsuvy`);
|
|
309
|
+
const custom = validations["custom"];
|
|
310
|
+
if (custom !== void 0 && typeof custom !== "function") throw new SchemaError(`${path}.custom: must be a function (received ${typeof custom})`);
|
|
311
|
+
}
|
|
312
|
+
function compilePattern({ pattern, flags, path }) {
|
|
313
|
+
try {
|
|
314
|
+
return new RegExp(pattern, flags.replace(/[gy]/g, ""));
|
|
315
|
+
} catch (error) {
|
|
316
|
+
throw new SchemaError(`${path}: invalid pattern — ${errorMessage(error)}`, { cause: error });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Rejects every value in the config that would not survive
|
|
321
|
+
* `JSON.parse(JSON.stringify(x))` — a function included, except at the one
|
|
322
|
+
* permitted location, `validations.custom` on a field.
|
|
323
|
+
*/
|
|
324
|
+
function assertJsonRepresentable({ root }) {
|
|
325
|
+
const stripped = withoutFieldValidators(root);
|
|
326
|
+
const serialized = stringifyOrUndefined(stripped);
|
|
327
|
+
if (serialized === void 0) throw new SchemaError(`${findNonJsonPath({
|
|
328
|
+
value: stripped,
|
|
329
|
+
path: "schema",
|
|
330
|
+
seen: /* @__PURE__ */ new Set()
|
|
331
|
+
}) ?? "schema"}: value cannot be serialized to JSON`);
|
|
332
|
+
const mismatch = firstMismatch({
|
|
333
|
+
actual: stripped,
|
|
334
|
+
expected: JSON.parse(serialized),
|
|
335
|
+
path: "schema"
|
|
336
|
+
});
|
|
337
|
+
if (mismatch) throw new SchemaError(`${mismatch}: value does not survive a JSON round trip; only a field's validations.custom may hold a function, and every other value must be JSON-representable`);
|
|
338
|
+
}
|
|
339
|
+
function withoutFieldValidators(root) {
|
|
340
|
+
if (!isObjectLike$1(root)) return root;
|
|
341
|
+
if (!Array.isArray(root["fields"])) return root;
|
|
342
|
+
const fields = root["fields"];
|
|
343
|
+
return {
|
|
344
|
+
...root,
|
|
345
|
+
fields: fields.map(stripField)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function stripField(field) {
|
|
349
|
+
if (!isObjectLike$1(field)) return field;
|
|
350
|
+
const validations = field["validations"];
|
|
351
|
+
if (!isObjectLike$1(validations) || !("custom" in validations)) return field;
|
|
352
|
+
const { custom: _custom, ...rest } = validations;
|
|
353
|
+
return {
|
|
354
|
+
...field,
|
|
355
|
+
validations: rest
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function stringifyOrUndefined(value) {
|
|
359
|
+
try {
|
|
360
|
+
return JSON.stringify(value);
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/** The path of the first value `JSON.stringify` cannot handle at all. */
|
|
366
|
+
function findNonJsonPath({ value, path, seen }) {
|
|
367
|
+
if (typeof value === "bigint" || typeof value === "symbol") return path;
|
|
368
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
369
|
+
if (seen.has(value)) return path;
|
|
370
|
+
seen.add(value);
|
|
371
|
+
for (const [childPath, child] of childEntries({
|
|
372
|
+
value,
|
|
373
|
+
path
|
|
374
|
+
})) {
|
|
375
|
+
const found = findNonJsonPath({
|
|
376
|
+
value: child,
|
|
377
|
+
path: childPath,
|
|
378
|
+
seen
|
|
379
|
+
});
|
|
380
|
+
if (found) return found;
|
|
381
|
+
}
|
|
382
|
+
seen.delete(value);
|
|
383
|
+
}
|
|
384
|
+
function childEntries({ value, path }) {
|
|
385
|
+
if (Array.isArray(value)) return value.map((item, index) => [`${path}[${index}]`, item]);
|
|
386
|
+
return Object.entries(value).map(([key, item]) => [`${path}.${key}`, item]);
|
|
387
|
+
}
|
|
388
|
+
/** The path of the first value that changed across the round trip. */
|
|
389
|
+
function firstMismatch({ actual, expected, path }) {
|
|
390
|
+
if (Array.isArray(actual) || Array.isArray(expected)) {
|
|
391
|
+
if (!Array.isArray(actual) || !Array.isArray(expected)) return path;
|
|
392
|
+
const actualItems = actual;
|
|
393
|
+
const expectedItems = expected;
|
|
394
|
+
if (actualItems.length !== expectedItems.length) return path;
|
|
395
|
+
for (const [index, item] of actualItems.entries()) {
|
|
396
|
+
const found = firstMismatch({
|
|
397
|
+
actual: item,
|
|
398
|
+
expected: expectedItems[index],
|
|
399
|
+
path: `${path}[${index}]`
|
|
400
|
+
});
|
|
401
|
+
if (found) return found;
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (isJsonObject(actual) && isJsonObject(expected)) {
|
|
406
|
+
const actualKeys = Object.keys(actual);
|
|
407
|
+
const expectedKeys = Object.keys(expected);
|
|
408
|
+
if (actualKeys.length !== expectedKeys.length) {
|
|
409
|
+
const dropped = actualKeys.find((key) => !expectedKeys.includes(key));
|
|
410
|
+
return dropped === void 0 ? path : `${path}.${dropped}`;
|
|
411
|
+
}
|
|
412
|
+
for (const key of actualKeys) {
|
|
413
|
+
const found = firstMismatch({
|
|
414
|
+
actual: actual[key],
|
|
415
|
+
expected: expected[key],
|
|
416
|
+
path: `${path}.${key}`
|
|
417
|
+
});
|
|
418
|
+
if (found) return found;
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
return actual === expected ? void 0 : path;
|
|
423
|
+
}
|
|
424
|
+
function isObjectLike$1(value) {
|
|
425
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
426
|
+
}
|
|
427
|
+
/** Narrower than `isObjectLike`: a `Date`, `RegExp`, or class instance is not one. */
|
|
428
|
+
function isJsonObject(value) {
|
|
429
|
+
if (!isObjectLike$1(value)) return false;
|
|
430
|
+
const prototype = Object.getPrototypeOf(value);
|
|
431
|
+
return prototype === Object.prototype || prototype === null;
|
|
432
|
+
}
|
|
433
|
+
function errorMessage(error) {
|
|
434
|
+
return error instanceof Error ? error.message : String(error);
|
|
435
|
+
}
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/storage.ts
|
|
438
|
+
/** Every form on the origin shares this one entry, indexed by form key. */
|
|
439
|
+
const FORM_DATA_KEY = "EMBEDDABLES-FORM-DATA";
|
|
440
|
+
const LIVE_DOCUMENTS = /* @__PURE__ */ new WeakMap();
|
|
441
|
+
function readDocumentFromStorage({ storage }) {
|
|
442
|
+
try {
|
|
443
|
+
const raw = storage.getItem(FORM_DATA_KEY);
|
|
444
|
+
if (raw === null) return {};
|
|
445
|
+
const parsed = JSON.parse(raw);
|
|
446
|
+
if (!isObjectLike(parsed)) return {};
|
|
447
|
+
return parsed;
|
|
448
|
+
} catch {
|
|
449
|
+
return {};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Load once per storage object; later calls reuse the in-memory document.
|
|
454
|
+
*
|
|
455
|
+
* There is no invalidation: a write from another tab or a user clearing site
|
|
456
|
+
* data is never picked up, and the next write here overwrites it. Recovering
|
|
457
|
+
* from an external mutation means constructing a new storage object.
|
|
458
|
+
*/
|
|
459
|
+
function loadDocument({ storage }) {
|
|
460
|
+
const cached = LIVE_DOCUMENTS.get(storage);
|
|
461
|
+
if (cached) return cached;
|
|
462
|
+
const document = readDocumentFromStorage({ storage });
|
|
463
|
+
LIVE_DOCUMENTS.set(storage, document);
|
|
464
|
+
return document;
|
|
465
|
+
}
|
|
466
|
+
function resolveStorage({ storage }) {
|
|
467
|
+
if (storage) return storage;
|
|
468
|
+
try {
|
|
469
|
+
const candidate = globalThis.localStorage;
|
|
470
|
+
candidate.getItem(FORM_DATA_KEY);
|
|
471
|
+
return candidate;
|
|
472
|
+
} catch {
|
|
473
|
+
return createMemoryStorage();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* A fresh in-memory shim seeded with the document as last read, for an instance
|
|
478
|
+
* whose real storage started throwing mid-session.
|
|
479
|
+
*/
|
|
480
|
+
function degradeToMemory({ storage }) {
|
|
481
|
+
const shim = createMemoryStorage();
|
|
482
|
+
const snapshot = { ...LIVE_DOCUMENTS.get(storage) ?? readDocumentFromStorage({ storage }) };
|
|
483
|
+
shim.setItem(FORM_DATA_KEY, JSON.stringify(snapshot));
|
|
484
|
+
LIVE_DOCUMENTS.set(shim, snapshot);
|
|
485
|
+
return shim;
|
|
486
|
+
}
|
|
487
|
+
function readFields({ storage, formKey }) {
|
|
488
|
+
const bag = loadDocument({ storage })[formKey];
|
|
489
|
+
if (!isObjectLike(bag)) return {};
|
|
490
|
+
return bag;
|
|
491
|
+
}
|
|
492
|
+
function writeFields({ storage, formKey, fields }) {
|
|
493
|
+
const next = {
|
|
494
|
+
...loadDocument({ storage }),
|
|
495
|
+
[formKey]: { ...fields }
|
|
496
|
+
};
|
|
497
|
+
storage.setItem(FORM_DATA_KEY, JSON.stringify(next));
|
|
498
|
+
LIVE_DOCUMENTS.set(storage, next);
|
|
499
|
+
}
|
|
500
|
+
function removeFields({ storage, formKey }) {
|
|
501
|
+
const { [formKey]: _dropped, ...rest } = loadDocument({ storage });
|
|
502
|
+
if (Object.keys(rest).length === 0) {
|
|
503
|
+
storage.removeItem(FORM_DATA_KEY);
|
|
504
|
+
LIVE_DOCUMENTS.set(storage, rest);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
storage.setItem(FORM_DATA_KEY, JSON.stringify(rest));
|
|
508
|
+
LIVE_DOCUMENTS.set(storage, rest);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Whether a value survives `JSON.stringify`. `undefined`, a function, and a
|
|
512
|
+
* `Symbol` make it return `undefined`; a circular reference and a `BigInt` make
|
|
513
|
+
* it throw. All five must be rejected before a write, because a value that
|
|
514
|
+
* cannot stringify aborts a write carrying every form's data.
|
|
515
|
+
*/
|
|
516
|
+
function isSerializable({ value }) {
|
|
517
|
+
try {
|
|
518
|
+
return typeof JSON.stringify(value) === "string";
|
|
519
|
+
} catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function createMemoryStorage() {
|
|
524
|
+
const entries = /* @__PURE__ */ new Map();
|
|
525
|
+
return {
|
|
526
|
+
getItem: (key) => entries.get(key) ?? null,
|
|
527
|
+
setItem: (key, value) => {
|
|
528
|
+
entries.set(key, value);
|
|
529
|
+
},
|
|
530
|
+
removeItem: (key) => {
|
|
531
|
+
entries.delete(key);
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function isObjectLike(value) {
|
|
536
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/validation.ts
|
|
540
|
+
/** Runtime counterpart to `FieldType`. Exhaustive by construction. */
|
|
541
|
+
const FIELD_TYPE_PREDICATES = {
|
|
542
|
+
text: (value) => typeof value === "string",
|
|
543
|
+
email: (value) => typeof value === "string",
|
|
544
|
+
number: (value) => typeof value === "number",
|
|
545
|
+
boolean: (value) => typeof value === "boolean",
|
|
546
|
+
select: (value) => typeof value === "string",
|
|
547
|
+
multiselect: (value) => Array.isArray(value),
|
|
548
|
+
json: () => true
|
|
549
|
+
};
|
|
550
|
+
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])?)*$/;
|
|
551
|
+
/**
|
|
552
|
+
* Every message a single field's value earns. Empty means valid. Not generic:
|
|
553
|
+
* the per-field types live at the instance boundary, and the cast down to
|
|
554
|
+
* `JsonValue` happens once, in `initForm`.
|
|
555
|
+
*/
|
|
556
|
+
function validateValue({ field, value, values, pattern, validator }) {
|
|
557
|
+
const rules = field.validations;
|
|
558
|
+
const messages = [];
|
|
559
|
+
const isAbsent = value === void 0 || value === null;
|
|
560
|
+
const isBlank = isAbsent || value === "" || Array.isArray(value) && value.length === 0;
|
|
561
|
+
if (rules?.required === true && isBlank) messages.push(`${field.label} is required`);
|
|
562
|
+
if (isAbsent) return messages;
|
|
563
|
+
if (!FIELD_TYPE_PREDICATES[field.type](value)) messages.push(`${field.label} expects ${/^[aeiou]/.test(field.type) ? "an" : "a"} ${field.type} value`);
|
|
564
|
+
if (typeof value === "string") {
|
|
565
|
+
if (field.type === "email" && value !== "" && !EMAIL_PATTERN.test(value)) messages.push(`${field.label} must be a valid email address`);
|
|
566
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must be at least ${rules.minLength} characters`);
|
|
567
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must be at most ${rules.maxLength} characters`);
|
|
568
|
+
if (pattern && !pattern.test(value)) messages.push(`${field.label} is not in the expected format`);
|
|
569
|
+
}
|
|
570
|
+
if (Array.isArray(value)) {
|
|
571
|
+
if (rules?.minLength !== void 0 && value.length < rules.minLength) messages.push(`${field.label} must have at least ${rules.minLength} items`);
|
|
572
|
+
if (rules?.maxLength !== void 0 && value.length > rules.maxLength) messages.push(`${field.label} must have at most ${rules.maxLength} items`);
|
|
573
|
+
}
|
|
574
|
+
if (typeof value === "number") {
|
|
575
|
+
if (rules?.min !== void 0 && value < rules.min) messages.push(`${field.label} must be at least ${rules.min}`);
|
|
576
|
+
if (rules?.max !== void 0 && value > rules.max) messages.push(`${field.label} must be at most ${rules.max}`);
|
|
577
|
+
}
|
|
578
|
+
if (rules?.oneOf) {
|
|
579
|
+
const encoded = canonicalize(value);
|
|
580
|
+
if (!rules.oneOf.some((option) => canonicalize(option) === encoded)) messages.push(`${field.label} must be one of the allowed options`);
|
|
581
|
+
}
|
|
582
|
+
if (!validator || messages.length > 0) return messages;
|
|
583
|
+
return normalizeValidatorResult({
|
|
584
|
+
result: validator({
|
|
585
|
+
value,
|
|
586
|
+
values
|
|
587
|
+
}),
|
|
588
|
+
field
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
function normalizeValidatorResult({ result, field }) {
|
|
592
|
+
if (isThenable(result)) throw new ValidatorError(`Validator for "${field.key}" returned a promise; custom validators must be synchronous`);
|
|
593
|
+
if (result === null || result === void 0) return [];
|
|
594
|
+
if (typeof result === "string") return [result];
|
|
595
|
+
if (Array.isArray(result)) return result.filter((entry) => typeof entry === "string");
|
|
596
|
+
throw new ValidatorError(`Validator for "${field.key}" returned ${typeof result}; expected a string, an array of strings, or null`);
|
|
597
|
+
}
|
|
598
|
+
function isThenable(value) {
|
|
599
|
+
return typeof value?.then === "function";
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* JSON encoding with object keys sorted at every depth, so two values compare
|
|
603
|
+
* by content rather than by insertion order.
|
|
604
|
+
*/
|
|
605
|
+
function canonicalize(value) {
|
|
606
|
+
const walk = (input) => {
|
|
607
|
+
if (Array.isArray(input)) return input.map(walk);
|
|
608
|
+
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)]));
|
|
609
|
+
return input;
|
|
610
|
+
};
|
|
611
|
+
return JSON.stringify(walk(value));
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/form.ts
|
|
615
|
+
const REQUIRED_CORE_METHODS = [
|
|
616
|
+
"getAppUserId",
|
|
617
|
+
"getProjectId",
|
|
618
|
+
"getPublishableKey"
|
|
619
|
+
];
|
|
620
|
+
function hasRequiredCoreMethods(value) {
|
|
621
|
+
if (typeof value !== "object" || value === null) return false;
|
|
622
|
+
return REQUIRED_CORE_METHODS.every((method) => typeof value[method] === "function");
|
|
623
|
+
}
|
|
624
|
+
function mergeCustomValidations({ schema, customValidations }) {
|
|
625
|
+
if (!customValidations) return schema;
|
|
626
|
+
const declared = new Set(schema.fields.map((field) => field.key));
|
|
627
|
+
for (const key of Object.keys(customValidations)) {
|
|
628
|
+
if (!declared.has(key)) throw new SchemaError(`customValidations: unknown field key "${key}"`);
|
|
629
|
+
if (typeof customValidations[key] !== "function") throw new SchemaError(`customValidations.${key}: must be a function`);
|
|
630
|
+
}
|
|
631
|
+
const fields = schema.fields.map((field) => {
|
|
632
|
+
const custom = customValidations[field.key];
|
|
633
|
+
if (!custom) return field;
|
|
634
|
+
return {
|
|
635
|
+
...field,
|
|
636
|
+
validations: {
|
|
637
|
+
...field.validations,
|
|
638
|
+
custom
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
return {
|
|
643
|
+
...schema,
|
|
644
|
+
fields
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Validates the core instance once, then returns a per-form `initForm`. Public
|
|
649
|
+
* API — the Miro signature. Consumers pass only `core` and, optionally, an
|
|
650
|
+
* analytics client.
|
|
651
|
+
*/
|
|
652
|
+
function initForms(options) {
|
|
653
|
+
return createFormsClient(options);
|
|
654
|
+
}
|
|
655
|
+
function createFormsClient({ core, analyticsInstance, baseUrl, storage, persistence }) {
|
|
656
|
+
if (!hasRequiredCoreMethods(core)) throw new FormsError("initForms requires an initialized Embeddables core instance.");
|
|
657
|
+
const resolvedPersistence = persistence ?? resolveDefaultPersistence({
|
|
658
|
+
core,
|
|
659
|
+
baseUrl
|
|
660
|
+
});
|
|
661
|
+
return { initForm: (options) => createFormInstance({
|
|
662
|
+
analyticsInstance,
|
|
663
|
+
storage,
|
|
664
|
+
persistence: resolvedPersistence,
|
|
665
|
+
schema: options.schema,
|
|
666
|
+
customValidations: options.customValidations
|
|
667
|
+
}) };
|
|
668
|
+
}
|
|
669
|
+
function createFormInstance({ analyticsInstance, storage, persistence, schema, customValidations }) {
|
|
670
|
+
const resolved = resolveForm({ schema: mergeCustomValidations({
|
|
671
|
+
schema,
|
|
672
|
+
customValidations
|
|
673
|
+
}) });
|
|
674
|
+
const declared = new Map(resolved.fields.map((field) => [field.key, field]));
|
|
675
|
+
const resolvedStorage = resolveStorage({ storage });
|
|
676
|
+
const state = {
|
|
677
|
+
storage: resolvedStorage,
|
|
678
|
+
bag: { ...readFields({
|
|
679
|
+
storage: resolvedStorage,
|
|
680
|
+
formKey: resolved.formKey
|
|
681
|
+
}) },
|
|
682
|
+
errors: /* @__PURE__ */ new Map()
|
|
683
|
+
};
|
|
684
|
+
const firePersistence = (run) => {
|
|
685
|
+
try {
|
|
686
|
+
const result = run();
|
|
687
|
+
if (result instanceof Promise) result.then(void 0, () => void 0);
|
|
688
|
+
} catch {}
|
|
689
|
+
};
|
|
690
|
+
const mergeRecovered = (recovered) => {
|
|
691
|
+
for (const [key, value] of Object.entries(recovered)) if (value !== void 0 && state.bag[key] === void 0) state.bag[key] = value;
|
|
692
|
+
};
|
|
693
|
+
const recoverable = resolved.fields.filter((field) => (field.registryFieldId !== void 0 || field.protocolFieldId !== void 0) && state.bag[field.key] === void 0).map((field) => ({
|
|
694
|
+
key: field.key,
|
|
695
|
+
registryFieldId: field.registryFieldId,
|
|
696
|
+
protocolFieldId: field.protocolFieldId
|
|
697
|
+
}));
|
|
698
|
+
if (recoverable.length > 0) try {
|
|
699
|
+
const result = persistence.recoverRegistryFields({
|
|
700
|
+
formKey: resolved.formKey,
|
|
701
|
+
fields: recoverable
|
|
702
|
+
});
|
|
703
|
+
if (result instanceof Promise) result.then((recovered) => mergeRecovered(recovered ?? {}), () => void 0);
|
|
704
|
+
else mergeRecovered(result);
|
|
705
|
+
} catch {}
|
|
706
|
+
const freeze = (entries) => Object.freeze(Object.fromEntries(entries));
|
|
707
|
+
const noErrors = () => freeze(/* @__PURE__ */ new Map());
|
|
708
|
+
/** The stored bag narrowed to the keys the config declares. */
|
|
709
|
+
const narrow = (bag) => {
|
|
710
|
+
const narrowed = {};
|
|
711
|
+
for (const field of resolved.fields) {
|
|
712
|
+
const value = bag[field.key];
|
|
713
|
+
if (value !== void 0) narrowed[field.key] = value;
|
|
714
|
+
}
|
|
715
|
+
return narrowed;
|
|
716
|
+
};
|
|
717
|
+
const readBag = () => state.bag;
|
|
718
|
+
const validatorFor = (field) => field.validations?.custom;
|
|
719
|
+
const validateDeclaredFields = ({ snapshot, keys }) => {
|
|
720
|
+
const errors = /* @__PURE__ */ new Map();
|
|
721
|
+
for (const key of keys) {
|
|
722
|
+
const field = declared.get(key);
|
|
723
|
+
if (!field) continue;
|
|
724
|
+
const messages = validateValue({
|
|
725
|
+
field,
|
|
726
|
+
value: snapshot[field.key],
|
|
727
|
+
values: snapshot,
|
|
728
|
+
pattern: resolved.patterns.get(key),
|
|
729
|
+
validator: validatorFor(field)
|
|
730
|
+
});
|
|
731
|
+
if (messages.length > 0) errors.set(key, messages);
|
|
732
|
+
}
|
|
733
|
+
return errors;
|
|
734
|
+
};
|
|
735
|
+
const replaceErrors = (errors) => {
|
|
736
|
+
state.errors.clear();
|
|
737
|
+
for (const [key, messages] of errors) state.errors.set(key, messages);
|
|
738
|
+
};
|
|
739
|
+
const applyPatchValidationErrors = ({ errors, patchKeys }) => {
|
|
740
|
+
for (const key of patchKeys) {
|
|
741
|
+
const messages = errors.get(key);
|
|
742
|
+
if (messages) state.errors.set(key, messages);
|
|
743
|
+
else state.errors.delete(key);
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
const set = (patch) => {
|
|
747
|
+
const changes = patch;
|
|
748
|
+
const entries = Object.entries(changes);
|
|
749
|
+
if (entries.length === 0) return Promise.resolve({
|
|
750
|
+
ok: true,
|
|
751
|
+
errors: noErrors()
|
|
752
|
+
});
|
|
753
|
+
const errors = /* @__PURE__ */ new Map();
|
|
754
|
+
for (const [key, value] of entries) {
|
|
755
|
+
const field = declared.get(key);
|
|
756
|
+
if (!field) {
|
|
757
|
+
errors.set(key, [`Unknown field: ${key}`]);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
|
|
761
|
+
}
|
|
762
|
+
const candidate = {
|
|
763
|
+
...readBag(),
|
|
764
|
+
...changes
|
|
765
|
+
};
|
|
766
|
+
const snapshot = narrow(candidate);
|
|
767
|
+
for (const [key, value] of entries) {
|
|
768
|
+
const field = declared.get(key);
|
|
769
|
+
if (!field || errors.has(key)) continue;
|
|
770
|
+
const messages = validateValue({
|
|
771
|
+
field,
|
|
772
|
+
value,
|
|
773
|
+
values: snapshot,
|
|
774
|
+
pattern: resolved.patterns.get(key),
|
|
775
|
+
validator: validatorFor(field)
|
|
776
|
+
});
|
|
777
|
+
if (messages.length > 0) errors.set(key, messages);
|
|
778
|
+
}
|
|
779
|
+
if (errors.size > 0) {
|
|
780
|
+
for (const [key, messages] of errors) state.errors.set(key, messages);
|
|
781
|
+
return Promise.resolve({
|
|
782
|
+
ok: false,
|
|
783
|
+
errors: freeze(errors)
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
for (const [key] of entries) state.errors.delete(key);
|
|
787
|
+
try {
|
|
788
|
+
writeFields({
|
|
789
|
+
storage: state.storage,
|
|
790
|
+
formKey: resolved.formKey,
|
|
791
|
+
fields: candidate
|
|
792
|
+
});
|
|
793
|
+
state.bag = candidate;
|
|
794
|
+
} catch {
|
|
795
|
+
return Promise.resolve(degradeAndReport({ entries }));
|
|
796
|
+
}
|
|
797
|
+
const persistedFields = entries.map(([key, value]) => {
|
|
798
|
+
const field = declared.get(key);
|
|
799
|
+
return {
|
|
800
|
+
key,
|
|
801
|
+
value,
|
|
802
|
+
registryFieldId: field?.registryFieldId,
|
|
803
|
+
protocolFieldId: field?.protocolFieldId
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
firePersistence(() => persistence.savePartial({
|
|
807
|
+
formKey: resolved.formKey,
|
|
808
|
+
values: snapshot
|
|
809
|
+
}));
|
|
810
|
+
firePersistence(() => persistence.saveFields({
|
|
811
|
+
formKey: resolved.formKey,
|
|
812
|
+
fields: persistedFields
|
|
813
|
+
}));
|
|
814
|
+
if (!analyticsInstance) return Promise.resolve({
|
|
815
|
+
ok: true,
|
|
816
|
+
errors: noErrors()
|
|
817
|
+
});
|
|
818
|
+
return analyticsInstance.trackEvent([buildDataUpdatedEvent({
|
|
819
|
+
fields: resolved.fields,
|
|
820
|
+
patch: changes
|
|
821
|
+
}), ...buildFieldUpdatedEvents({
|
|
822
|
+
fields: resolved.fields,
|
|
823
|
+
patch: changes
|
|
824
|
+
})]).then(() => ({
|
|
825
|
+
ok: true,
|
|
826
|
+
errors: noErrors()
|
|
827
|
+
})).catch((error) => ({
|
|
828
|
+
ok: true,
|
|
829
|
+
errors: noErrors(),
|
|
830
|
+
trackError: error
|
|
831
|
+
}));
|
|
832
|
+
};
|
|
833
|
+
const degradeAndReport = ({ entries }) => {
|
|
834
|
+
state.storage = degradeToMemory({ storage: state.storage });
|
|
835
|
+
const errors = /* @__PURE__ */ new Map();
|
|
836
|
+
for (const [key] of entries) {
|
|
837
|
+
const message = `${declared.get(key)?.label ?? key} could not be persisted; this form is now in-memory only`;
|
|
838
|
+
errors.set(key, [message]);
|
|
839
|
+
state.errors.set(key, [message]);
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
ok: false,
|
|
843
|
+
errors: freeze(errors)
|
|
844
|
+
};
|
|
845
|
+
};
|
|
846
|
+
const get = (key) => {
|
|
847
|
+
const fieldKey = key;
|
|
848
|
+
if (!declared.has(fieldKey)) return void 0;
|
|
849
|
+
return state.bag[fieldKey];
|
|
850
|
+
};
|
|
851
|
+
const getAll = () => narrow(state.bag);
|
|
852
|
+
const submit = () => {
|
|
853
|
+
const snapshot = narrow(readBag());
|
|
854
|
+
const values = snapshot;
|
|
855
|
+
const errors = validateDeclaredFields({
|
|
856
|
+
snapshot,
|
|
857
|
+
keys: resolved.fields.map((field) => field.key)
|
|
858
|
+
});
|
|
859
|
+
if (errors.size > 0) {
|
|
860
|
+
replaceErrors(errors);
|
|
861
|
+
return Promise.resolve({
|
|
862
|
+
ok: false,
|
|
863
|
+
errors: freeze(errors),
|
|
864
|
+
values
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
state.errors.clear();
|
|
868
|
+
firePersistence(() => persistence.saveSubmission({
|
|
869
|
+
formKey: resolved.formKey,
|
|
870
|
+
values: snapshot
|
|
871
|
+
}));
|
|
872
|
+
if (!analyticsInstance) return Promise.resolve({
|
|
873
|
+
ok: true,
|
|
874
|
+
errors: noErrors(),
|
|
875
|
+
values
|
|
876
|
+
});
|
|
877
|
+
return analyticsInstance.trackEvent([{
|
|
878
|
+
event_name: "form:submitted",
|
|
879
|
+
form_key: resolved.formKey
|
|
880
|
+
}]).then(() => ({
|
|
881
|
+
ok: true,
|
|
882
|
+
errors: noErrors(),
|
|
883
|
+
values
|
|
884
|
+
})).catch((error) => ({
|
|
885
|
+
ok: true,
|
|
886
|
+
errors: noErrors(),
|
|
887
|
+
values,
|
|
888
|
+
trackError: error
|
|
889
|
+
}));
|
|
890
|
+
};
|
|
891
|
+
const validate = (patch) => {
|
|
892
|
+
if (patch === void 0) {
|
|
893
|
+
const snapshot = narrow(readBag());
|
|
894
|
+
const values = snapshot;
|
|
895
|
+
const errors = validateDeclaredFields({
|
|
896
|
+
snapshot,
|
|
897
|
+
keys: resolved.fields.map((field) => field.key)
|
|
898
|
+
});
|
|
899
|
+
if (errors.size > 0) {
|
|
900
|
+
replaceErrors(errors);
|
|
901
|
+
return Promise.resolve({
|
|
902
|
+
ok: false,
|
|
903
|
+
errors: freeze(errors),
|
|
904
|
+
values
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
state.errors.clear();
|
|
908
|
+
return Promise.resolve({
|
|
909
|
+
ok: true,
|
|
910
|
+
errors: noErrors(),
|
|
911
|
+
values
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
const changes = patch;
|
|
915
|
+
const entries = Object.entries(changes);
|
|
916
|
+
const values = narrow({
|
|
917
|
+
...readBag(),
|
|
918
|
+
...changes
|
|
919
|
+
});
|
|
920
|
+
if (entries.length === 0) return Promise.resolve({
|
|
921
|
+
ok: true,
|
|
922
|
+
errors: noErrors(),
|
|
923
|
+
values
|
|
924
|
+
});
|
|
925
|
+
const errors = /* @__PURE__ */ new Map();
|
|
926
|
+
const patchKeys = entries.map(([key]) => key);
|
|
927
|
+
for (const [key, value] of entries) {
|
|
928
|
+
const field = declared.get(key);
|
|
929
|
+
if (!field) {
|
|
930
|
+
errors.set(key, [`Unknown field: ${key}`]);
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (!isSerializable({ value })) errors.set(key, [`${field.label} value is not JSON-serializable`]);
|
|
934
|
+
}
|
|
935
|
+
const snapshot = narrow({
|
|
936
|
+
...readBag(),
|
|
937
|
+
...changes
|
|
938
|
+
});
|
|
939
|
+
for (const [key] of entries) {
|
|
940
|
+
if (errors.has(key)) continue;
|
|
941
|
+
const field = declared.get(key);
|
|
942
|
+
if (!field) continue;
|
|
943
|
+
const messages = validateValue({
|
|
944
|
+
field,
|
|
945
|
+
value: snapshot[key],
|
|
946
|
+
values: snapshot,
|
|
947
|
+
pattern: resolved.patterns.get(key),
|
|
948
|
+
validator: validatorFor(field)
|
|
949
|
+
});
|
|
950
|
+
if (messages.length > 0) errors.set(key, messages);
|
|
951
|
+
}
|
|
952
|
+
applyPatchValidationErrors({
|
|
953
|
+
errors,
|
|
954
|
+
patchKeys
|
|
955
|
+
});
|
|
956
|
+
if (errors.size > 0) return Promise.resolve({
|
|
957
|
+
ok: false,
|
|
958
|
+
errors: freeze(errors),
|
|
959
|
+
values
|
|
960
|
+
});
|
|
961
|
+
return Promise.resolve({
|
|
962
|
+
ok: true,
|
|
963
|
+
errors: noErrors(),
|
|
964
|
+
values
|
|
965
|
+
});
|
|
966
|
+
};
|
|
967
|
+
const clear = () => {
|
|
968
|
+
removeFields({
|
|
969
|
+
storage: state.storage,
|
|
970
|
+
formKey: resolved.formKey
|
|
971
|
+
});
|
|
972
|
+
state.bag = {};
|
|
973
|
+
state.errors.clear();
|
|
974
|
+
};
|
|
975
|
+
return {
|
|
976
|
+
key: schema.id,
|
|
977
|
+
set,
|
|
978
|
+
get,
|
|
979
|
+
getAll,
|
|
980
|
+
submit,
|
|
981
|
+
validate,
|
|
982
|
+
errors: () => freeze(state.errors),
|
|
983
|
+
clear
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
//#endregion
|
|
987
|
+
export { FORM_DATA_KEY, FormsError, SchemaError, ValidatorError, initForms };
|
|
988
|
+
|
|
989
|
+
//# sourceMappingURL=index.js.map
|