@visns-studio/visns-components 6.1.7 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,971 @@
1
+ /**
2
+ * Semantic report model helpers.
3
+ *
4
+ * The semantic model lets a non-technical user design a report in business
5
+ * language: entities, labelled fields and labelled relations. Nothing in this
6
+ * module knows about database tables, columns or joins — field ids are opaque
7
+ * handles that the server maps back to storage.
8
+ *
9
+ * Everything here is pure. The network call lives in `useSemanticModel`, and
10
+ * the wizard UI lives in `./reportSemanticSteps/*`.
11
+ */
12
+
13
+ /** Version stamped onto every definition this module produces. */
14
+ export const SEMANTIC_SCHEMA_VERSION = 2;
15
+
16
+ /** Default endpoint probed on mount when `setting.semanticModelUrl` is absent. */
17
+ export const DEFAULT_SEMANTIC_MODEL_URL = '/ajax/reportBuilder/semanticModel';
18
+
19
+ /** Field types the semantic model may declare. */
20
+ export const FIELD_TYPES = [
21
+ 'text',
22
+ 'number',
23
+ 'money',
24
+ 'percent',
25
+ 'date',
26
+ 'datetime',
27
+ 'boolean',
28
+ 'enum',
29
+ ];
30
+
31
+ /** Types that can be summed or averaged without an explicit `summable` flag. */
32
+ const NUMERIC_TYPES = ['number', 'money', 'percent'];
33
+
34
+ /** Types that carry an ordering, so min/max read as earliest/latest. */
35
+ const TEMPORAL_TYPES = ['date', 'datetime'];
36
+
37
+ /**
38
+ * Operators offered per field type, in the order they should appear in menus.
39
+ * Unknown types fall back to the text operators.
40
+ */
41
+ export const OPERATORS_BY_TYPE = {
42
+ text: [
43
+ 'equals',
44
+ 'not_equals',
45
+ 'contains',
46
+ 'not_contains',
47
+ 'is_empty',
48
+ 'not_empty',
49
+ ],
50
+ number: [
51
+ 'equals',
52
+ 'not_equals',
53
+ 'gt',
54
+ 'gte',
55
+ 'lt',
56
+ 'lte',
57
+ 'between',
58
+ 'is_empty',
59
+ 'not_empty',
60
+ ],
61
+ date: ['equals', 'before', 'after', 'between', 'is_empty', 'not_empty'],
62
+ boolean: ['is_true', 'is_false'],
63
+ enum: ['equals', 'not_equals', 'in', 'not_in'],
64
+ };
65
+
66
+ OPERATORS_BY_TYPE.money = OPERATORS_BY_TYPE.number;
67
+ OPERATORS_BY_TYPE.percent = OPERATORS_BY_TYPE.number;
68
+ OPERATORS_BY_TYPE.datetime = OPERATORS_BY_TYPE.date;
69
+
70
+ /** Plain-language operator names shown to the user. */
71
+ export const OPERATOR_LABELS = {
72
+ equals: 'is',
73
+ not_equals: 'is not',
74
+ contains: 'contains',
75
+ not_contains: 'does not contain',
76
+ is_empty: 'is blank',
77
+ not_empty: 'is not blank',
78
+ gt: 'is more than',
79
+ gte: 'is at least',
80
+ lt: 'is less than',
81
+ lte: 'is at most',
82
+ between: 'is between',
83
+ before: 'is before',
84
+ after: 'is after',
85
+ is_true: 'is yes',
86
+ is_false: 'is no',
87
+ in: 'is any of',
88
+ not_in: 'is none of',
89
+ };
90
+
91
+ /** Operators that take no value at all. */
92
+ const VALUELESS_OPERATORS = ['is_empty', 'not_empty', 'is_true', 'is_false'];
93
+
94
+ /** Operators that take an open-ended list of values. */
95
+ const LIST_OPERATORS = ['in', 'not_in'];
96
+
97
+ /** Aggregate ids, in menu order. An empty agg means "show the plain value". */
98
+ export const AGGREGATES = ['sum', 'count', 'avg', 'min', 'max'];
99
+
100
+ /** Field-type categories used to group the field picker. */
101
+ export const FIELD_CATEGORIES = [
102
+ { id: 'text', label: 'Details', types: ['text'] },
103
+ {
104
+ id: 'amounts',
105
+ label: 'Amounts & numbers',
106
+ types: ['number', 'money', 'percent'],
107
+ },
108
+ { id: 'dates', label: 'Dates', types: ['date', 'datetime'] },
109
+ { id: 'status', label: 'Status & yes/no', types: ['boolean', 'enum'] },
110
+ ];
111
+
112
+ /* -------------------------------------------------------------------------- */
113
+ /* Model normalisation */
114
+ /* -------------------------------------------------------------------------- */
115
+
116
+ /**
117
+ * Pull the `{ entities }` map out of whatever shape the endpoint returned.
118
+ * Returns null when the payload carries no usable entities, which is the
119
+ * signal to stay in legacy mode.
120
+ *
121
+ * @param {*} payload Raw response body (or a CustomFetch response envelope).
122
+ * @returns {{entities: Object}|null}
123
+ */
124
+ export const normalizeSemanticModel = (payload) => {
125
+ if (!payload || typeof payload !== 'object') return null;
126
+
127
+ // Accept `{success, data:{entities}}`, `{data:{entities}}`, `{entities}`,
128
+ // or a bare entity map — in that order of preference.
129
+ const candidates = [
130
+ payload?.data?.data?.entities,
131
+ payload?.data?.entities,
132
+ payload?.entities,
133
+ payload?.data?.data,
134
+ payload?.data,
135
+ payload,
136
+ ];
137
+
138
+ const entities = candidates.find(
139
+ (candidate) =>
140
+ candidate &&
141
+ typeof candidate === 'object' &&
142
+ !Array.isArray(candidate) &&
143
+ Object.values(candidate).some(
144
+ (entity) =>
145
+ entity &&
146
+ typeof entity === 'object' &&
147
+ (entity.fields || entity.relations)
148
+ )
149
+ );
150
+
151
+ if (!entities) return null;
152
+
153
+ const usable = Object.entries(entities).filter(
154
+ ([, entity]) => entity && typeof entity === 'object' && entity.fields
155
+ );
156
+
157
+ if (usable.length === 0) return null;
158
+
159
+ return {
160
+ entities: usable.reduce((acc, [id, entity]) => {
161
+ acc[id] = {
162
+ ...entity,
163
+ label: entity.label || entity.plural || id,
164
+ fields: entity.fields || {},
165
+ relations: entity.relations || {},
166
+ };
167
+ return acc;
168
+ }, {}),
169
+ };
170
+ };
171
+
172
+ /**
173
+ * Entities offered as a starting point, sorted by label.
174
+ *
175
+ * `hidden` entities are left out: they are lookups ("Notes", "Task types")
176
+ * that exist so a relation has somewhere to land. They stay in the model — a
177
+ * path like `notes.subject` still resolves through them — they are just never
178
+ * something you start a report *about*.
179
+ *
180
+ * @returns {Array<{id: string, label, plural, description}>}
181
+ */
182
+ export const listEntities = (model) => {
183
+ if (!model?.entities) return [];
184
+ return Object.entries(model.entities)
185
+ .filter(([, entity]) => !entity.hidden)
186
+ .map(([id, entity]) => ({
187
+ id,
188
+ label: entity.label || id,
189
+ plural: entity.plural || entity.label || id,
190
+ description: entity.description || '',
191
+ fieldCount: Object.keys(entity.fields || {}).length,
192
+ relationCount: Object.keys(entity.relations || {}).length,
193
+ }))
194
+ .sort((a, b) => a.label.localeCompare(b.label));
195
+ };
196
+
197
+ export const getEntity = (model, entityId) =>
198
+ (model?.entities && model.entities[entityId]) || null;
199
+
200
+ export const getEntityLabel = (model, entityId) =>
201
+ getEntity(model, entityId)?.label || entityId || '';
202
+
203
+ /** @returns {Array<{id, label, entity, cardinality, description}>} */
204
+ export const listRelations = (model, entityId) => {
205
+ const entity = getEntity(model, entityId);
206
+ if (!entity) return [];
207
+ return Object.entries(entity.relations || {})
208
+ .map(([id, relation]) => ({
209
+ id,
210
+ label: relation.label || id,
211
+ entity: relation.entity,
212
+ cardinality: relation.cardinality === 'many' ? 'many' : 'one',
213
+ description: relation.description || '',
214
+ targetLabel: getEntityLabel(model, relation.entity),
215
+ }))
216
+ .filter((relation) => !!getEntity(model, relation.entity))
217
+ .sort((a, b) => a.label.localeCompare(b.label));
218
+ };
219
+
220
+ /** "one of these" vs "many of these", for the relation cardinality hint. */
221
+ export const cardinalityHint = (relation) =>
222
+ relation?.cardinality === 'many'
223
+ ? 'Many per record — rows may repeat'
224
+ : 'One per record';
225
+
226
+ /* -------------------------------------------------------------------------- */
227
+ /* Field path resolution */
228
+ /* -------------------------------------------------------------------------- */
229
+
230
+ /**
231
+ * Walk a dot-path of relations (no trailing field), e.g. `adviser.team`.
232
+ *
233
+ * @returns {{entityId, label, cardinality, hops}|null} null when any hop is
234
+ * undeclared. `cardinality` is 'many' when any hop along the way is 'many'.
235
+ */
236
+ export const resolveRelationPath = (model, rootEntityId, path) => {
237
+ if (!model || !rootEntityId) return null;
238
+ if (!path) {
239
+ return {
240
+ entityId: rootEntityId,
241
+ label: getEntityLabel(model, rootEntityId),
242
+ cardinality: 'one',
243
+ hops: [],
244
+ };
245
+ }
246
+
247
+ let entityId = rootEntityId;
248
+ let cardinality = 'one';
249
+ const hops = [];
250
+
251
+ const segments = String(path).split('.');
252
+ for (let index = 0; index < segments.length; index += 1) {
253
+ const relation = getEntity(model, entityId)?.relations?.[
254
+ segments[index]
255
+ ];
256
+ if (!relation || !getEntity(model, relation.entity)) return null;
257
+ if (relation.cardinality === 'many') cardinality = 'many';
258
+ hops.push({
259
+ id: segments[index],
260
+ label: relation.label || segments[index],
261
+ });
262
+ entityId = relation.entity;
263
+ }
264
+
265
+ return {
266
+ entityId,
267
+ label: hops.map((hop) => hop.label).join(' › '),
268
+ cardinality,
269
+ hops,
270
+ };
271
+ };
272
+
273
+ /**
274
+ * Walk a dot-path such as `adviser.name` (or a chained `adviser.team.name`)
275
+ * from a root entity down to a field.
276
+ *
277
+ * @returns {{
278
+ * path: string,
279
+ * fieldId: string,
280
+ * field: Object,
281
+ * entityId: string,
282
+ * label: string,
283
+ * fullLabel: string,
284
+ * type: string,
285
+ * relationPath: Array<{id, label, cardinality, entity}>,
286
+ * }|null} null when any hop is undeclared.
287
+ */
288
+ export const resolveFieldPath = (model, rootEntityId, path) => {
289
+ if (!model || !rootEntityId || typeof path !== 'string' || !path) {
290
+ return null;
291
+ }
292
+
293
+ const segments = path.split('.');
294
+ const fieldId = segments.pop();
295
+ let entityId = rootEntityId;
296
+ const relationPath = [];
297
+
298
+ for (let index = 0; index < segments.length; index += 1) {
299
+ const entity = getEntity(model, entityId);
300
+ const relation = entity?.relations?.[segments[index]];
301
+ if (!relation || !getEntity(model, relation.entity)) return null;
302
+ relationPath.push({
303
+ id: segments[index],
304
+ label: relation.label || segments[index],
305
+ cardinality: relation.cardinality === 'many' ? 'many' : 'one',
306
+ entity: relation.entity,
307
+ });
308
+ entityId = relation.entity;
309
+ }
310
+
311
+ const entity = getEntity(model, entityId);
312
+ const field = entity?.fields?.[fieldId];
313
+ if (!field) return null;
314
+
315
+ const label = field.label || fieldId;
316
+ const fullLabel = relationPath.length
317
+ ? `${relationPath.map((hop) => hop.label).join(' › ')} › ${label}`
318
+ : label;
319
+
320
+ return {
321
+ path,
322
+ fieldId,
323
+ field,
324
+ entityId,
325
+ label,
326
+ fullLabel,
327
+ type: FIELD_TYPES.includes(field.type) ? field.type : 'text',
328
+ relationPath,
329
+ };
330
+ };
331
+
332
+ /**
333
+ * Every field directly on `entityId`, as resolved descriptors, sorted by label.
334
+ * `prefix` prepends a relation path so the returned `path` is root-relative.
335
+ */
336
+ export const listEntityFields = (model, entityId, prefix = '') => {
337
+ const entity = getEntity(model, entityId);
338
+ if (!entity) return [];
339
+ return Object.entries(entity.fields || {})
340
+ .map(([id, field]) => ({
341
+ path: prefix ? `${prefix}.${id}` : id,
342
+ fieldId: id,
343
+ field,
344
+ entityId,
345
+ label: field.label || id,
346
+ type: FIELD_TYPES.includes(field.type) ? field.type : 'text',
347
+ summable: !!field.summable,
348
+ values: field.values || null,
349
+ }))
350
+ .sort((a, b) => a.label.localeCompare(b.label));
351
+ };
352
+
353
+ /** Bucket resolved field descriptors into the type categories. */
354
+ export const categorizeSemanticFields = (fields) => {
355
+ const buckets = FIELD_CATEGORIES.map((category) => ({
356
+ ...category,
357
+ fields: [],
358
+ }));
359
+ const other = { id: 'other', label: 'Other', fields: [] };
360
+
361
+ (fields || []).forEach((entry) => {
362
+ const bucket = buckets.find((candidate) =>
363
+ candidate.types.includes(entry.type)
364
+ );
365
+ (bucket || other).fields.push(entry);
366
+ });
367
+
368
+ return [...buckets, other].filter((bucket) => bucket.fields.length > 0);
369
+ };
370
+
371
+ /* -------------------------------------------------------------------------- */
372
+ /* Aggregates */
373
+ /* -------------------------------------------------------------------------- */
374
+
375
+ /** True when sum/avg make sense for this field. */
376
+ export const isSummableField = (field) =>
377
+ !!field &&
378
+ (field.summable === true || NUMERIC_TYPES.includes(field.type || ''));
379
+
380
+ /**
381
+ * Aggregates offered for a field.
382
+ *
383
+ * Contract fixes sum/avg (summable or numeric) and count (anything). min/max
384
+ * are unspecified there, so we also offer them on dates — "earliest"/"latest"
385
+ * is the one aggregate a non-technical user reliably wants from a date.
386
+ */
387
+ export const aggregatesForField = (field) => {
388
+ if (!field) return [];
389
+ const type = field.type || 'text';
390
+ const available = ['count'];
391
+ if (isSummableField(field)) {
392
+ available.unshift('sum', 'avg');
393
+ available.push('min', 'max');
394
+ } else if (TEMPORAL_TYPES.includes(type)) {
395
+ available.push('min', 'max');
396
+ }
397
+ return AGGREGATES.filter((agg) => available.includes(agg));
398
+ };
399
+
400
+ /** Menu label for an aggregate applied to a field, e.g. "Earliest". */
401
+ export const aggregateOptionLabel = (agg, field) => {
402
+ const temporal = TEMPORAL_TYPES.includes(field?.type || '');
403
+ switch (agg) {
404
+ case 'sum':
405
+ return 'Total';
406
+ case 'count':
407
+ return 'Count';
408
+ case 'avg':
409
+ return 'Average';
410
+ case 'min':
411
+ return temporal ? 'Earliest' : 'Lowest';
412
+ case 'max':
413
+ return temporal ? 'Latest' : 'Highest';
414
+ default:
415
+ return 'Value';
416
+ }
417
+ };
418
+
419
+ /**
420
+ * Default column heading for an aggregated field. This doubles as the row key
421
+ * the server echoes back, so it must be stable and human-readable.
422
+ */
423
+ export const defaultAggregateLabel = (agg, fieldLabelText, field) => {
424
+ const temporal = TEMPORAL_TYPES.includes(field?.type || '');
425
+ switch (agg) {
426
+ case 'sum':
427
+ return `Total ${fieldLabelText.toLowerCase()}`;
428
+ case 'count':
429
+ return `Number of ${fieldLabelText.toLowerCase()}`;
430
+ case 'avg':
431
+ return `Average ${fieldLabelText.toLowerCase()}`;
432
+ case 'min':
433
+ return `${temporal ? 'Earliest' : 'Lowest'} ${fieldLabelText.toLowerCase()}`;
434
+ case 'max':
435
+ return `${temporal ? 'Latest' : 'Highest'} ${fieldLabelText.toLowerCase()}`;
436
+ default:
437
+ return fieldLabelText;
438
+ }
439
+ };
440
+
441
+ /* -------------------------------------------------------------------------- */
442
+ /* Selected columns */
443
+ /* -------------------------------------------------------------------------- */
444
+
445
+ /**
446
+ * A selection is `{ path, agg, label }`. `agg` is '' for a plain value.
447
+ * The row key the server returns is the path for plain fields and the label
448
+ * for aggregates — see `selectionKey`.
449
+ */
450
+ /**
451
+ * Default heading for a selection.
452
+ *
453
+ * A plain value keeps its relation context ("Their adviser › Name"), because
454
+ * a bare "Name" is ambiguous once two areas are in play. An aggregate uses the
455
+ * short field label so the heading still reads as English ("Total fee
456
+ * amount"); `dedupeSelectionLabels` resolves any collision that creates.
457
+ */
458
+ export const defaultSelectionLabel = (resolved, agg = '') => {
459
+ if (!resolved) return '';
460
+ return agg
461
+ ? defaultAggregateLabel(agg, resolved.label, resolved.field)
462
+ : resolved.fullLabel;
463
+ };
464
+
465
+ export const createSelection = (model, rootEntityId, path, agg = '') => {
466
+ const resolved = resolveFieldPath(model, rootEntityId, path);
467
+ if (!resolved) return null;
468
+ return {
469
+ path,
470
+ agg: agg || '',
471
+ label: defaultSelectionLabel(resolved, agg),
472
+ };
473
+ };
474
+
475
+ /** The exact key this column will occupy in an executed row. */
476
+ export const selectionKey = (selection) =>
477
+ selection?.agg ? selection.label : selection?.path;
478
+
479
+ /** Heading shown to the user — always the label, never a raw server key. */
480
+ export const selectionHeader = (model, rootEntityId, selection) => {
481
+ if (!selection) return '';
482
+ if (selection.agg) return selection.label || selection.path;
483
+ const resolved = resolveFieldPath(model, rootEntityId, selection.path);
484
+ if (!resolved) return selection.label || selection.path;
485
+ return selection.label || resolved.fullLabel;
486
+ };
487
+
488
+ /**
489
+ * Ensure aggregate labels stay unique — they are row keys, so a collision
490
+ * would silently drop a column.
491
+ */
492
+ export const dedupeSelectionLabels = (selections) => {
493
+ const seen = new Map();
494
+ return (selections || []).map((selection) => {
495
+ const key = selectionKey(selection);
496
+ const count = seen.get(key) || 0;
497
+ seen.set(key, count + 1);
498
+ if (count === 0 || !selection.agg) return selection;
499
+ return { ...selection, label: `${selection.label} (${count + 1})` };
500
+ });
501
+ };
502
+
503
+ /* -------------------------------------------------------------------------- */
504
+ /* Filter tree */
505
+ /* -------------------------------------------------------------------------- */
506
+
507
+ let nodeCounter = 0;
508
+
509
+ const nextNodeId = () => {
510
+ nodeCounter += 1;
511
+ return `node_${nodeCounter}`;
512
+ };
513
+
514
+ /** How many values an operator consumes: 0, 1, 2, or 'list'. */
515
+ export const operatorArity = (operator) => {
516
+ if (VALUELESS_OPERATORS.includes(operator)) return 0;
517
+ if (LIST_OPERATORS.includes(operator)) return 'list';
518
+ if (operator === 'between') return 2;
519
+ return 1;
520
+ };
521
+
522
+ /** Operators valid for a field type, defaulting to the text set. */
523
+ export const operatorsForType = (type) =>
524
+ OPERATORS_BY_TYPE[type] || OPERATORS_BY_TYPE.text;
525
+
526
+ export const operatorLabel = (operator) =>
527
+ OPERATOR_LABELS[operator] || operator || '';
528
+
529
+ export const createFilterCondition = (path = '', operator = '') => ({
530
+ _id: nextNodeId(),
531
+ kind: 'condition',
532
+ field: path,
533
+ operator,
534
+ value: '',
535
+ values: [],
536
+ param: '',
537
+ askEachTime: false,
538
+ });
539
+
540
+ export const createFilterGroup = (op = 'and', items = []) => ({
541
+ _id: nextNodeId(),
542
+ kind: 'group',
543
+ op,
544
+ items,
545
+ });
546
+
547
+ export const isFilterGroup = (node) =>
548
+ !!node && (node.kind === 'group' || Array.isArray(node.items));
549
+
550
+ /** Depth-first walk over every condition node in a tree. */
551
+ export const forEachCondition = (node, visit) => {
552
+ if (!node) return;
553
+ if (isFilterGroup(node)) {
554
+ (node.items || []).forEach((child) => forEachCondition(child, visit));
555
+ return;
556
+ }
557
+ visit(node);
558
+ };
559
+
560
+ /** Immutably replace the node with `_id === id`; `updater` returns the new node
561
+ * or null to delete it. */
562
+ export const updateFilterNode = (node, id, updater) => {
563
+ if (!node) return node;
564
+ if (node._id === id) return updater(node);
565
+ if (!isFilterGroup(node)) return node;
566
+ return {
567
+ ...node,
568
+ items: (node.items || [])
569
+ .map((child) => updateFilterNode(child, id, updater))
570
+ .filter((child) => child !== null),
571
+ };
572
+ };
573
+
574
+ /** Append `child` to the group with `_id === groupId`. */
575
+ export const appendToFilterGroup = (node, groupId, child) =>
576
+ updateFilterNode(node, groupId, (group) => ({
577
+ ...group,
578
+ items: [...(group.items || []), child],
579
+ }));
580
+
581
+ /** A condition is complete enough to serialise. */
582
+ export const isConditionComplete = (condition) => {
583
+ if (!condition?.field || !condition?.operator) return false;
584
+ const arity = operatorArity(condition.operator);
585
+ if (arity === 0) return true;
586
+ if (condition.askEachTime) return !!condition.param;
587
+ if (arity === 'list') return (condition.values || []).length > 0;
588
+ if (arity === 2) {
589
+ const [from, to] = condition.values || [];
590
+ return from !== '' && from != null && to !== '' && to != null;
591
+ }
592
+ return condition.value !== '' && condition.value != null;
593
+ };
594
+
595
+ /** Parameter type inferred from the field type and the operator it feeds. */
596
+ export const parameterTypeFor = (fieldType, operator) => {
597
+ if (operator === 'between') {
598
+ if (TEMPORAL_TYPES.includes(fieldType)) return 'date_range';
599
+ if (NUMERIC_TYPES.includes(fieldType)) return 'number_range';
600
+ return 'range';
601
+ }
602
+ if (LIST_OPERATORS.includes(operator)) {
603
+ return fieldType === 'enum' ? 'enum_multi' : 'list';
604
+ }
605
+ if (TEMPORAL_TYPES.includes(fieldType)) return fieldType;
606
+ if (NUMERIC_TYPES.includes(fieldType)) return 'number';
607
+ if (fieldType === 'boolean') return 'boolean';
608
+ if (fieldType === 'enum') return 'enum';
609
+ return 'text';
610
+ };
611
+
612
+ /** Slug used as a parameter id when the user has not named one. */
613
+ export const suggestParameterId = (path, operator) =>
614
+ `${String(path || 'value')
615
+ .replace(/[^a-z0-9]+/gi, '_')
616
+ .replace(/^_+|_+$/g, '')
617
+ .toLowerCase()}_${operator === 'between' ? 'range' : 'value'}`;
618
+
619
+ /* -------------------------------------------------------------------------- */
620
+ /* Serialisation */
621
+ /* -------------------------------------------------------------------------- */
622
+
623
+ const serializeCondition = (condition) => {
624
+ const node = {
625
+ field: condition.field,
626
+ operator: condition.operator,
627
+ };
628
+ const arity = operatorArity(condition.operator);
629
+ if (arity === 0) return node;
630
+ if (condition.askEachTime && condition.param) {
631
+ node.param = condition.param;
632
+ return node;
633
+ }
634
+ if (arity === 'list') {
635
+ node.value = [...(condition.values || [])];
636
+ } else if (arity === 2) {
637
+ node.value = [
638
+ condition.values?.[0] ?? '',
639
+ condition.values?.[1] ?? '',
640
+ ];
641
+ } else {
642
+ node.value = condition.value;
643
+ }
644
+ return node;
645
+ };
646
+
647
+ /**
648
+ * Serialise the editing tree into the wire format, dropping incomplete
649
+ * conditions and groups that end up empty. Returns null when nothing survives,
650
+ * so the caller can omit `filters` entirely.
651
+ */
652
+ export const serializeFilterTree = (node) => {
653
+ if (!node) return null;
654
+ if (!isFilterGroup(node)) {
655
+ return isConditionComplete(node) ? serializeCondition(node) : null;
656
+ }
657
+ const items = (node.items || [])
658
+ .map((child) => serializeFilterTree(child))
659
+ .filter(Boolean);
660
+ if (items.length === 0) return null;
661
+ return { op: node.op === 'or' ? 'or' : 'and', items };
662
+ };
663
+
664
+ /** Inverse of `serializeFilterTree` — rebuilds the editing tree with `_id`s. */
665
+ export const deserializeFilterTree = (node) => {
666
+ if (!node) return createFilterGroup('and', []);
667
+ if (isFilterGroup(node)) {
668
+ return createFilterGroup(
669
+ node.op === 'or' ? 'or' : 'and',
670
+ (node.items || []).map((child) => deserializeFilterTree(child))
671
+ );
672
+ }
673
+ const condition = createFilterCondition(node.field, node.operator);
674
+ const arity = operatorArity(node.operator);
675
+ if (node.param) {
676
+ condition.askEachTime = true;
677
+ condition.param = node.param;
678
+ } else if (arity === 'list' || arity === 2) {
679
+ condition.values = Array.isArray(node.value) ? [...node.value] : [];
680
+ } else if (arity === 1) {
681
+ condition.value = node.value ?? '';
682
+ }
683
+ return condition;
684
+ };
685
+
686
+ /**
687
+ * Collect parameter definitions declared by "ask each time" conditions.
688
+ * `existing` supplies previously edited labels so they survive a rebuild.
689
+ */
690
+ export const collectParameters = (model, entityId, tree, existing = []) => {
691
+ const byId = new Map((existing || []).map((param) => [param.id, param]));
692
+ const collected = [];
693
+ const seen = new Set();
694
+
695
+ forEachCondition(tree, (condition) => {
696
+ if (!condition.askEachTime || !condition.param) return;
697
+ if (seen.has(condition.param)) return;
698
+ seen.add(condition.param);
699
+
700
+ const resolved = resolveFieldPath(model, entityId, condition.field);
701
+ const previous = byId.get(condition.param);
702
+ collected.push({
703
+ id: condition.param,
704
+ label:
705
+ previous?.label ||
706
+ (resolved
707
+ ? `${resolved.fullLabel} ${operatorLabel(condition.operator)}`
708
+ : condition.param),
709
+ type: parameterTypeFor(resolved?.type || 'text', condition.operator),
710
+ required: previous?.required !== false,
711
+ });
712
+ });
713
+
714
+ return collected;
715
+ };
716
+
717
+ /**
718
+ * Build the v2 report definition document.
719
+ *
720
+ * @param {Object} args
721
+ * @param {Object} args.model Normalised semantic model.
722
+ * @param {string} args.entity Root entity id.
723
+ * @param {Array} args.selections Column selections (`{path, agg, label}`).
724
+ * @param {Object} args.filterTree Editing filter tree.
725
+ * @param {Array} args.parameters Parameter definitions.
726
+ * @param {Array} args.groupBy Field paths to group by.
727
+ * @param {Array} args.sort `[{field, dir}]` where field is a path.
728
+ */
729
+ export const buildDefinition = ({
730
+ model,
731
+ entity,
732
+ selections = [],
733
+ filterTree = null,
734
+ parameters = [],
735
+ groupBy = [],
736
+ sort = [],
737
+ }) => {
738
+ const definition = {
739
+ schema_version: SEMANTIC_SCHEMA_VERSION,
740
+ entity,
741
+ fields: dedupeSelectionLabels(selections).map((selection) =>
742
+ selection.agg
743
+ ? {
744
+ agg: selection.agg,
745
+ field: selection.path,
746
+ label: selection.label,
747
+ }
748
+ : { field: selection.path }
749
+ ),
750
+ };
751
+
752
+ const filters = serializeFilterTree(filterTree);
753
+ if (filters) definition.filters = filters;
754
+
755
+ const usedParamIds = new Set();
756
+ forEachCondition(filterTree, (condition) => {
757
+ if (condition.askEachTime && condition.param) {
758
+ usedParamIds.add(condition.param);
759
+ }
760
+ });
761
+ const activeParameters = (parameters || []).filter((param) =>
762
+ usedParamIds.has(param.id)
763
+ );
764
+ if (activeParameters.length > 0) definition.parameters = activeParameters;
765
+
766
+ const validGroupBy = (groupBy || []).filter((path) =>
767
+ model ? !!resolveFieldPath(model, entity, path) : true
768
+ );
769
+ if (validGroupBy.length > 0) definition.groupBy = validGroupBy;
770
+
771
+ const validSort = (sort || []).filter((entry) => entry && entry.field);
772
+ if (validSort.length > 0) {
773
+ definition.sort = validSort.map((entry) => ({
774
+ field: entry.field,
775
+ dir: entry.dir === 'desc' ? 'desc' : 'asc',
776
+ }));
777
+ }
778
+
779
+ return definition;
780
+ };
781
+
782
+ /** True when a saved `detail` document is a v2 semantic definition. */
783
+ export const isSemanticDefinition = (detail) => {
784
+ if (!detail || typeof detail !== 'object') return false;
785
+ if (detail.mainTable) return false;
786
+ return (
787
+ detail.schema_version === SEMANTIC_SCHEMA_VERSION ||
788
+ (!!detail.entity && Array.isArray(detail.fields))
789
+ );
790
+ };
791
+
792
+ /** Rehydrate wizard state from a saved v2 definition. */
793
+ export const parseDefinition = (definition) => {
794
+ if (!isSemanticDefinition(definition)) return null;
795
+
796
+ const selections = (definition.fields || []).map((field) => ({
797
+ path: field.field,
798
+ agg: field.agg || '',
799
+ label: field.label || '',
800
+ }));
801
+
802
+ return {
803
+ entity: definition.entity,
804
+ selections,
805
+ filterTree: definition.filters
806
+ ? deserializeFilterTree(definition.filters)
807
+ : createFilterGroup('and', []),
808
+ parameters: Array.isArray(definition.parameters)
809
+ ? definition.parameters.map((param) => ({ ...param }))
810
+ : [],
811
+ groupBy: Array.isArray(definition.groupBy)
812
+ ? [...definition.groupBy]
813
+ : [],
814
+ sort: Array.isArray(definition.sort)
815
+ ? definition.sort.map((entry) => ({
816
+ field: entry.field,
817
+ dir: entry.dir === 'desc' ? 'desc' : 'asc',
818
+ }))
819
+ : [],
820
+ };
821
+ };
822
+
823
+ /**
824
+ * Recover the related areas a parsed definition reaches through, so the
825
+ * relations step can show them as already added after a load. Every prefix of
826
+ * every dot-path is included, which is what makes chained relations reappear.
827
+ */
828
+ export const relationPathsFromDefinition = (parsed, model) => {
829
+ if (!parsed) return [];
830
+
831
+ const paths = new Set();
832
+ const addPath = (path) => {
833
+ if (typeof path !== 'string' || !path.includes('.')) return;
834
+ const segments = path.split('.');
835
+ segments.pop(); // the field itself
836
+ segments.forEach((segment, index) => {
837
+ paths.add(segments.slice(0, index + 1).join('.'));
838
+ });
839
+ };
840
+
841
+ (parsed.selections || []).forEach((selection) => addPath(selection.path));
842
+ (parsed.groupBy || []).forEach(addPath);
843
+ (parsed.sort || []).forEach((entry) => addPath(entry.field));
844
+ forEachCondition(parsed.filterTree, (condition) =>
845
+ addPath(condition.field)
846
+ );
847
+
848
+ const resolvable = Array.from(paths).filter((path) =>
849
+ model ? !!resolveRelationPath(model, parsed.entity, path) : true
850
+ );
851
+
852
+ // Shortest first, so a parent is always listed before anything nested.
853
+ return resolvable.sort(
854
+ (a, b) => a.split('.').length - b.split('.').length
855
+ );
856
+ };
857
+
858
+ /* -------------------------------------------------------------------------- */
859
+ /* Runtime parameters */
860
+ /* -------------------------------------------------------------------------- */
861
+
862
+ /**
863
+ * Find the field path a parameter feeds, by locating the condition that
864
+ * references it. The wire format for a parameter carries only id/label/type/
865
+ * required, so this is how the run screen recovers enum choices without
866
+ * extending the contract.
867
+ */
868
+ export const parameterFieldPath = (tree, parameterId) => {
869
+ let found = '';
870
+ forEachCondition(tree, (condition) => {
871
+ if (!found && condition.param === parameterId) {
872
+ found = condition.field;
873
+ }
874
+ });
875
+ return found;
876
+ };
877
+
878
+ /** A blank runtime value shaped for the parameter's type. */
879
+ export const emptyParameterValue = (type) => {
880
+ if (type === 'date_range' || type === 'number_range' || type === 'range') {
881
+ return ['', ''];
882
+ }
883
+ if (type === 'enum_multi' || type === 'list') return [];
884
+ if (type === 'boolean') return false;
885
+ return '';
886
+ };
887
+
888
+ /** True when every required parameter has been answered. */
889
+ export const parametersSatisfied = (parameters, values) =>
890
+ (parameters || []).every((param) => {
891
+ if (param.required === false) return true;
892
+ const value = values?.[param.id];
893
+ if (Array.isArray(value)) {
894
+ return (
895
+ value.length > 0 &&
896
+ value.every((entry) => entry !== '' && entry != null)
897
+ );
898
+ }
899
+ if (typeof value === 'boolean') return true;
900
+ return value !== '' && value != null;
901
+ });
902
+
903
+ /* -------------------------------------------------------------------------- */
904
+ /* Display */
905
+ /* -------------------------------------------------------------------------- */
906
+
907
+ const currencyFormatter = new Intl.NumberFormat('en-AU', {
908
+ style: 'currency',
909
+ currency: 'AUD',
910
+ });
911
+
912
+ const numberFormatter = new Intl.NumberFormat('en-AU');
913
+
914
+ /**
915
+ * Format a cell for display using the semantic type — no name sniffing, the
916
+ * model already told us what the value is.
917
+ */
918
+ export const formatSemanticValue = (value, type, field) => {
919
+ if (value === null || value === undefined || value === '') return '';
920
+
921
+ switch (type) {
922
+ case 'money': {
923
+ const numeric = Number(value);
924
+ return Number.isFinite(numeric)
925
+ ? currencyFormatter.format(numeric)
926
+ : String(value);
927
+ }
928
+ case 'percent': {
929
+ const numeric = Number(value);
930
+ return Number.isFinite(numeric)
931
+ ? `${numberFormatter.format(numeric)}%`
932
+ : String(value);
933
+ }
934
+ case 'number': {
935
+ const numeric = Number(value);
936
+ return Number.isFinite(numeric)
937
+ ? numberFormatter.format(numeric)
938
+ : String(value);
939
+ }
940
+ case 'date': {
941
+ const date = new Date(value);
942
+ return Number.isNaN(date.getTime())
943
+ ? String(value)
944
+ : date.toLocaleDateString();
945
+ }
946
+ case 'datetime': {
947
+ const date = new Date(value);
948
+ return Number.isNaN(date.getTime())
949
+ ? String(value)
950
+ : date.toLocaleString();
951
+ }
952
+ case 'boolean': {
953
+ if (value === true || value === 1 || value === '1') return 'Yes';
954
+ if (value === false || value === 0 || value === '0') return 'No';
955
+ return String(value);
956
+ }
957
+ case 'enum': {
958
+ const values = field?.values || {};
959
+ return values[value] ?? values[String(value)] ?? String(value);
960
+ }
961
+ default:
962
+ return String(value);
963
+ }
964
+ };
965
+
966
+ /** `{key, value}` pairs for an enum field's select options. */
967
+ export const enumOptions = (field) =>
968
+ Object.entries(field?.values || {}).map(([value, label]) => ({
969
+ value,
970
+ label: String(label),
971
+ }));