@frt-platform/report-core 1.2.0 → 1.3.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 +255 -392
- package/dist/index.d.mts +127 -95
- package/dist/index.d.ts +127 -95
- package/dist/index.js +506 -398
- package/dist/index.mjs +508 -398
- package/package.json +7 -3
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/schema.ts
|
|
1
|
+
// src/template/schema.ts
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
var REPORT_TEMPLATE_VERSION = 1;
|
|
4
4
|
var REPORT_TEMPLATE_FIELD_TYPES = [
|
|
@@ -123,59 +123,7 @@ var ReportTemplateSchemaValidator = z.object({
|
|
|
123
123
|
sections: z.array(ReportTemplateSectionSchema).max(25, "Templates can include at most 25 sections.").default([])
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
-
// src/
|
|
127
|
-
var DEFAULT_FIELD_LABEL = "Untitled question";
|
|
128
|
-
var CORE_FIELD_DEFAULTS = {
|
|
129
|
-
shortText: {
|
|
130
|
-
label: DEFAULT_FIELD_LABEL,
|
|
131
|
-
placeholder: "Short answer text"
|
|
132
|
-
},
|
|
133
|
-
longText: {
|
|
134
|
-
label: DEFAULT_FIELD_LABEL,
|
|
135
|
-
placeholder: "Long answer text"
|
|
136
|
-
},
|
|
137
|
-
number: {
|
|
138
|
-
label: DEFAULT_FIELD_LABEL,
|
|
139
|
-
placeholder: "123"
|
|
140
|
-
},
|
|
141
|
-
date: {
|
|
142
|
-
label: DEFAULT_FIELD_LABEL
|
|
143
|
-
},
|
|
144
|
-
checkbox: {
|
|
145
|
-
label: DEFAULT_FIELD_LABEL,
|
|
146
|
-
placeholder: "Check to confirm"
|
|
147
|
-
},
|
|
148
|
-
singleSelect: {
|
|
149
|
-
label: DEFAULT_FIELD_LABEL,
|
|
150
|
-
options: ["Option 1", "Option 2", "Option 3"]
|
|
151
|
-
},
|
|
152
|
-
multiSelect: {
|
|
153
|
-
label: DEFAULT_FIELD_LABEL,
|
|
154
|
-
options: ["Option 1", "Option 2", "Option 3"],
|
|
155
|
-
allowOther: false
|
|
156
|
-
},
|
|
157
|
-
repeatGroup: {
|
|
158
|
-
// Minimal safe defaults – your builder UI is expected
|
|
159
|
-
// to configure nested fields + min/max as needed.
|
|
160
|
-
label: DEFAULT_FIELD_LABEL,
|
|
161
|
-
fields: []
|
|
162
|
-
// nested fields go here in the UI layer
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// src/ids.ts
|
|
167
|
-
function createUniqueId(prefix, existing) {
|
|
168
|
-
const used = new Set(existing);
|
|
169
|
-
let attempt = used.size + 1;
|
|
170
|
-
let candidate = `${prefix}-${attempt}`;
|
|
171
|
-
while (used.has(candidate)) {
|
|
172
|
-
attempt++;
|
|
173
|
-
candidate = `${prefix}-${attempt}`;
|
|
174
|
-
}
|
|
175
|
-
return candidate;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// src/migrate.ts
|
|
126
|
+
// src/template/migrate.ts
|
|
179
127
|
var LEGACY_FIELD_TYPE_MAP = {
|
|
180
128
|
shorttext: "shortText",
|
|
181
129
|
text: "shortText",
|
|
@@ -202,12 +150,14 @@ function migrateLegacySchema(raw) {
|
|
|
202
150
|
if (obj.fields) {
|
|
203
151
|
obj.sections = [
|
|
204
152
|
{
|
|
205
|
-
id: "
|
|
153
|
+
id: "section_1",
|
|
154
|
+
// ⬅ underscore to match normalizer/tests
|
|
206
155
|
title: obj.title,
|
|
207
156
|
description: obj.description,
|
|
208
157
|
fields: obj.fields.map((f, i) => ({
|
|
209
158
|
...f,
|
|
210
|
-
id: f.id || `
|
|
159
|
+
id: f.id || `field_${i + 1}`,
|
|
160
|
+
// ⬅ underscore here too
|
|
211
161
|
type: LEGACY_FIELD_TYPE_MAP[f.type?.toLowerCase()] ?? f.type
|
|
212
162
|
}))
|
|
213
163
|
}
|
|
@@ -218,10 +168,12 @@ function migrateLegacySchema(raw) {
|
|
|
218
168
|
if (Array.isArray(obj.sections)) {
|
|
219
169
|
obj.sections = obj.sections.map((sec, i) => ({
|
|
220
170
|
...sec,
|
|
221
|
-
id: sec.id || `
|
|
171
|
+
id: sec.id || `section_${i + 1}`,
|
|
172
|
+
// ⬅ underscore
|
|
222
173
|
fields: (sec.fields || []).map((f, idx) => ({
|
|
223
174
|
...f,
|
|
224
|
-
id: f.id || `
|
|
175
|
+
id: f.id || `field_${idx + 1}`,
|
|
176
|
+
// ⬅ underscore
|
|
225
177
|
type: LEGACY_FIELD_TYPE_MAP[f.type?.toLowerCase()] ?? f.type
|
|
226
178
|
}))
|
|
227
179
|
}));
|
|
@@ -229,7 +181,7 @@ function migrateLegacySchema(raw) {
|
|
|
229
181
|
return obj;
|
|
230
182
|
}
|
|
231
183
|
|
|
232
|
-
// src/normalize.ts
|
|
184
|
+
// src/template/normalize.ts
|
|
233
185
|
function normalizeId(raw, fallback) {
|
|
234
186
|
if (!raw || typeof raw !== "string") return fallback;
|
|
235
187
|
const cleaned = raw.trim().toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_-]/g, "");
|
|
@@ -302,349 +254,96 @@ function parseReportTemplateSchema(raw) {
|
|
|
302
254
|
function parseReportTemplateSchemaFromString(raw) {
|
|
303
255
|
return parseReportTemplateSchema(JSON.parse(raw));
|
|
304
256
|
}
|
|
305
|
-
function serializeReportTemplateSchema(schema) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
get(type) {
|
|
323
|
-
return this.registry.get(type);
|
|
324
|
-
}
|
|
325
|
-
/** Check if field type is registered. */
|
|
326
|
-
has(type) {
|
|
327
|
-
return this.registry.has(type);
|
|
328
|
-
}
|
|
329
|
-
/** Return all field types currently registered. */
|
|
330
|
-
list() {
|
|
331
|
-
return [...this.registry.keys()];
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
var FieldRegistry = new FieldRegistryClass();
|
|
335
|
-
var CORE_TYPES = [
|
|
336
|
-
"shortText",
|
|
337
|
-
"longText",
|
|
338
|
-
"number",
|
|
339
|
-
"date",
|
|
340
|
-
"checkbox",
|
|
341
|
-
"singleSelect",
|
|
342
|
-
"multiSelect"
|
|
343
|
-
];
|
|
344
|
-
for (const type of CORE_TYPES) {
|
|
345
|
-
FieldRegistry.register(type, {
|
|
346
|
-
defaults: CORE_FIELD_DEFAULTS[type]
|
|
347
|
-
// Core types rely on existing validation logic.
|
|
348
|
-
// buildResponseSchema is optional — fallback handles it.
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// src/conditions.ts
|
|
353
|
-
function evaluateCondition(condition, response) {
|
|
354
|
-
if ("equals" in condition) {
|
|
355
|
-
return Object.entries(condition.equals).every(([key, val]) => {
|
|
356
|
-
return response[key] === val;
|
|
257
|
+
function serializeReportTemplateSchema(schema, options = {}) {
|
|
258
|
+
const {
|
|
259
|
+
pretty = true,
|
|
260
|
+
sortSectionsById = false,
|
|
261
|
+
sortFieldsById = false
|
|
262
|
+
} = options;
|
|
263
|
+
let toSerialize = schema;
|
|
264
|
+
if (sortSectionsById || sortFieldsById) {
|
|
265
|
+
const sortedSections = [...schema.sections].map((section) => {
|
|
266
|
+
const clonedSection = { ...section };
|
|
267
|
+
if (sortFieldsById) {
|
|
268
|
+
const fields = section.fields ?? [];
|
|
269
|
+
clonedSection.fields = [...fields].sort(
|
|
270
|
+
(a, b) => a.id.localeCompare(b.id)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
return clonedSection;
|
|
357
274
|
});
|
|
275
|
+
if (sortSectionsById) {
|
|
276
|
+
toSerialize = {
|
|
277
|
+
...schema,
|
|
278
|
+
sections: sortedSections.sort(
|
|
279
|
+
(a, b) => a.id.localeCompare(b.id)
|
|
280
|
+
)
|
|
281
|
+
};
|
|
282
|
+
} else {
|
|
283
|
+
toSerialize = {
|
|
284
|
+
...schema,
|
|
285
|
+
sections: sortedSections
|
|
286
|
+
};
|
|
287
|
+
}
|
|
358
288
|
}
|
|
359
|
-
|
|
360
|
-
return condition.any.some(
|
|
361
|
-
(sub) => evaluateCondition(sub, response)
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
if ("all" in condition) {
|
|
365
|
-
return condition.all.every(
|
|
366
|
-
(sub) => evaluateCondition(sub, response)
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
if ("not" in condition) {
|
|
370
|
-
const sub = condition.not;
|
|
371
|
-
return !evaluateCondition(sub, response);
|
|
372
|
-
}
|
|
373
|
-
return false;
|
|
289
|
+
return JSON.stringify(toSerialize, null, pretty ? 2 : 0);
|
|
374
290
|
}
|
|
375
291
|
|
|
376
|
-
// src/
|
|
377
|
-
function
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
return isRequired ? schema : schema.optional();
|
|
394
|
-
}
|
|
395
|
-
case "number": {
|
|
396
|
-
let schema = z2.number();
|
|
397
|
-
if (typeof field.minValue === "number") {
|
|
398
|
-
schema = schema.min(field.minValue);
|
|
399
|
-
}
|
|
400
|
-
if (typeof field.maxValue === "number") {
|
|
401
|
-
schema = schema.max(field.maxValue);
|
|
402
|
-
}
|
|
403
|
-
return isRequired ? schema : schema.optional();
|
|
404
|
-
}
|
|
405
|
-
case "date": {
|
|
406
|
-
let schema = z2.string();
|
|
407
|
-
return isRequired ? schema : schema.optional();
|
|
408
|
-
}
|
|
409
|
-
case "checkbox": {
|
|
410
|
-
if (isRequired) return z2.literal(true);
|
|
411
|
-
return z2.boolean().optional();
|
|
412
|
-
}
|
|
413
|
-
case "singleSelect": {
|
|
414
|
-
let schema = z2.string();
|
|
415
|
-
if (Array.isArray(field.options) && field.options.length > 0) {
|
|
416
|
-
const allowed = new Set(field.options);
|
|
417
|
-
schema = schema.refine(
|
|
418
|
-
(value) => allowed.has(value),
|
|
419
|
-
"Value must be one of the defined options."
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
return isRequired ? schema : schema.optional();
|
|
423
|
-
}
|
|
424
|
-
case "multiSelect": {
|
|
425
|
-
let schema = z2.array(z2.string());
|
|
426
|
-
if (Array.isArray(field.options) && field.options.length > 0 && !field.allowOther) {
|
|
427
|
-
const allowed = new Set(field.options);
|
|
428
|
-
schema = schema.refine(
|
|
429
|
-
(values) => values.every((value) => allowed.has(value)),
|
|
430
|
-
"All values must be among the defined options."
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
if (typeof field.minSelections === "number") {
|
|
434
|
-
schema = schema.min(
|
|
435
|
-
field.minSelections,
|
|
436
|
-
`Select at least ${field.minSelections} option(s).`
|
|
437
|
-
);
|
|
438
|
-
} else if (isRequired) {
|
|
439
|
-
schema = schema.min(
|
|
440
|
-
1,
|
|
441
|
-
"Select at least one option."
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
if (typeof field.maxSelections === "number") {
|
|
445
|
-
schema = schema.max(
|
|
446
|
-
field.maxSelections,
|
|
447
|
-
`Select at most ${field.maxSelections} option(s).`
|
|
448
|
-
);
|
|
449
|
-
}
|
|
450
|
-
return isRequired ? schema : schema.optional();
|
|
451
|
-
}
|
|
452
|
-
default: {
|
|
453
|
-
return z2.any();
|
|
292
|
+
// src/template/diff.ts
|
|
293
|
+
function indexById(items) {
|
|
294
|
+
const map = {};
|
|
295
|
+
items.forEach((item, i) => map[item.id] = i);
|
|
296
|
+
return map;
|
|
297
|
+
}
|
|
298
|
+
function diffObjectProperties(before, after, ignoreKeys = []) {
|
|
299
|
+
const changes = [];
|
|
300
|
+
const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
301
|
+
for (const key of keys) {
|
|
302
|
+
if (ignoreKeys.includes(key)) continue;
|
|
303
|
+
const b = before[key];
|
|
304
|
+
const a = after[key];
|
|
305
|
+
const changed = Array.isArray(b) && Array.isArray(a) ? JSON.stringify(b) !== JSON.stringify(a) : b !== a;
|
|
306
|
+
if (changed) {
|
|
307
|
+
changes.push({ key, before: b, after: a });
|
|
454
308
|
}
|
|
455
309
|
}
|
|
310
|
+
return changes;
|
|
456
311
|
}
|
|
457
|
-
function
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
312
|
+
function diffRepeatGroupFields(beforeFields, afterFields, sectionId, groupId) {
|
|
313
|
+
const empty = {
|
|
314
|
+
addedSections: [],
|
|
315
|
+
removedSections: [],
|
|
316
|
+
reorderedSections: [],
|
|
317
|
+
modifiedSections: [],
|
|
318
|
+
addedFields: [],
|
|
319
|
+
removedFields: [],
|
|
320
|
+
reorderedFields: [],
|
|
321
|
+
modifiedFields: [],
|
|
322
|
+
nestedFieldDiffs: []
|
|
323
|
+
};
|
|
324
|
+
const beforeIndex = indexById(beforeFields);
|
|
325
|
+
const afterIndex = indexById(afterFields);
|
|
326
|
+
for (const f of afterFields) {
|
|
327
|
+
if (!(f.id in beforeIndex)) {
|
|
328
|
+
empty.addedFields.push({ sectionId, fieldId: f.id, index: afterIndex[f.id] });
|
|
462
329
|
}
|
|
463
330
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
return z2.object(shape);
|
|
483
|
-
}
|
|
484
|
-
function validateReportResponse(template, data) {
|
|
485
|
-
if (typeof data !== "object" || data === null) {
|
|
486
|
-
throw new Error("Response must be an object");
|
|
487
|
-
}
|
|
488
|
-
const response = data;
|
|
489
|
-
const schema = buildResponseSchemaWithConditions(template, response);
|
|
490
|
-
const parsed = schema.parse(response);
|
|
491
|
-
for (const section of template.sections) {
|
|
492
|
-
for (const field of section.fields) {
|
|
493
|
-
if (field.visibleIf) {
|
|
494
|
-
const visible = evaluateCondition(field.visibleIf, response);
|
|
495
|
-
if (!visible) {
|
|
496
|
-
delete parsed[field.id];
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return parsed;
|
|
502
|
-
}
|
|
503
|
-
function mapZodIssueToFieldErrorCode(issue) {
|
|
504
|
-
switch (issue.code) {
|
|
505
|
-
case z2.ZodIssueCode.invalid_type:
|
|
506
|
-
return "field.invalid_type";
|
|
507
|
-
case z2.ZodIssueCode.too_small:
|
|
508
|
-
return "field.too_small";
|
|
509
|
-
case z2.ZodIssueCode.too_big:
|
|
510
|
-
return "field.too_big";
|
|
511
|
-
case z2.ZodIssueCode.invalid_enum_value:
|
|
512
|
-
case z2.ZodIssueCode.invalid_literal:
|
|
513
|
-
return "field.invalid_option";
|
|
514
|
-
case z2.ZodIssueCode.custom:
|
|
515
|
-
return "field.custom";
|
|
516
|
-
default:
|
|
517
|
-
return "field.custom";
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
function buildFieldMetadataLookup(template) {
|
|
521
|
-
const map = /* @__PURE__ */ new Map();
|
|
522
|
-
for (const section of template.sections) {
|
|
523
|
-
for (const field of section.fields) {
|
|
524
|
-
map.set(field.id, {
|
|
525
|
-
sectionId: section.id,
|
|
526
|
-
sectionTitle: section.title,
|
|
527
|
-
label: field.label
|
|
528
|
-
});
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
return map;
|
|
532
|
-
}
|
|
533
|
-
function validateReportResponseDetailed(template, data) {
|
|
534
|
-
if (typeof data !== "object" || data === null) {
|
|
535
|
-
return {
|
|
536
|
-
success: false,
|
|
537
|
-
errors: [
|
|
538
|
-
{
|
|
539
|
-
fieldId: "",
|
|
540
|
-
code: "response.invalid_root",
|
|
541
|
-
message: "Response must be an object."
|
|
542
|
-
}
|
|
543
|
-
]
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
const response = data;
|
|
547
|
-
const schema = buildResponseSchemaWithConditions(template, response);
|
|
548
|
-
const fieldMeta = buildFieldMetadataLookup(template);
|
|
549
|
-
const result = schema.safeParse(response);
|
|
550
|
-
if (result.success) {
|
|
551
|
-
const parsed = { ...result.data };
|
|
552
|
-
for (const section of template.sections) {
|
|
553
|
-
for (const field of section.fields) {
|
|
554
|
-
if (field.visibleIf) {
|
|
555
|
-
const visible = evaluateCondition(field.visibleIf, response);
|
|
556
|
-
if (!visible) {
|
|
557
|
-
delete parsed[field.id];
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
return { success: true, value: parsed };
|
|
563
|
-
}
|
|
564
|
-
const errors = [];
|
|
565
|
-
for (const issue of result.error.issues) {
|
|
566
|
-
const path = issue.path ?? [];
|
|
567
|
-
const fieldId = typeof path[0] === "string" ? path[0] : "";
|
|
568
|
-
const meta = fieldId ? fieldMeta.get(fieldId) : void 0;
|
|
569
|
-
const code = mapZodIssueToFieldErrorCode(issue);
|
|
570
|
-
const sectionLabel = meta?.sectionTitle ?? meta?.sectionId;
|
|
571
|
-
const fieldLabel = meta?.label ?? fieldId;
|
|
572
|
-
let message;
|
|
573
|
-
if (meta && sectionLabel && fieldLabel) {
|
|
574
|
-
message = `Section "${sectionLabel}" \u2192 Field "${fieldLabel}": ${issue.message}`;
|
|
575
|
-
} else if (fieldLabel) {
|
|
576
|
-
message = `Field "${fieldLabel}": ${issue.message}`;
|
|
577
|
-
} else {
|
|
578
|
-
message = issue.message;
|
|
579
|
-
}
|
|
580
|
-
errors.push({
|
|
581
|
-
fieldId,
|
|
582
|
-
sectionId: meta?.sectionId,
|
|
583
|
-
sectionTitle: meta?.sectionTitle,
|
|
584
|
-
label: meta?.label,
|
|
585
|
-
code,
|
|
586
|
-
message,
|
|
587
|
-
rawIssue: issue
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
return { success: false, errors };
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// src/diff.ts
|
|
594
|
-
function indexById(items) {
|
|
595
|
-
const map = {};
|
|
596
|
-
items.forEach((item, i) => map[item.id] = i);
|
|
597
|
-
return map;
|
|
598
|
-
}
|
|
599
|
-
function diffObjectProperties(before, after, ignoreKeys = []) {
|
|
600
|
-
const changes = [];
|
|
601
|
-
const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
602
|
-
for (const key of keys) {
|
|
603
|
-
if (ignoreKeys.includes(key)) continue;
|
|
604
|
-
const b = before[key];
|
|
605
|
-
const a = after[key];
|
|
606
|
-
const changed = Array.isArray(b) && Array.isArray(a) ? JSON.stringify(b) !== JSON.stringify(a) : b !== a;
|
|
607
|
-
if (changed) {
|
|
608
|
-
changes.push({ key, before: b, after: a });
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return changes;
|
|
612
|
-
}
|
|
613
|
-
function diffRepeatGroupFields(beforeFields, afterFields, sectionId, groupId) {
|
|
614
|
-
const empty = {
|
|
615
|
-
addedSections: [],
|
|
616
|
-
removedSections: [],
|
|
617
|
-
reorderedSections: [],
|
|
618
|
-
modifiedSections: [],
|
|
619
|
-
addedFields: [],
|
|
620
|
-
removedFields: [],
|
|
621
|
-
reorderedFields: [],
|
|
622
|
-
modifiedFields: [],
|
|
623
|
-
nestedFieldDiffs: []
|
|
624
|
-
};
|
|
625
|
-
const beforeIndex = indexById(beforeFields);
|
|
626
|
-
const afterIndex = indexById(afterFields);
|
|
627
|
-
for (const f of afterFields) {
|
|
628
|
-
if (!(f.id in beforeIndex)) {
|
|
629
|
-
empty.addedFields.push({ sectionId, fieldId: f.id, index: afterIndex[f.id] });
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
for (const f of beforeFields) {
|
|
633
|
-
if (!(f.id in afterIndex)) {
|
|
634
|
-
empty.removedFields.push({ sectionId, fieldId: f.id, index: beforeIndex[f.id] });
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
for (const f of afterFields) {
|
|
638
|
-
if (f.id in beforeIndex) {
|
|
639
|
-
const from = beforeIndex[f.id];
|
|
640
|
-
const to = afterIndex[f.id];
|
|
641
|
-
if (from !== to) {
|
|
642
|
-
empty.reorderedFields.push({
|
|
643
|
-
sectionId,
|
|
644
|
-
fieldId: f.id,
|
|
645
|
-
from,
|
|
646
|
-
to
|
|
647
|
-
});
|
|
331
|
+
for (const f of beforeFields) {
|
|
332
|
+
if (!(f.id in afterIndex)) {
|
|
333
|
+
empty.removedFields.push({ sectionId, fieldId: f.id, index: beforeIndex[f.id] });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
for (const f of afterFields) {
|
|
337
|
+
if (f.id in beforeIndex) {
|
|
338
|
+
const from = beforeIndex[f.id];
|
|
339
|
+
const to = afterIndex[f.id];
|
|
340
|
+
if (from !== to) {
|
|
341
|
+
empty.reorderedFields.push({
|
|
342
|
+
sectionId,
|
|
343
|
+
fieldId: f.id,
|
|
344
|
+
from,
|
|
345
|
+
to
|
|
346
|
+
});
|
|
648
347
|
}
|
|
649
348
|
}
|
|
650
349
|
}
|
|
@@ -757,7 +456,7 @@ function diffTemplates(before, after) {
|
|
|
757
456
|
return diff;
|
|
758
457
|
}
|
|
759
458
|
|
|
760
|
-
// src/jsonSchema.ts
|
|
459
|
+
// src/template/jsonSchema.ts
|
|
761
460
|
function mapFieldTypeToJSONSchemaCore(field) {
|
|
762
461
|
const common = {
|
|
763
462
|
title: field.label,
|
|
@@ -809,9 +508,6 @@ function mapFieldTypeToJSONSchemaCore(field) {
|
|
|
809
508
|
...common,
|
|
810
509
|
type: "boolean"
|
|
811
510
|
};
|
|
812
|
-
if (field.required && !field.requiredIf) {
|
|
813
|
-
schema.const = true;
|
|
814
|
-
}
|
|
815
511
|
return schema;
|
|
816
512
|
}
|
|
817
513
|
case "singleSelect": {
|
|
@@ -819,7 +515,8 @@ function mapFieldTypeToJSONSchemaCore(field) {
|
|
|
819
515
|
...common,
|
|
820
516
|
type: "string"
|
|
821
517
|
};
|
|
822
|
-
|
|
518
|
+
const hasOptions = Array.isArray(field.options) && field.options.length > 0;
|
|
519
|
+
if (hasOptions && !field.allowOther) {
|
|
823
520
|
schema.enum = field.options;
|
|
824
521
|
}
|
|
825
522
|
return schema;
|
|
@@ -847,6 +544,38 @@ function mapFieldTypeToJSONSchemaCore(field) {
|
|
|
847
544
|
}
|
|
848
545
|
return schema;
|
|
849
546
|
}
|
|
547
|
+
case "repeatGroup": {
|
|
548
|
+
const nestedFields = Array.isArray(field.fields) ? field.fields : [];
|
|
549
|
+
const rowProperties = {};
|
|
550
|
+
const rowRequired = [];
|
|
551
|
+
for (const nested of nestedFields) {
|
|
552
|
+
rowProperties[nested.id] = mapFieldTypeToJSONSchemaCore(
|
|
553
|
+
nested
|
|
554
|
+
);
|
|
555
|
+
if (nested.required === true && !nested.requiredIf && !nested.visibleIf) {
|
|
556
|
+
rowRequired.push(nested.id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const rowSchema = {
|
|
560
|
+
type: "object",
|
|
561
|
+
properties: rowProperties,
|
|
562
|
+
additionalProperties: false
|
|
563
|
+
};
|
|
564
|
+
if (rowRequired.length > 0) {
|
|
565
|
+
rowSchema.required = rowRequired;
|
|
566
|
+
}
|
|
567
|
+
const schema = {
|
|
568
|
+
...common,
|
|
569
|
+
type: "array",
|
|
570
|
+
items: rowSchema,
|
|
571
|
+
// expose min/max + conditional minIf/maxIf as extensions
|
|
572
|
+
"x-frt-min": field.min,
|
|
573
|
+
"x-frt-max": field.max,
|
|
574
|
+
"x-frt-minIf": field.minIf,
|
|
575
|
+
"x-frt-maxIf": field.maxIf
|
|
576
|
+
};
|
|
577
|
+
return schema;
|
|
578
|
+
}
|
|
850
579
|
default: {
|
|
851
580
|
return {
|
|
852
581
|
...common
|
|
@@ -879,6 +608,386 @@ function exportJSONSchema(template) {
|
|
|
879
608
|
}
|
|
880
609
|
return schema;
|
|
881
610
|
}
|
|
611
|
+
|
|
612
|
+
// src/fields/defaults.ts
|
|
613
|
+
var DEFAULT_FIELD_LABEL = "Untitled question";
|
|
614
|
+
var CORE_FIELD_DEFAULTS = {
|
|
615
|
+
shortText: {
|
|
616
|
+
label: DEFAULT_FIELD_LABEL,
|
|
617
|
+
placeholder: "Short answer text"
|
|
618
|
+
},
|
|
619
|
+
longText: {
|
|
620
|
+
label: DEFAULT_FIELD_LABEL,
|
|
621
|
+
placeholder: "Long answer text"
|
|
622
|
+
},
|
|
623
|
+
number: {
|
|
624
|
+
label: DEFAULT_FIELD_LABEL,
|
|
625
|
+
placeholder: "123"
|
|
626
|
+
},
|
|
627
|
+
date: {
|
|
628
|
+
label: DEFAULT_FIELD_LABEL
|
|
629
|
+
},
|
|
630
|
+
checkbox: {
|
|
631
|
+
label: DEFAULT_FIELD_LABEL,
|
|
632
|
+
placeholder: "Check to confirm"
|
|
633
|
+
},
|
|
634
|
+
singleSelect: {
|
|
635
|
+
label: DEFAULT_FIELD_LABEL,
|
|
636
|
+
options: ["Option 1", "Option 2", "Option 3"]
|
|
637
|
+
},
|
|
638
|
+
multiSelect: {
|
|
639
|
+
label: DEFAULT_FIELD_LABEL,
|
|
640
|
+
options: ["Option 1", "Option 2", "Option 3"],
|
|
641
|
+
allowOther: false
|
|
642
|
+
},
|
|
643
|
+
repeatGroup: {
|
|
644
|
+
// Minimal safe defaults – your builder UI is expected
|
|
645
|
+
// to configure nested fields + min/max as needed.
|
|
646
|
+
label: DEFAULT_FIELD_LABEL,
|
|
647
|
+
fields: []
|
|
648
|
+
// nested fields go here in the UI layer
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
// src/fields/ids.ts
|
|
653
|
+
function createUniqueId(prefix, existing) {
|
|
654
|
+
const used = new Set(existing);
|
|
655
|
+
let attempt = used.size + 1;
|
|
656
|
+
let candidate = `${prefix}-${attempt}`;
|
|
657
|
+
while (used.has(candidate)) {
|
|
658
|
+
attempt++;
|
|
659
|
+
candidate = `${prefix}-${attempt}`;
|
|
660
|
+
}
|
|
661
|
+
return candidate;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/fields/registry.ts
|
|
665
|
+
var FieldRegistryClass = class {
|
|
666
|
+
constructor() {
|
|
667
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
668
|
+
}
|
|
669
|
+
/** Register or override a field type. */
|
|
670
|
+
register(type, entry) {
|
|
671
|
+
this.registry.set(type, entry);
|
|
672
|
+
}
|
|
673
|
+
/** Get registry entry for a field type. */
|
|
674
|
+
get(type) {
|
|
675
|
+
return this.registry.get(type);
|
|
676
|
+
}
|
|
677
|
+
/** Check if field type is registered. */
|
|
678
|
+
has(type) {
|
|
679
|
+
return this.registry.has(type);
|
|
680
|
+
}
|
|
681
|
+
/** Return all field types currently registered. */
|
|
682
|
+
list() {
|
|
683
|
+
return [...this.registry.keys()];
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
var FieldRegistry = new FieldRegistryClass();
|
|
687
|
+
var CORE_TYPES = [
|
|
688
|
+
"shortText",
|
|
689
|
+
"longText",
|
|
690
|
+
"number",
|
|
691
|
+
"date",
|
|
692
|
+
"checkbox",
|
|
693
|
+
"singleSelect",
|
|
694
|
+
"multiSelect",
|
|
695
|
+
"repeatGroup"
|
|
696
|
+
];
|
|
697
|
+
for (const type of CORE_TYPES) {
|
|
698
|
+
FieldRegistry.register(type, {
|
|
699
|
+
defaults: CORE_FIELD_DEFAULTS[type]
|
|
700
|
+
// Core types rely on existing validation logic.
|
|
701
|
+
// buildResponseSchema is optional — fallback handles it.
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/validation/conditions.ts
|
|
706
|
+
function evaluateCondition(condition, response) {
|
|
707
|
+
if ("equals" in condition) {
|
|
708
|
+
return Object.entries(condition.equals).every(([key, val]) => {
|
|
709
|
+
return response[key] === val;
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
if ("any" in condition) {
|
|
713
|
+
return condition.any.some(
|
|
714
|
+
(sub) => evaluateCondition(sub, response)
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if ("all" in condition) {
|
|
718
|
+
return condition.all.every(
|
|
719
|
+
(sub) => evaluateCondition(sub, response)
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
if ("not" in condition) {
|
|
723
|
+
const sub = condition.not;
|
|
724
|
+
return !evaluateCondition(sub, response);
|
|
725
|
+
}
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// src/validation/responses.ts
|
|
730
|
+
import {
|
|
731
|
+
z as z2,
|
|
732
|
+
ZodError
|
|
733
|
+
} from "zod";
|
|
734
|
+
function buildBaseFieldSchema(field) {
|
|
735
|
+
const isRequired = Boolean(field.required);
|
|
736
|
+
const registry = FieldRegistry.get(field.type);
|
|
737
|
+
if (registry?.buildResponseSchema) {
|
|
738
|
+
return registry.buildResponseSchema(field);
|
|
739
|
+
}
|
|
740
|
+
switch (field.type) {
|
|
741
|
+
case "shortText":
|
|
742
|
+
case "longText": {
|
|
743
|
+
let schema = z2.string();
|
|
744
|
+
if (typeof field.minLength === "number") {
|
|
745
|
+
schema = schema.min(field.minLength);
|
|
746
|
+
}
|
|
747
|
+
if (typeof field.maxLength === "number") {
|
|
748
|
+
schema = schema.max(field.maxLength);
|
|
749
|
+
}
|
|
750
|
+
return isRequired ? schema : schema.optional();
|
|
751
|
+
}
|
|
752
|
+
case "number": {
|
|
753
|
+
let schema = z2.number();
|
|
754
|
+
if (typeof field.minValue === "number") {
|
|
755
|
+
schema = schema.min(field.minValue);
|
|
756
|
+
}
|
|
757
|
+
if (typeof field.maxValue === "number") {
|
|
758
|
+
schema = schema.max(field.maxValue);
|
|
759
|
+
}
|
|
760
|
+
return isRequired ? schema : schema.optional();
|
|
761
|
+
}
|
|
762
|
+
case "date": {
|
|
763
|
+
let schema = z2.string();
|
|
764
|
+
return isRequired ? schema : schema.optional();
|
|
765
|
+
}
|
|
766
|
+
case "checkbox": {
|
|
767
|
+
if (isRequired) return z2.literal(true);
|
|
768
|
+
return z2.boolean().optional();
|
|
769
|
+
}
|
|
770
|
+
case "singleSelect": {
|
|
771
|
+
let schema = z2.string();
|
|
772
|
+
if (Array.isArray(field.options) && field.options.length > 0) {
|
|
773
|
+
const allowed = new Set(field.options);
|
|
774
|
+
schema = schema.refine(
|
|
775
|
+
(value) => allowed.has(value),
|
|
776
|
+
"Value must be one of the defined options."
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return isRequired ? schema : schema.optional();
|
|
780
|
+
}
|
|
781
|
+
case "multiSelect": {
|
|
782
|
+
let schema = z2.array(z2.string());
|
|
783
|
+
if (Array.isArray(field.options) && field.options.length > 0 && !field.allowOther) {
|
|
784
|
+
const allowed = new Set(field.options);
|
|
785
|
+
schema = schema.refine(
|
|
786
|
+
(values) => values.every((value) => allowed.has(value)),
|
|
787
|
+
"All values must be among the defined options."
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (typeof field.minSelections === "number") {
|
|
791
|
+
schema = schema.min(
|
|
792
|
+
field.minSelections,
|
|
793
|
+
`Select at least ${field.minSelections} option(s).`
|
|
794
|
+
);
|
|
795
|
+
} else if (isRequired) {
|
|
796
|
+
schema = schema.min(
|
|
797
|
+
1,
|
|
798
|
+
"Select at least one option."
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
if (typeof field.maxSelections === "number") {
|
|
802
|
+
schema = schema.max(
|
|
803
|
+
field.maxSelections,
|
|
804
|
+
`Select at most ${field.maxSelections} option(s).`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
return isRequired ? schema : schema.optional();
|
|
808
|
+
}
|
|
809
|
+
case "repeatGroup": {
|
|
810
|
+
const nestedFields = Array.isArray(field.fields) ? field.fields : [];
|
|
811
|
+
const rowShape = {};
|
|
812
|
+
for (const nested of nestedFields) {
|
|
813
|
+
rowShape[nested.id] = buildBaseFieldSchema(
|
|
814
|
+
nested
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
let schema = z2.array(z2.object(rowShape));
|
|
818
|
+
if (typeof field.min === "number") {
|
|
819
|
+
schema = schema.min(
|
|
820
|
+
field.min,
|
|
821
|
+
`Add at least ${field.min} item(s).`
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
if (typeof field.max === "number") {
|
|
825
|
+
schema = schema.max(
|
|
826
|
+
field.max,
|
|
827
|
+
`Add at most ${field.max} item(s).`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
return isRequired ? schema : schema.optional();
|
|
831
|
+
}
|
|
832
|
+
default: {
|
|
833
|
+
return z2.any();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function buildConditionalFieldSchema(field, response) {
|
|
838
|
+
if (field.visibleIf) {
|
|
839
|
+
const visible = evaluateCondition(field.visibleIf, response);
|
|
840
|
+
if (!visible) {
|
|
841
|
+
return z2.any().optional();
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
let schema = buildBaseFieldSchema(field);
|
|
845
|
+
if (field.requiredIf) {
|
|
846
|
+
const shouldBeRequired = evaluateCondition(field.requiredIf, response);
|
|
847
|
+
if (shouldBeRequired) {
|
|
848
|
+
if (schema instanceof z2.ZodOptional) {
|
|
849
|
+
schema = schema.unwrap();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return schema;
|
|
854
|
+
}
|
|
855
|
+
function buildResponseSchemaWithConditions(template, response) {
|
|
856
|
+
const shape = {};
|
|
857
|
+
for (const section of template.sections) {
|
|
858
|
+
for (const field of section.fields) {
|
|
859
|
+
shape[field.id] = buildConditionalFieldSchema(field, response);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return z2.object(shape);
|
|
863
|
+
}
|
|
864
|
+
function validateReportResponse(template, data) {
|
|
865
|
+
if (typeof data !== "object" || data === null) {
|
|
866
|
+
throw new Error("Response must be an object");
|
|
867
|
+
}
|
|
868
|
+
const response = data;
|
|
869
|
+
const schema = buildResponseSchemaWithConditions(template, response);
|
|
870
|
+
const parsed = schema.parse(response);
|
|
871
|
+
for (const section of template.sections) {
|
|
872
|
+
for (const field of section.fields) {
|
|
873
|
+
if (field.visibleIf) {
|
|
874
|
+
const visible = evaluateCondition(field.visibleIf, response);
|
|
875
|
+
if (!visible) {
|
|
876
|
+
delete parsed[field.id];
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return parsed;
|
|
882
|
+
}
|
|
883
|
+
function mapZodIssueToFieldErrorCode(issue) {
|
|
884
|
+
switch (issue.code) {
|
|
885
|
+
case z2.ZodIssueCode.invalid_type:
|
|
886
|
+
return "field.invalid_type";
|
|
887
|
+
case z2.ZodIssueCode.too_small:
|
|
888
|
+
return "field.too_small";
|
|
889
|
+
case z2.ZodIssueCode.too_big:
|
|
890
|
+
return "field.too_big";
|
|
891
|
+
case z2.ZodIssueCode.invalid_enum_value:
|
|
892
|
+
case z2.ZodIssueCode.invalid_literal:
|
|
893
|
+
return "field.invalid_option";
|
|
894
|
+
case z2.ZodIssueCode.custom:
|
|
895
|
+
return "field.custom";
|
|
896
|
+
default:
|
|
897
|
+
return "field.custom";
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
function buildFieldMetadataLookup(template) {
|
|
901
|
+
const map = /* @__PURE__ */ new Map();
|
|
902
|
+
for (const section of template.sections) {
|
|
903
|
+
for (const field of section.fields) {
|
|
904
|
+
map.set(field.id, {
|
|
905
|
+
sectionId: section.id,
|
|
906
|
+
sectionTitle: section.title,
|
|
907
|
+
label: field.label
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return map;
|
|
912
|
+
}
|
|
913
|
+
function mapZodIssuesToResponseErrors(template, issues) {
|
|
914
|
+
const fieldMeta = buildFieldMetadataLookup(template);
|
|
915
|
+
const errors = [];
|
|
916
|
+
for (const issue of issues) {
|
|
917
|
+
const path = issue.path ?? [];
|
|
918
|
+
const topLevelFieldId = path.find(
|
|
919
|
+
(p) => typeof p === "string"
|
|
920
|
+
);
|
|
921
|
+
const rowIndex = path.find(
|
|
922
|
+
(p) => typeof p === "number"
|
|
923
|
+
);
|
|
924
|
+
const nestedFieldId = path.length >= 3 && typeof path[path.length - 1] === "string" ? path[path.length - 1] : void 0;
|
|
925
|
+
const fieldId = topLevelFieldId ?? "";
|
|
926
|
+
const meta = fieldId ? fieldMeta.get(fieldId) : void 0;
|
|
927
|
+
const code = mapZodIssueToFieldErrorCode(issue);
|
|
928
|
+
const sectionLabel = meta?.sectionTitle ?? meta?.sectionId;
|
|
929
|
+
const fieldLabel = meta?.label ?? fieldId;
|
|
930
|
+
let message;
|
|
931
|
+
if (rowIndex != null && nestedFieldId && meta && sectionLabel) {
|
|
932
|
+
message = `Section "${sectionLabel}" \u2192 "${meta.label}" (row ${rowIndex + 1}, field "${nestedFieldId}"): ${issue.message}`;
|
|
933
|
+
} else if (meta && sectionLabel && fieldLabel) {
|
|
934
|
+
message = `Section "${sectionLabel}" \u2192 Field "${fieldLabel}": ${issue.message}`;
|
|
935
|
+
} else if (fieldLabel) {
|
|
936
|
+
message = `Field "${fieldLabel}": ${issue.message}`;
|
|
937
|
+
} else {
|
|
938
|
+
message = issue.message;
|
|
939
|
+
}
|
|
940
|
+
errors.push({
|
|
941
|
+
fieldId,
|
|
942
|
+
sectionId: meta?.sectionId,
|
|
943
|
+
sectionTitle: meta?.sectionTitle,
|
|
944
|
+
label: meta?.label,
|
|
945
|
+
code,
|
|
946
|
+
message,
|
|
947
|
+
rawIssue: issue
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
return errors;
|
|
951
|
+
}
|
|
952
|
+
function validateReportResponseDetailed(template, data) {
|
|
953
|
+
if (typeof data !== "object" || data === null) {
|
|
954
|
+
return {
|
|
955
|
+
success: false,
|
|
956
|
+
errors: [
|
|
957
|
+
{
|
|
958
|
+
fieldId: "",
|
|
959
|
+
code: "response.invalid_root",
|
|
960
|
+
message: "Response must be an object."
|
|
961
|
+
}
|
|
962
|
+
]
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
const response = data;
|
|
966
|
+
const schema = buildResponseSchemaWithConditions(template, response);
|
|
967
|
+
const result = schema.safeParse(response);
|
|
968
|
+
if (result.success) {
|
|
969
|
+
const parsed = { ...result.data };
|
|
970
|
+
for (const section of template.sections) {
|
|
971
|
+
for (const field of section.fields) {
|
|
972
|
+
if (field.visibleIf) {
|
|
973
|
+
const visible = evaluateCondition(field.visibleIf, response);
|
|
974
|
+
if (!visible) {
|
|
975
|
+
delete parsed[field.id];
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
return { success: true, value: parsed };
|
|
981
|
+
}
|
|
982
|
+
const errors = mapZodIssuesToResponseErrors(template, result.error.issues);
|
|
983
|
+
return { success: false, errors };
|
|
984
|
+
}
|
|
985
|
+
function explainValidationError(template, error) {
|
|
986
|
+
if (error instanceof ZodError) {
|
|
987
|
+
return mapZodIssuesToResponseErrors(template, error.issues);
|
|
988
|
+
}
|
|
989
|
+
return null;
|
|
990
|
+
}
|
|
882
991
|
export {
|
|
883
992
|
CORE_FIELD_DEFAULTS,
|
|
884
993
|
Condition,
|
|
@@ -895,6 +1004,7 @@ export {
|
|
|
895
1004
|
createUniqueId,
|
|
896
1005
|
diffTemplates,
|
|
897
1006
|
evaluateCondition,
|
|
1007
|
+
explainValidationError,
|
|
898
1008
|
exportJSONSchema,
|
|
899
1009
|
migrateLegacySchema,
|
|
900
1010
|
normalizeReportTemplateSchema,
|