@form-engine-ts/core 1.0.0 → 2.0.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 +34 -0
- package/dist/index.cjs +586 -57
- package/dist/index.d.cts +107 -9
- package/dist/index.d.ts +107 -9
- package/dist/index.js +579 -57
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -86,17 +86,46 @@ function validateSchemaStructure(schema) {
|
|
|
86
86
|
function sanitizeSchema(schema) {
|
|
87
87
|
const existingQuestionIds = new Set(schema.fields.map((field) => field.id));
|
|
88
88
|
const cyclic = cyclicQuestionIds(schema.fields);
|
|
89
|
-
|
|
89
|
+
const sanitizedFields = schema.fields.map((field) => {
|
|
90
|
+
const sourceId = field.displayCondition?.questionId;
|
|
91
|
+
if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
|
|
92
|
+
return field;
|
|
93
|
+
}
|
|
94
|
+
const { displayCondition: _displayCondition, ...sanitized } = field;
|
|
95
|
+
return sanitized;
|
|
96
|
+
});
|
|
97
|
+
const base = {
|
|
90
98
|
...schema,
|
|
91
|
-
fields:
|
|
92
|
-
const sourceId = field.displayCondition?.questionId;
|
|
93
|
-
if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
|
|
94
|
-
return field;
|
|
95
|
-
}
|
|
96
|
-
const { displayCondition: _displayCondition, ...sanitized } = field;
|
|
97
|
-
return sanitized;
|
|
98
|
-
})
|
|
99
|
+
fields: sanitizedFields
|
|
99
100
|
};
|
|
101
|
+
if (schema.pages === void 0) return base;
|
|
102
|
+
const assigned = /* @__PURE__ */ new Set();
|
|
103
|
+
const pages = schema.pages.map((page) => ({
|
|
104
|
+
...page,
|
|
105
|
+
questionIds: page.questionIds.filter((id) => {
|
|
106
|
+
if (!existingQuestionIds.has(id) || assigned.has(id)) return false;
|
|
107
|
+
assigned.add(id);
|
|
108
|
+
return true;
|
|
109
|
+
})
|
|
110
|
+
})).filter((page) => page.questionIds.length > 0);
|
|
111
|
+
if (pages.length === 0) {
|
|
112
|
+
const { pages: _pages, ...singlePage } = base;
|
|
113
|
+
return singlePage;
|
|
114
|
+
}
|
|
115
|
+
const unassigned = schema.fields.map((field) => field.id).filter((id) => !assigned.has(id));
|
|
116
|
+
const completePages = pages.map(
|
|
117
|
+
(page, index) => index === pages.length - 1 ? { ...page, questionIds: [...page.questionIds, ...unassigned] } : page
|
|
118
|
+
);
|
|
119
|
+
const pageIndexByQuestion = new Map(
|
|
120
|
+
completePages.flatMap((page, pageIndex) => page.questionIds.map((id) => [id, pageIndex]))
|
|
121
|
+
);
|
|
122
|
+
const safePages = completePages.map((page, pageIndex) => {
|
|
123
|
+
const sourceIndex = page.displayCondition === void 0 ? void 0 : pageIndexByQuestion.get(page.displayCondition.questionId);
|
|
124
|
+
if (page.displayCondition === void 0 || sourceIndex !== void 0 && sourceIndex < pageIndex) return page;
|
|
125
|
+
const { displayCondition: _displayCondition, ...safePage } = page;
|
|
126
|
+
return safePage;
|
|
127
|
+
});
|
|
128
|
+
return { ...base, pages: safePages };
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
// src/schema.ts
|
|
@@ -111,6 +140,80 @@ function isNonEmptyString(value) {
|
|
|
111
140
|
function issue(issues, path, code, message) {
|
|
112
141
|
issues.push({ path, code, message });
|
|
113
142
|
}
|
|
143
|
+
function validateJsonValue(value, path, issues, ancestors = /* @__PURE__ */ new Set()) {
|
|
144
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
145
|
+
if (typeof value === "number") {
|
|
146
|
+
if (!Number.isFinite(value)) issue(issues, path, "invalid_metadata", "Metadata numbers must be finite.");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (typeof value !== "object") {
|
|
150
|
+
issue(issues, path, "invalid_metadata", "Expected JSON-serializable metadata.");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (ancestors.has(value)) {
|
|
154
|
+
issue(issues, path, "invalid_metadata", "Metadata must not contain cycles.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const nextAncestors = new Set(ancestors).add(value);
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
value.forEach((item, index) => {
|
|
160
|
+
validateJsonValue(item, `${path}[${index}]`, issues, nextAncestors);
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const prototype = Object.getPrototypeOf(value);
|
|
165
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
166
|
+
issue(issues, path, "invalid_metadata", "Metadata objects must be plain JSON objects.");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
for (const [key, item] of Object.entries(value)) {
|
|
170
|
+
validateJsonValue(item, `${path}.${key}`, issues, nextAncestors);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function validateExtensibleNode(value, path, issues) {
|
|
174
|
+
for (const property of ["metadata", "translationMetadata"]) {
|
|
175
|
+
const candidate = value[property];
|
|
176
|
+
if (candidate === void 0) continue;
|
|
177
|
+
if (!isRecord(candidate)) {
|
|
178
|
+
issue(
|
|
179
|
+
issues,
|
|
180
|
+
path.length === 0 ? property : `${path}.${property}`,
|
|
181
|
+
"invalid_metadata",
|
|
182
|
+
"Expected a metadata object."
|
|
183
|
+
);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
validateJsonValue(candidate, path.length === 0 ? property : `${path}.${property}`, issues);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function validateLocalizedTextMap(value, path, issues) {
|
|
190
|
+
if (!isRecord(value)) {
|
|
191
|
+
issue(issues, path, "invalid_translations", "Expected a locale-to-translation object.");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const [locale, translation] of Object.entries(value)) {
|
|
195
|
+
if (!isNonEmptyString(locale) || !isRecord(translation)) {
|
|
196
|
+
issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a translation object.");
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
for (const key of ["title", "description", "completionMessage"]) {
|
|
200
|
+
if (translation[key] !== void 0 && !isNonEmptyString(translation[key])) {
|
|
201
|
+
issue(issues, `${path}.${locale}.${key}`, "invalid_translation", "Expected non-empty translated text.");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function validateOptionTranslations(value, path, issues) {
|
|
207
|
+
if (!isRecord(value)) {
|
|
208
|
+
issue(issues, path, "invalid_translations", "Expected a locale-to-label object.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const [locale, label] of Object.entries(value)) {
|
|
212
|
+
if (!isNonEmptyString(locale) || !isNonEmptyString(label)) {
|
|
213
|
+
issue(issues, `${path}.${locale}`, "invalid_translation", "Expected a non-empty translated label.");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
114
217
|
function rejectLegacyProperties(value, path, properties, issues) {
|
|
115
218
|
for (const property of properties) {
|
|
116
219
|
if (Object.hasOwn(value, property)) {
|
|
@@ -144,6 +247,7 @@ function validateOptions(value, path, issues) {
|
|
|
144
247
|
return;
|
|
145
248
|
}
|
|
146
249
|
rejectLegacyProperties(option, optionPath, ["value", "labelKey"], issues);
|
|
250
|
+
validateExtensibleNode(option, optionPath, issues);
|
|
147
251
|
if (!isNonEmptyString(option.id)) {
|
|
148
252
|
issue(issues, `${optionPath}.id`, "invalid_option_id", "Expected a non-empty option ID.");
|
|
149
253
|
} else if (seen.has(option.id)) {
|
|
@@ -154,6 +258,8 @@ function validateOptions(value, path, issues) {
|
|
|
154
258
|
if (!isNonEmptyString(option.label)) {
|
|
155
259
|
issue(issues, `${optionPath}.label`, "invalid_label", "Expected a non-empty option label.");
|
|
156
260
|
}
|
|
261
|
+
if (option.translations !== void 0)
|
|
262
|
+
validateOptionTranslations(option.translations, `${optionPath}.translations`, issues);
|
|
157
263
|
});
|
|
158
264
|
return true;
|
|
159
265
|
}
|
|
@@ -187,6 +293,7 @@ function validateField(value, path, issues) {
|
|
|
187
293
|
return false;
|
|
188
294
|
}
|
|
189
295
|
rejectLegacyProperties(value, path, ["titleKey", "labelKey", "helpTextKey", "descriptionKey"], issues);
|
|
296
|
+
validateExtensibleNode(value, path, issues);
|
|
190
297
|
if (!isNonEmptyString(value.id)) issue(issues, `${path}.id`, "invalid_id", "Expected a non-empty ID.");
|
|
191
298
|
if (!isNonEmptyString(value.title)) {
|
|
192
299
|
issue(issues, `${path}.title`, "invalid_title", "Expected a non-empty question title.");
|
|
@@ -203,6 +310,7 @@ function validateField(value, path, issues) {
|
|
|
203
310
|
if (value.displayCondition !== void 0) {
|
|
204
311
|
validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
|
|
205
312
|
}
|
|
313
|
+
if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
|
|
206
314
|
if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
|
|
207
315
|
issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
|
|
208
316
|
return false;
|
|
@@ -271,6 +379,7 @@ function validateFormSchema(input) {
|
|
|
271
379
|
return { valid: false, issues: [{ path: "$", code: "invalid_schema", message: "Expected a schema object." }] };
|
|
272
380
|
}
|
|
273
381
|
rejectLegacyProperties(input, "", ["titleKey", "descriptionKey"], issues);
|
|
382
|
+
validateExtensibleNode(input, "", issues);
|
|
274
383
|
if (!isNonEmptyString(input.id)) issue(issues, "id", "invalid_id", "Expected a non-empty ID.");
|
|
275
384
|
if (!Number.isInteger(input.version) || input.version < 1) {
|
|
276
385
|
issue(issues, "version", "invalid_version", "Expected a positive integer version.");
|
|
@@ -278,16 +387,36 @@ function validateFormSchema(input) {
|
|
|
278
387
|
if (!isNonEmptyString(input.title)) {
|
|
279
388
|
issue(issues, "title", "invalid_title", "Expected a non-empty form title.");
|
|
280
389
|
}
|
|
281
|
-
for (const key of ["description", "submitLabelKey"]) {
|
|
390
|
+
for (const key of ["description", "completionMessage", "submitLabelKey"]) {
|
|
282
391
|
if (input[key] !== void 0 && !isNonEmptyString(input[key])) {
|
|
283
392
|
issue(
|
|
284
393
|
issues,
|
|
285
394
|
key,
|
|
286
|
-
key === "
|
|
287
|
-
key === "
|
|
395
|
+
key === "submitLabelKey" ? "invalid_translation_key" : "invalid_description",
|
|
396
|
+
key === "submitLabelKey" ? "Expected a translation key." : "Expected non-empty form text."
|
|
288
397
|
);
|
|
289
398
|
}
|
|
290
399
|
}
|
|
400
|
+
if (input.defaultLocale !== void 0 && !isNonEmptyString(input.defaultLocale)) {
|
|
401
|
+
issue(issues, "defaultLocale", "invalid_locale", "Expected a non-empty default locale.");
|
|
402
|
+
}
|
|
403
|
+
if (input.supportedLocales !== void 0) {
|
|
404
|
+
if (!Array.isArray(input.supportedLocales) || input.supportedLocales.length === 0) {
|
|
405
|
+
issue(issues, "supportedLocales", "invalid_locales", "Expected at least one supported locale.");
|
|
406
|
+
} else {
|
|
407
|
+
const locales = /* @__PURE__ */ new Set();
|
|
408
|
+
input.supportedLocales.forEach((locale, index) => {
|
|
409
|
+
if (!isNonEmptyString(locale)) {
|
|
410
|
+
issue(issues, `supportedLocales[${index}]`, "invalid_locale", "Expected a non-empty locale.");
|
|
411
|
+
} else if (locales.has(locale)) {
|
|
412
|
+
issue(issues, `supportedLocales[${index}]`, "duplicate_locale", "Locales must be unique.");
|
|
413
|
+
} else {
|
|
414
|
+
locales.add(locale);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
|
|
291
420
|
if (!Array.isArray(input.fields) || input.fields.length === 0) {
|
|
292
421
|
issue(issues, "fields", "invalid_fields", "Expected at least one field.");
|
|
293
422
|
} else {
|
|
@@ -314,6 +443,81 @@ function validateFormSchema(input) {
|
|
|
314
443
|
const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
|
|
315
444
|
issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
|
|
316
445
|
}
|
|
446
|
+
if (input.pages !== void 0) {
|
|
447
|
+
if (!Array.isArray(input.pages) || input.pages.length === 0) {
|
|
448
|
+
issue(issues, "pages", "invalid_pages", "Expected at least one page when pages is defined.");
|
|
449
|
+
} else {
|
|
450
|
+
const pageIds = /* @__PURE__ */ new Set();
|
|
451
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
452
|
+
input.pages.forEach((page, pageIndex) => {
|
|
453
|
+
const pagePath = `pages[${pageIndex}]`;
|
|
454
|
+
if (!isRecord(page)) {
|
|
455
|
+
issue(issues, pagePath, "invalid_page", "Expected a page object.");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
validateExtensibleNode(page, pagePath, issues);
|
|
459
|
+
if (!isNonEmptyString(page.id)) {
|
|
460
|
+
issue(issues, `${pagePath}.id`, "invalid_page_id", "Expected a non-empty page ID.");
|
|
461
|
+
} else if (pageIds.has(page.id)) {
|
|
462
|
+
issue(issues, `${pagePath}.id`, "duplicate_page", "Page IDs must be unique.");
|
|
463
|
+
} else {
|
|
464
|
+
pageIds.add(page.id);
|
|
465
|
+
}
|
|
466
|
+
for (const key of ["title", "description"]) {
|
|
467
|
+
if (page[key] !== void 0 && !isNonEmptyString(page[key])) {
|
|
468
|
+
issue(issues, `${pagePath}.${key}`, "invalid_page_text", "Expected non-empty page text.");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
if (page.translations !== void 0) {
|
|
472
|
+
validateLocalizedTextMap(page.translations, `${pagePath}.translations`, issues);
|
|
473
|
+
}
|
|
474
|
+
if (page.displayCondition !== void 0) {
|
|
475
|
+
validateDisplayCondition(page.displayCondition, `${pagePath}.displayCondition`, issues);
|
|
476
|
+
}
|
|
477
|
+
if (!Array.isArray(page.questionIds) || page.questionIds.length === 0) {
|
|
478
|
+
issue(issues, `${pagePath}.questionIds`, "invalid_page_questions", "Expected at least one question ID.");
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
page.questionIds.forEach((questionId, questionIndex) => {
|
|
482
|
+
const questionPath = `${pagePath}.questionIds[${questionIndex}]`;
|
|
483
|
+
if (!isNonEmptyString(questionId)) {
|
|
484
|
+
issue(issues, questionPath, "invalid_question_reference", "Expected a question ID.");
|
|
485
|
+
} else if (!ids.has(questionId)) {
|
|
486
|
+
issue(issues, questionPath, "unknown_page_question", "Page references an unknown question.");
|
|
487
|
+
} else if (assigned.has(questionId)) {
|
|
488
|
+
issue(issues, questionPath, "duplicate_page_question", "A question may belong to only one page.");
|
|
489
|
+
} else {
|
|
490
|
+
assigned.set(questionId, pageIndex);
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
for (const fieldId of ids) {
|
|
495
|
+
if (!assigned.has(fieldId))
|
|
496
|
+
issue(issues, "pages", "unassigned_page_question", `Question ${fieldId} has no page.`);
|
|
497
|
+
}
|
|
498
|
+
input.pages.forEach((page, pageIndex) => {
|
|
499
|
+
if (!isRecord(page) || !isRecord(page.displayCondition)) return;
|
|
500
|
+
const sourceId = page.displayCondition.questionId;
|
|
501
|
+
if (typeof sourceId !== "string") return;
|
|
502
|
+
const sourcePageIndex = assigned.get(sourceId);
|
|
503
|
+
if (sourcePageIndex === void 0) {
|
|
504
|
+
issue(
|
|
505
|
+
issues,
|
|
506
|
+
`pages[${pageIndex}].displayCondition.questionId`,
|
|
507
|
+
"unknown_page_condition_source",
|
|
508
|
+
"Page condition references an unknown question."
|
|
509
|
+
);
|
|
510
|
+
} else if (sourcePageIndex >= pageIndex) {
|
|
511
|
+
issue(
|
|
512
|
+
issues,
|
|
513
|
+
`pages[${pageIndex}].displayCondition.questionId`,
|
|
514
|
+
"forward_page_condition",
|
|
515
|
+
"Page conditions must reference a question on an earlier page."
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
317
521
|
}
|
|
318
522
|
return issues.length === 0 ? { valid: true, value: input, issues: [] } : { valid: false, issues };
|
|
319
523
|
}
|
|
@@ -341,7 +545,9 @@ function valuesEqual(left, right) {
|
|
|
341
545
|
return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
|
|
342
546
|
}
|
|
343
547
|
function isQuestionVisible(question, currentAnswers) {
|
|
344
|
-
|
|
548
|
+
return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
|
|
549
|
+
}
|
|
550
|
+
function isDisplayConditionSatisfied(condition, currentAnswers) {
|
|
345
551
|
if (condition === void 0) return true;
|
|
346
552
|
const answer = currentAnswers[condition.questionId];
|
|
347
553
|
if (isEmpty(answer)) return false;
|
|
@@ -354,7 +560,7 @@ function isQuestionVisible(question, currentAnswers) {
|
|
|
354
560
|
}
|
|
355
561
|
return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
|
|
356
562
|
}
|
|
357
|
-
function
|
|
563
|
+
function calculateBaseFieldVisibility(schema, currentAnswers) {
|
|
358
564
|
const fields = new Map(schema.fields.map((field) => [field.id, field]));
|
|
359
565
|
const resolved = /* @__PURE__ */ new Map();
|
|
360
566
|
const resolving = /* @__PURE__ */ new Set();
|
|
@@ -372,7 +578,34 @@ function calculateFieldVisibility(schema, currentAnswers) {
|
|
|
372
578
|
return visible;
|
|
373
579
|
};
|
|
374
580
|
for (const field of schema.fields) resolve(field);
|
|
375
|
-
return Object.
|
|
581
|
+
return Object.fromEntries(resolved);
|
|
582
|
+
}
|
|
583
|
+
function calculatePageVisibility(schema, currentAnswers) {
|
|
584
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return {};
|
|
585
|
+
const baseFieldVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
|
|
586
|
+
const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
|
|
587
|
+
const pageVisibility = {};
|
|
588
|
+
for (const page of schema.pages) {
|
|
589
|
+
const sourceId = page.displayCondition?.questionId;
|
|
590
|
+
const sourcePageId = sourceId === void 0 ? void 0 : pageByQuestion.get(sourceId);
|
|
591
|
+
const sourceVisible = sourceId === void 0 || baseFieldVisibility[sourceId] === true && sourcePageId !== void 0 && pageVisibility[sourcePageId] === true;
|
|
592
|
+
pageVisibility[page.id] = sourceVisible && isDisplayConditionSatisfied(page.displayCondition, currentAnswers);
|
|
593
|
+
}
|
|
594
|
+
return Object.freeze(pageVisibility);
|
|
595
|
+
}
|
|
596
|
+
function calculateFieldVisibility(schema, currentAnswers) {
|
|
597
|
+
const baseVisibility = calculateBaseFieldVisibility(schema, currentAnswers);
|
|
598
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return Object.freeze(baseVisibility);
|
|
599
|
+
const pageVisibility = calculatePageVisibility(schema, currentAnswers);
|
|
600
|
+
const pageByQuestion = new Map(schema.pages.flatMap((page) => page.questionIds.map((id) => [id, page.id])));
|
|
601
|
+
return Object.freeze(
|
|
602
|
+
Object.fromEntries(
|
|
603
|
+
schema.fields.map((field) => {
|
|
604
|
+
const pageId = pageByQuestion.get(field.id);
|
|
605
|
+
return [field.id, baseVisibility[field.id] === true && pageId !== void 0 && pageVisibility[pageId] === true];
|
|
606
|
+
})
|
|
607
|
+
)
|
|
608
|
+
);
|
|
376
609
|
}
|
|
377
610
|
function selectVisibleAnswers(schema, currentAnswers) {
|
|
378
611
|
const visibility = calculateFieldVisibility(schema, currentAnswers);
|
|
@@ -406,6 +639,23 @@ function calculateNumericSummary(responses, questionId) {
|
|
|
406
639
|
max: numbers.length === 0 ? null : Math.max(...numbers)
|
|
407
640
|
};
|
|
408
641
|
}
|
|
642
|
+
function calculateCrossTabulation(responses, rowQuestionId, colQuestionId) {
|
|
643
|
+
const matrix = {};
|
|
644
|
+
const rowTotals = {};
|
|
645
|
+
const colTotals = {};
|
|
646
|
+
let grandTotal = 0;
|
|
647
|
+
for (const response of responses) {
|
|
648
|
+
const row = response.values[rowQuestionId];
|
|
649
|
+
const col = response.values[colQuestionId];
|
|
650
|
+
if (typeof row !== "string" || row.length === 0 || typeof col !== "string" || col.length === 0) continue;
|
|
651
|
+
matrix[row] ??= {};
|
|
652
|
+
matrix[row][col] = (matrix[row][col] ?? 0) + 1;
|
|
653
|
+
rowTotals[row] = (rowTotals[row] ?? 0) + 1;
|
|
654
|
+
colTotals[col] = (colTotals[col] ?? 0) + 1;
|
|
655
|
+
grandTotal += 1;
|
|
656
|
+
}
|
|
657
|
+
return { rowQuestionId, colQuestionId, matrix, rowTotals, colTotals, grandTotal };
|
|
658
|
+
}
|
|
409
659
|
function valueIsValid(field, value) {
|
|
410
660
|
if (value === void 0 || value === "") return false;
|
|
411
661
|
if (field.type === "text" || field.type === "textarea") {
|
|
@@ -497,15 +747,20 @@ function aggregateResponses(schema, submissions) {
|
|
|
497
747
|
questions: schema.fields.map((field) => aggregateField(schema, field, submissions))
|
|
498
748
|
};
|
|
499
749
|
}
|
|
500
|
-
function escapeCsvCell(value) {
|
|
750
|
+
function escapeCsvCell(value, neutralizeFormulas = true) {
|
|
501
751
|
if (value === null || value === void 0) return "";
|
|
502
|
-
|
|
752
|
+
let stringValue = String(value);
|
|
753
|
+
if (neutralizeFormulas && typeof value === "string") {
|
|
754
|
+
const trimmed = stringValue.trimStart();
|
|
755
|
+
if (trimmed.length > 0 && ["=", "+", "-", "@"].includes(trimmed[0] ?? "")) {
|
|
756
|
+
stringValue = `'${stringValue}`;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
503
759
|
return /[",\r\n]/.test(stringValue) ? `"${stringValue.replaceAll('"', '""')}"` : stringValue;
|
|
504
760
|
}
|
|
505
761
|
function serializeValue(value) {
|
|
506
762
|
if (value === void 0) return "";
|
|
507
|
-
|
|
508
|
-
return String(value);
|
|
763
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : JSON.stringify(value);
|
|
509
764
|
}
|
|
510
765
|
function exportResponsesToCsv(schema, responses, options = {}) {
|
|
511
766
|
assertValidFormSchema(schema);
|
|
@@ -526,10 +781,52 @@ function exportResponsesToCsv(schema, responses, options = {}) {
|
|
|
526
781
|
];
|
|
527
782
|
})
|
|
528
783
|
];
|
|
529
|
-
const
|
|
784
|
+
const neutralizeFormulas = options.neutralizeFormulas ?? true;
|
|
785
|
+
const csv = rows.map((row) => row.map((cell) => escapeCsvCell(cell, neutralizeFormulas)).join(",")).join("\r\n");
|
|
530
786
|
return options.withBom ?? true ? `\uFEFF${csv}` : csv;
|
|
531
787
|
}
|
|
532
788
|
|
|
789
|
+
// src/events.ts
|
|
790
|
+
function bytesToHex(bytes) {
|
|
791
|
+
return [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
792
|
+
}
|
|
793
|
+
async function signPayload(payload, secret) {
|
|
794
|
+
if (globalThis.crypto?.subtle === void 0) throw new Error("Web Crypto is unavailable for webhook signing.");
|
|
795
|
+
const encoder = new TextEncoder();
|
|
796
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
797
|
+
"raw",
|
|
798
|
+
encoder.encode(secret),
|
|
799
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
800
|
+
false,
|
|
801
|
+
["sign"]
|
|
802
|
+
);
|
|
803
|
+
return bytesToHex(await globalThis.crypto.subtle.sign("HMAC", key, encoder.encode(payload)));
|
|
804
|
+
}
|
|
805
|
+
async function dispatchWebhook(event, config, fetchImpl = globalThis.fetch) {
|
|
806
|
+
const body = JSON.stringify(event);
|
|
807
|
+
const controller = new AbortController();
|
|
808
|
+
const timeoutMs = config.timeoutMs ?? 5e3;
|
|
809
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs);
|
|
810
|
+
try {
|
|
811
|
+
const headers = { "content-type": "application/json", ...config.headers };
|
|
812
|
+
if (config.secret !== void 0) {
|
|
813
|
+
headers["X-Form-Engine-Signature"] = await signPayload(body, config.secret);
|
|
814
|
+
}
|
|
815
|
+
const response = await fetchImpl(config.url, {
|
|
816
|
+
method: "POST",
|
|
817
|
+
headers,
|
|
818
|
+
body,
|
|
819
|
+
signal: controller.signal
|
|
820
|
+
});
|
|
821
|
+
return response.ok ? { success: true, status: response.status } : { success: false, status: response.status, error: `Webhook returned HTTP ${response.status}.` };
|
|
822
|
+
} catch (cause) {
|
|
823
|
+
const error = controller.signal.aborted ? `Webhook request timed out after ${timeoutMs}ms.` : cause instanceof Error ? cause.message : String(cause);
|
|
824
|
+
return { success: false, error };
|
|
825
|
+
} finally {
|
|
826
|
+
globalThis.clearTimeout(timeout);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
533
830
|
// src/validation.ts
|
|
534
831
|
var DEFAULT_MESSAGES = {
|
|
535
832
|
required: "validation.required",
|
|
@@ -656,6 +953,18 @@ function validateAnswers(schema, values) {
|
|
|
656
953
|
}
|
|
657
954
|
return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
|
|
658
955
|
}
|
|
956
|
+
function validatePageAnswers(schema, pageIndex, values) {
|
|
957
|
+
if (schema.pages === void 0 || schema.pages.length === 0) return validateAnswers(schema, values);
|
|
958
|
+
const page = schema.pages[pageIndex];
|
|
959
|
+
if (page === void 0) return { valid: true, issues: [] };
|
|
960
|
+
const targetIds = new Set(page.questionIds);
|
|
961
|
+
const visibility = calculateFieldVisibility(schema, values);
|
|
962
|
+
const issues = [];
|
|
963
|
+
for (const field of schema.fields) {
|
|
964
|
+
if (targetIds.has(field.id) && visibility[field.id] === true) validateField2(field, values[field.id], issues);
|
|
965
|
+
}
|
|
966
|
+
return issues.length === 0 ? { valid: true, issues: [] } : { valid: false, issues };
|
|
967
|
+
}
|
|
659
968
|
|
|
660
969
|
// src/submission.ts
|
|
661
970
|
function cloneValues(values) {
|
|
@@ -683,67 +992,280 @@ function createSubmission(schema, values, options) {
|
|
|
683
992
|
formVersion: schema.version,
|
|
684
993
|
locale: options.locale,
|
|
685
994
|
values: Object.freeze(cloneValues(visibleValues)),
|
|
686
|
-
submittedAt: options.submittedAt
|
|
995
|
+
submittedAt: options.submittedAt,
|
|
996
|
+
...options.metadata === void 0 ? {} : { metadata: Object.freeze({ ...options.metadata }) },
|
|
997
|
+
...options.translationMetadata === void 0 ? {} : { translationMetadata: Object.freeze({ ...options.translationMetadata }) }
|
|
687
998
|
});
|
|
688
999
|
}
|
|
689
1000
|
|
|
690
1001
|
// src/translation.ts
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
const next = () => {
|
|
706
|
-
const value = translated[index];
|
|
707
|
-
index += 1;
|
|
708
|
-
if (value === void 0) throw new Error("Translation adapter returned an incomplete result.");
|
|
709
|
-
return value;
|
|
1002
|
+
function mergeLocalizedText(translations, locale, property, value) {
|
|
1003
|
+
return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
|
|
1004
|
+
}
|
|
1005
|
+
function withTranslationMetadata(node, locale, property, metadata) {
|
|
1006
|
+
if (metadata === void 0) return node;
|
|
1007
|
+
return {
|
|
1008
|
+
...node,
|
|
1009
|
+
translationMetadata: {
|
|
1010
|
+
...node.translationMetadata,
|
|
1011
|
+
[locale]: {
|
|
1012
|
+
...node.translationMetadata?.[locale],
|
|
1013
|
+
[property]: metadata
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
710
1016
|
};
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1017
|
+
}
|
|
1018
|
+
function createSlot(kind, nodeId, property, locale, sourceText, existingText, metadata) {
|
|
1019
|
+
return {
|
|
1020
|
+
kind,
|
|
1021
|
+
nodeId,
|
|
1022
|
+
property,
|
|
1023
|
+
locale,
|
|
1024
|
+
sourceText,
|
|
1025
|
+
...existingText === void 0 ? {} : { existingText },
|
|
1026
|
+
...metadata === void 0 ? {} : { metadata }
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function translationSlots(schema, locale) {
|
|
1030
|
+
const descriptors = [];
|
|
1031
|
+
const addFormSlot = (property, sourceText) => {
|
|
1032
|
+
const slot = createSlot(
|
|
1033
|
+
"form",
|
|
1034
|
+
schema.id,
|
|
1035
|
+
property,
|
|
1036
|
+
locale,
|
|
1037
|
+
sourceText,
|
|
1038
|
+
schema.translations?.[locale]?.[property],
|
|
1039
|
+
schema.metadata
|
|
1040
|
+
);
|
|
1041
|
+
descriptors.push({
|
|
1042
|
+
slot,
|
|
1043
|
+
apply: (current, value, metadata) => withTranslationMetadata(
|
|
1044
|
+
{ ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
|
|
1045
|
+
locale,
|
|
1046
|
+
property,
|
|
1047
|
+
metadata
|
|
1048
|
+
)
|
|
1049
|
+
});
|
|
1050
|
+
};
|
|
1051
|
+
addFormSlot("title", schema.title);
|
|
1052
|
+
if (schema.description !== void 0) addFormSlot("description", schema.description);
|
|
1053
|
+
if (schema.completionMessage !== void 0) addFormSlot("completionMessage", schema.completionMessage);
|
|
1054
|
+
schema.fields.forEach((field, fieldIndex) => {
|
|
1055
|
+
const addFieldSlot = (property, sourceText) => {
|
|
1056
|
+
const slot = createSlot(
|
|
1057
|
+
"field",
|
|
1058
|
+
field.id,
|
|
1059
|
+
property,
|
|
1060
|
+
locale,
|
|
1061
|
+
sourceText,
|
|
1062
|
+
field.translations?.[locale]?.[property],
|
|
1063
|
+
field.metadata
|
|
1064
|
+
);
|
|
1065
|
+
descriptors.push({
|
|
1066
|
+
slot,
|
|
1067
|
+
apply: (current, value, metadata) => ({
|
|
1068
|
+
...current,
|
|
1069
|
+
fields: current.fields.map(
|
|
1070
|
+
(candidate, index) => index === fieldIndex ? withTranslationMetadata(
|
|
1071
|
+
{
|
|
1072
|
+
...candidate,
|
|
1073
|
+
translations: mergeLocalizedText(candidate.translations, locale, property, value)
|
|
1074
|
+
},
|
|
1075
|
+
locale,
|
|
1076
|
+
property,
|
|
1077
|
+
metadata
|
|
1078
|
+
) : candidate
|
|
1079
|
+
)
|
|
1080
|
+
})
|
|
1081
|
+
});
|
|
1082
|
+
};
|
|
1083
|
+
addFieldSlot("title", field.title);
|
|
1084
|
+
if (field.description !== void 0) addFieldSlot("description", field.description);
|
|
1085
|
+
if ("options" in field) {
|
|
1086
|
+
field.options.forEach((option, optionIndex) => {
|
|
1087
|
+
const slot = createSlot(
|
|
1088
|
+
"option",
|
|
1089
|
+
option.id,
|
|
1090
|
+
"label",
|
|
1091
|
+
locale,
|
|
1092
|
+
option.label,
|
|
1093
|
+
option.translations?.[locale],
|
|
1094
|
+
option.metadata
|
|
1095
|
+
);
|
|
1096
|
+
descriptors.push({
|
|
1097
|
+
slot,
|
|
1098
|
+
apply: (current, value, metadata) => ({
|
|
1099
|
+
...current,
|
|
1100
|
+
fields: current.fields.map((candidate, candidateIndex) => {
|
|
1101
|
+
if (candidateIndex !== fieldIndex || !("options" in candidate)) return candidate;
|
|
1102
|
+
return {
|
|
1103
|
+
...candidate,
|
|
1104
|
+
options: candidate.options.map(
|
|
1105
|
+
(candidateOption, candidateOptionIndex) => candidateOptionIndex === optionIndex ? withTranslationMetadata(
|
|
1106
|
+
{
|
|
1107
|
+
...candidateOption,
|
|
1108
|
+
translations: { ...candidateOption.translations, [locale]: value }
|
|
1109
|
+
},
|
|
1110
|
+
locale,
|
|
1111
|
+
"label",
|
|
1112
|
+
metadata
|
|
1113
|
+
) : candidateOption
|
|
1114
|
+
)
|
|
1115
|
+
};
|
|
1116
|
+
})
|
|
1117
|
+
})
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
schema.pages?.forEach((page, pageIndex) => {
|
|
1123
|
+
const addPageSlot = (property, sourceText) => {
|
|
1124
|
+
const slot = createSlot(
|
|
1125
|
+
"page",
|
|
1126
|
+
page.id,
|
|
1127
|
+
property,
|
|
1128
|
+
locale,
|
|
1129
|
+
sourceText,
|
|
1130
|
+
page.translations?.[locale]?.[property],
|
|
1131
|
+
page.metadata
|
|
1132
|
+
);
|
|
1133
|
+
descriptors.push({
|
|
1134
|
+
slot,
|
|
1135
|
+
apply: (current, value, metadata) => ({
|
|
1136
|
+
...current,
|
|
1137
|
+
...current.pages === void 0 ? {} : {
|
|
1138
|
+
pages: current.pages.map(
|
|
1139
|
+
(candidate, index) => index === pageIndex ? withTranslationMetadata(
|
|
1140
|
+
{
|
|
1141
|
+
...candidate,
|
|
1142
|
+
translations: mergeLocalizedText(candidate.translations, locale, property, value)
|
|
1143
|
+
},
|
|
1144
|
+
locale,
|
|
1145
|
+
property,
|
|
1146
|
+
metadata
|
|
1147
|
+
) : candidate
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
})
|
|
1151
|
+
});
|
|
720
1152
|
};
|
|
721
|
-
if (
|
|
722
|
-
|
|
1153
|
+
if (page.title !== void 0) addPageSlot("title", page.title);
|
|
1154
|
+
if (page.description !== void 0) addPageSlot("description", page.description);
|
|
723
1155
|
});
|
|
724
|
-
|
|
1156
|
+
return descriptors;
|
|
1157
|
+
}
|
|
1158
|
+
function resolveLocalizedSchema(schema, targetLocale) {
|
|
1159
|
+
if (targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
|
|
1160
|
+
const formTranslation = schema.translations?.[targetLocale];
|
|
1161
|
+
const completionMessage = formTranslation?.completionMessage ?? schema.completionMessage;
|
|
1162
|
+
return {
|
|
725
1163
|
...schema,
|
|
726
|
-
title,
|
|
727
|
-
...description === void 0 ? {} : { description },
|
|
728
|
-
|
|
1164
|
+
title: formTranslation?.title ?? schema.title,
|
|
1165
|
+
...(formTranslation?.description ?? schema.description) === void 0 ? {} : { description: formTranslation?.description ?? schema.description },
|
|
1166
|
+
...completionMessage === void 0 ? {} : { completionMessage },
|
|
1167
|
+
fields: schema.fields.map((field) => {
|
|
1168
|
+
const translation = field.translations?.[targetLocale];
|
|
1169
|
+
const localized = {
|
|
1170
|
+
...field,
|
|
1171
|
+
title: translation?.title ?? field.title,
|
|
1172
|
+
...(translation?.description ?? field.description) === void 0 ? {} : { description: translation?.description ?? field.description }
|
|
1173
|
+
};
|
|
1174
|
+
if (!("options" in field)) return localized;
|
|
1175
|
+
return {
|
|
1176
|
+
...localized,
|
|
1177
|
+
options: field.options.map((option) => ({
|
|
1178
|
+
...option,
|
|
1179
|
+
label: option.translations?.[targetLocale] ?? option.label
|
|
1180
|
+
}))
|
|
1181
|
+
};
|
|
1182
|
+
}),
|
|
1183
|
+
...schema.pages === void 0 ? {} : {
|
|
1184
|
+
pages: schema.pages.map((page) => {
|
|
1185
|
+
const translation = page.translations?.[targetLocale];
|
|
1186
|
+
const title = translation?.title ?? page.title;
|
|
1187
|
+
const description = translation?.description ?? page.description;
|
|
1188
|
+
return {
|
|
1189
|
+
...page,
|
|
1190
|
+
...title === void 0 ? {} : { title },
|
|
1191
|
+
...description === void 0 ? {} : { description }
|
|
1192
|
+
};
|
|
1193
|
+
})
|
|
1194
|
+
}
|
|
729
1195
|
};
|
|
730
|
-
|
|
731
|
-
|
|
1196
|
+
}
|
|
1197
|
+
async function populateSchemaTranslations(schema, targetLocales, adapter, options = {}) {
|
|
1198
|
+
assertValidFormSchema(schema);
|
|
1199
|
+
const locales = [...new Set(targetLocales.filter((locale) => locale.length > 0 && locale !== schema.defaultLocale))];
|
|
1200
|
+
const updatedSlots = [];
|
|
1201
|
+
const skippedSlots = [];
|
|
1202
|
+
let result = schema;
|
|
1203
|
+
for (const locale of locales) {
|
|
1204
|
+
const descriptors = translationSlots(schema, locale);
|
|
1205
|
+
const selected = [];
|
|
1206
|
+
for (const descriptor of descriptors) {
|
|
1207
|
+
const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
|
|
1208
|
+
if (shouldTranslate) selected.push(descriptor);
|
|
1209
|
+
else skippedSlots.push(descriptor.slot);
|
|
1210
|
+
}
|
|
1211
|
+
if (selected.length === 0) continue;
|
|
1212
|
+
const translated = await adapter.translateBatch(
|
|
1213
|
+
selected.map((descriptor) => descriptor.slot.sourceText),
|
|
1214
|
+
locale,
|
|
1215
|
+
schema.defaultLocale
|
|
1216
|
+
);
|
|
1217
|
+
if (translated.length !== selected.length) {
|
|
1218
|
+
throw new Error(`Translation adapter returned ${translated.length} texts for ${selected.length} inputs.`);
|
|
1219
|
+
}
|
|
1220
|
+
selected.forEach((descriptor, index) => {
|
|
1221
|
+
const translatedText = translated[index];
|
|
1222
|
+
if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
|
|
1223
|
+
const metadata = options.createMetadata?.(descriptor.slot, translatedText);
|
|
1224
|
+
result = descriptor.apply(result, translatedText, metadata);
|
|
1225
|
+
updatedSlots.push(descriptor.slot);
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
const supportedLocales = [
|
|
1229
|
+
.../* @__PURE__ */ new Set([
|
|
1230
|
+
...schema.defaultLocale === void 0 ? [] : [schema.defaultLocale],
|
|
1231
|
+
...schema.supportedLocales ?? [],
|
|
1232
|
+
...locales
|
|
1233
|
+
])
|
|
1234
|
+
];
|
|
1235
|
+
if (supportedLocales.length > 0) result = { ...result, supportedLocales };
|
|
1236
|
+
assertValidFormSchema(result);
|
|
1237
|
+
return { schema: result, report: { updatedSlots, skippedSlots } };
|
|
1238
|
+
}
|
|
1239
|
+
async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
|
|
1240
|
+
const populated = await populateSchemaTranslations(
|
|
1241
|
+
sourceLocale === void 0 ? schema : { ...schema, defaultLocale: sourceLocale },
|
|
1242
|
+
[targetLocale],
|
|
1243
|
+
adapter,
|
|
1244
|
+
{ overwrite: "all" }
|
|
1245
|
+
);
|
|
1246
|
+
return resolveLocalizedSchema(populated.schema, targetLocale);
|
|
732
1247
|
}
|
|
733
1248
|
export {
|
|
734
1249
|
aggregateResponses,
|
|
735
1250
|
assertValidFormSchema,
|
|
736
1251
|
calculateChoiceDistribution,
|
|
1252
|
+
calculateCrossTabulation,
|
|
737
1253
|
calculateFieldVisibility,
|
|
738
1254
|
calculateNumericSummary,
|
|
1255
|
+
calculatePageVisibility,
|
|
739
1256
|
createSubmission,
|
|
1257
|
+
dispatchWebhook,
|
|
740
1258
|
escapeCsvCell,
|
|
741
1259
|
exportResponsesToCsv,
|
|
1260
|
+
isDisplayConditionSatisfied,
|
|
742
1261
|
isQuestionVisible,
|
|
1262
|
+
populateSchemaTranslations,
|
|
743
1263
|
resolveFormTranslation,
|
|
1264
|
+
resolveLocalizedSchema,
|
|
744
1265
|
sanitizeSchema,
|
|
745
1266
|
selectVisibleAnswers,
|
|
746
1267
|
validateAnswers,
|
|
747
1268
|
validateFormSchema,
|
|
1269
|
+
validatePageAnswers,
|
|
748
1270
|
validateSchemaStructure
|
|
749
1271
|
};
|