@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,157 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ parameterFieldPath,
5
+ resolveFieldPath,
6
+ emptyParameterValue,
7
+ } from '../reportSemantics';
8
+ import SemanticValueInput, {
9
+ SemanticValueListInput,
10
+ } from './SemanticValueInput';
11
+ import styles from '../../styles/ReportSemantic.module.scss';
12
+
13
+ const RANGE_TYPES = ['date_range', 'number_range', 'range'];
14
+ const LIST_TYPES = ['enum_multi', 'list'];
15
+
16
+ /** The value input type a range/list parameter uses for each of its slots. */
17
+ const slotType = (parameterType) => {
18
+ switch (parameterType) {
19
+ case 'date_range':
20
+ return 'date';
21
+ case 'number_range':
22
+ return 'number';
23
+ case 'enum_multi':
24
+ case 'enum':
25
+ return 'enum';
26
+ case 'datetime':
27
+ return 'datetime';
28
+ case 'date':
29
+ return 'date';
30
+ case 'number':
31
+ return 'number';
32
+ case 'boolean':
33
+ return 'boolean';
34
+ default:
35
+ return 'text';
36
+ }
37
+ };
38
+
39
+ /**
40
+ * Collects the runtime answers for a report's parameters.
41
+ *
42
+ * Values are POSTed as `parameters: {due_range: ['2026-01-01','2026-03-31']}`
43
+ * alongside the definition — ranges and lists as arrays, everything else as a
44
+ * scalar.
45
+ */
46
+ const SemanticParameterPrompt = ({
47
+ model,
48
+ entityId,
49
+ filterTree,
50
+ parameters,
51
+ values,
52
+ onChange,
53
+ }) => {
54
+ if (!parameters || parameters.length === 0) return null;
55
+
56
+ return (
57
+ <div className={styles.section}>
58
+ <div className={styles.sectionIntro}>
59
+ <h3>Before we run this report</h3>
60
+ <p>Answer these to decide which records are included.</p>
61
+ </div>
62
+
63
+ <div className={styles.parameterList}>
64
+ {parameters.map((parameter) => {
65
+ const path = parameterFieldPath(filterTree, parameter.id);
66
+ const resolved = resolveFieldPath(model, entityId, path);
67
+ const type = slotType(parameter.type);
68
+ const value =
69
+ values?.[parameter.id] ??
70
+ emptyParameterValue(parameter.type);
71
+
72
+ return (
73
+ <div
74
+ key={parameter.id}
75
+ className={styles.parameterRow}
76
+ >
77
+ <span className={styles.parameterLabel}>
78
+ {parameter.label}
79
+ {parameter.required !== false ? ' *' : ''}
80
+ </span>
81
+
82
+ {RANGE_TYPES.includes(parameter.type) ? (
83
+ <span className={styles.conditionValues}>
84
+ <SemanticValueInput
85
+ type={type}
86
+ field={resolved?.field}
87
+ value={value?.[0] ?? ''}
88
+ ariaLabel={`${parameter.label} from`}
89
+ onChange={(next) =>
90
+ onChange(parameter.id, [
91
+ next,
92
+ value?.[1] ?? '',
93
+ ])
94
+ }
95
+ />
96
+ <span>and</span>
97
+ <SemanticValueInput
98
+ type={type}
99
+ field={resolved?.field}
100
+ value={value?.[1] ?? ''}
101
+ ariaLabel={`${parameter.label} to`}
102
+ onChange={(next) =>
103
+ onChange(parameter.id, [
104
+ value?.[0] ?? '',
105
+ next,
106
+ ])
107
+ }
108
+ />
109
+ </span>
110
+ ) : LIST_TYPES.includes(parameter.type) ? (
111
+ <SemanticValueListInput
112
+ type={type}
113
+ field={resolved?.field}
114
+ values={
115
+ Array.isArray(value) ? value : []
116
+ }
117
+ onChange={(next) =>
118
+ onChange(parameter.id, next)
119
+ }
120
+ />
121
+ ) : (
122
+ <SemanticValueInput
123
+ type={type}
124
+ field={resolved?.field}
125
+ value={value}
126
+ ariaLabel={parameter.label}
127
+ onChange={(next) =>
128
+ onChange(parameter.id, next)
129
+ }
130
+ />
131
+ )}
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ SemanticParameterPrompt.propTypes = {
141
+ model: PropTypes.object,
142
+ entityId: PropTypes.string,
143
+ filterTree: PropTypes.object,
144
+ parameters: PropTypes.array,
145
+ values: PropTypes.object,
146
+ onChange: PropTypes.func.isRequired,
147
+ };
148
+
149
+ SemanticParameterPrompt.defaultProps = {
150
+ model: null,
151
+ entityId: '',
152
+ filterTree: null,
153
+ parameters: [],
154
+ values: {},
155
+ };
156
+
157
+ export default SemanticParameterPrompt;
@@ -0,0 +1,352 @@
1
+ import React, { useMemo } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { AlertTriangle } from 'lucide-react';
4
+ import GroupedReportRenderer from '../GroupedReportRenderer';
5
+ import {
6
+ formatSemanticValue,
7
+ parametersSatisfied,
8
+ resolveFieldPath,
9
+ selectionHeader,
10
+ selectionKey,
11
+ } from '../reportSemantics';
12
+ import SemanticParameterPrompt from './SemanticParameterPrompt';
13
+ import styles from '../../styles/ReportSemantic.module.scss';
14
+
15
+ const NUMERIC_TYPES = ['number', 'money', 'percent'];
16
+
17
+ /**
18
+ * Column descriptors for the preview: `key` is the exact row key the server
19
+ * promised (field path, or the aggregate label), `header` is always the user's
20
+ * own wording. This is the fix for headers previously being raw server keys.
21
+ */
22
+ export const buildPreviewColumns = (model, entityId, selections) =>
23
+ (selections || []).map((selection) => {
24
+ const resolved = resolveFieldPath(model, entityId, selection.path);
25
+ return {
26
+ key: selectionKey(selection),
27
+ header: selectionHeader(model, entityId, selection),
28
+ // A count is a plain number whatever the underlying field was.
29
+ type: selection.agg === 'count' ? 'number' : resolved?.type,
30
+ field: resolved?.field || null,
31
+ };
32
+ });
33
+
34
+ /** Client-side sectioning for a report the user chose to group. */
35
+ const buildGroups = (rows, columns, groupKey) => {
36
+ const buckets = new Map();
37
+ const groupColumn = columns.find((entry) => entry.key === groupKey);
38
+
39
+ rows.forEach((row) => {
40
+ const label =
41
+ formatSemanticValue(
42
+ row[groupKey],
43
+ groupColumn?.type,
44
+ groupColumn?.field
45
+ ) || 'Not set';
46
+ if (!buckets.has(label)) buckets.set(label, []);
47
+ buckets.get(label).push(row);
48
+ });
49
+
50
+ return Array.from(buckets.entries()).map(([label, groupRows]) => ({
51
+ groupName: label,
52
+ groupDisplayName: label,
53
+ totalRows: groupRows.length,
54
+ rows: groupRows.map((row) =>
55
+ columns.reduce((acc, column) => {
56
+ acc[column.key] = formatSemanticValue(
57
+ row[column.key],
58
+ column.type,
59
+ column.field
60
+ );
61
+ return acc;
62
+ }, {})
63
+ ),
64
+ }));
65
+ };
66
+
67
+ /**
68
+ * Step 6 in semantic mode: answer any run-time questions, run the report, and
69
+ * review the results before saving or exporting.
70
+ */
71
+ const SemanticPreviewStep = ({
72
+ model,
73
+ entityId,
74
+ selections,
75
+ filterTree,
76
+ parameters,
77
+ parameterValues,
78
+ groupBy,
79
+ rows,
80
+ total,
81
+ page,
82
+ pageSize,
83
+ isLoading,
84
+ error,
85
+ onChangeParameterValue,
86
+ onRun,
87
+ onChangePage,
88
+ onChangePageSize,
89
+ onExport,
90
+ onSave,
91
+ canSave,
92
+ }) => {
93
+ const columns = useMemo(
94
+ () => buildPreviewColumns(model, entityId, selections),
95
+ [model, entityId, selections]
96
+ );
97
+
98
+ const ready = parametersSatisfied(parameters, parameterValues);
99
+ const groupKey = (groupBy || [])[0] || '';
100
+ const grouped = !!groupKey && rows.length > 0;
101
+ const totalPages = pageSize > 0 ? Math.ceil((total || 0) / pageSize) : 0;
102
+
103
+ const groups = useMemo(
104
+ () => (grouped ? buildGroups(rows, columns, groupKey) : []),
105
+ [grouped, rows, columns, groupKey]
106
+ );
107
+
108
+ return (
109
+ <div className={styles.section}>
110
+ <SemanticParameterPrompt
111
+ model={model}
112
+ entityId={entityId}
113
+ filterTree={filterTree}
114
+ parameters={parameters}
115
+ values={parameterValues}
116
+ onChange={onChangeParameterValue}
117
+ />
118
+
119
+ <div className={styles.previewActions}>
120
+ <button
121
+ type="button"
122
+ className={styles.toggleButton}
123
+ disabled={isLoading || !ready || selections.length === 0}
124
+ onClick={() => onRun({ page: 1 })}
125
+ >
126
+ {isLoading ? 'Running…' : 'Run report'}
127
+ </button>
128
+
129
+ <button
130
+ type="button"
131
+ className={styles.toggleButton}
132
+ disabled={!canSave}
133
+ onClick={onSave}
134
+ >
135
+ Save report
136
+ </button>
137
+
138
+ <span className={styles.aggPicker}>
139
+ <span>Export as:</span>
140
+ <select
141
+ className={styles.select}
142
+ aria-label="Export format"
143
+ defaultValue=""
144
+ onChange={(event) => {
145
+ if (event.target.value) {
146
+ onExport(event.target.value);
147
+ event.target.value = '';
148
+ }
149
+ }}
150
+ disabled={rows.length === 0 || isLoading}
151
+ >
152
+ <option value="">Choose…</option>
153
+ <option value="xlsx">Excel</option>
154
+ <option value="csv">CSV</option>
155
+ <option value="pdf">PDF</option>
156
+ </select>
157
+ </span>
158
+ </div>
159
+
160
+ {!ready && (parameters || []).length > 0 && (
161
+ <span className={styles.cardMeta}>
162
+ Answer the questions above to run this report.
163
+ </span>
164
+ )}
165
+
166
+ {error && (
167
+ <div className={styles.errorPanel} role="alert">
168
+ <AlertTriangle size={16} />
169
+ <span>{error}</span>
170
+ </div>
171
+ )}
172
+
173
+ {isLoading && <div className={styles.loading}>Running your report…</div>}
174
+
175
+ {!isLoading && !error && rows.length === 0 && (
176
+ <div className={styles.empty}>
177
+ No results yet. Run the report to see your data.
178
+ </div>
179
+ )}
180
+
181
+ {!isLoading && rows.length > 0 && grouped && (
182
+ <GroupedReportRenderer
183
+ data={{
184
+ grouped: true,
185
+ groups,
186
+ totalGroups: groups.length,
187
+ totalRecords: total || rows.length,
188
+ }}
189
+ config={{
190
+ type: 'sections',
191
+ groupDisplayName:
192
+ columns.find((column) => column.key === groupKey)
193
+ ?.header || 'Group',
194
+ showGroupTotals: true,
195
+ }}
196
+ columns={columns.map((column) => ({
197
+ key: column.key,
198
+ header: column.header,
199
+ }))}
200
+ />
201
+ )}
202
+
203
+ {!isLoading && rows.length > 0 && !grouped && (
204
+ <>
205
+ <div className={styles.summaryBar}>
206
+ <span>
207
+ Showing {rows.length} of {total || rows.length}{' '}
208
+ records
209
+ </span>
210
+ <span className={styles.aggPicker}>
211
+ <span>Rows per page:</span>
212
+ <select
213
+ className={styles.select}
214
+ aria-label="Rows per page"
215
+ value={pageSize}
216
+ onChange={(event) =>
217
+ onChangePageSize(
218
+ Number(event.target.value)
219
+ )
220
+ }
221
+ >
222
+ {[10, 25, 50, 100].map((size) => (
223
+ <option key={size} value={size}>
224
+ {size}
225
+ </option>
226
+ ))}
227
+ </select>
228
+ </span>
229
+ </div>
230
+
231
+ <div className={styles.tableWrapper}>
232
+ <table className={styles.previewTable}>
233
+ <thead>
234
+ <tr>
235
+ {columns.map((column) => (
236
+ <th key={column.key}>
237
+ {column.header}
238
+ </th>
239
+ ))}
240
+ </tr>
241
+ </thead>
242
+ <tbody>
243
+ {rows.map((row, rowIndex) => (
244
+ <tr key={row.id || rowIndex}>
245
+ {columns.map((column) => {
246
+ const formatted =
247
+ formatSemanticValue(
248
+ row[column.key],
249
+ column.type,
250
+ column.field
251
+ );
252
+ return (
253
+ <td
254
+ key={column.key}
255
+ className={
256
+ NUMERIC_TYPES.includes(
257
+ column.type
258
+ )
259
+ ? styles.numericCell
260
+ : undefined
261
+ }
262
+ >
263
+ {formatted === '' ? (
264
+ <span
265
+ className={
266
+ styles.emptyCell
267
+ }
268
+ >
269
+
270
+ </span>
271
+ ) : (
272
+ formatted
273
+ )}
274
+ </td>
275
+ );
276
+ })}
277
+ </tr>
278
+ ))}
279
+ </tbody>
280
+ </table>
281
+ </div>
282
+
283
+ {totalPages > 1 && (
284
+ <div className={styles.previewActions}>
285
+ <button
286
+ type="button"
287
+ className={styles.toggleButton}
288
+ disabled={page <= 1 || isLoading}
289
+ onClick={() => onChangePage(page - 1)}
290
+ >
291
+ Previous
292
+ </button>
293
+ <span className={styles.cardMeta}>
294
+ Page {page} of {totalPages}
295
+ </span>
296
+ <button
297
+ type="button"
298
+ className={styles.toggleButton}
299
+ disabled={page >= totalPages || isLoading}
300
+ onClick={() => onChangePage(page + 1)}
301
+ >
302
+ Next
303
+ </button>
304
+ </div>
305
+ )}
306
+ </>
307
+ )}
308
+ </div>
309
+ );
310
+ };
311
+
312
+ SemanticPreviewStep.propTypes = {
313
+ model: PropTypes.object,
314
+ entityId: PropTypes.string,
315
+ selections: PropTypes.array,
316
+ filterTree: PropTypes.object,
317
+ parameters: PropTypes.array,
318
+ parameterValues: PropTypes.object,
319
+ groupBy: PropTypes.arrayOf(PropTypes.string),
320
+ rows: PropTypes.array,
321
+ total: PropTypes.number,
322
+ page: PropTypes.number,
323
+ pageSize: PropTypes.number,
324
+ isLoading: PropTypes.bool,
325
+ error: PropTypes.string,
326
+ onChangeParameterValue: PropTypes.func.isRequired,
327
+ onRun: PropTypes.func.isRequired,
328
+ onChangePage: PropTypes.func.isRequired,
329
+ onChangePageSize: PropTypes.func.isRequired,
330
+ onExport: PropTypes.func.isRequired,
331
+ onSave: PropTypes.func.isRequired,
332
+ canSave: PropTypes.bool,
333
+ };
334
+
335
+ SemanticPreviewStep.defaultProps = {
336
+ model: null,
337
+ entityId: '',
338
+ selections: [],
339
+ filterTree: null,
340
+ parameters: [],
341
+ parameterValues: {},
342
+ groupBy: [],
343
+ rows: [],
344
+ total: 0,
345
+ page: 1,
346
+ pageSize: 25,
347
+ isLoading: false,
348
+ error: '',
349
+ canSave: false,
350
+ };
351
+
352
+ export default SemanticPreviewStep;
@@ -0,0 +1,154 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { CheckCircle, Info } from 'lucide-react';
4
+ import {
5
+ cardinalityHint,
6
+ getEntityLabel,
7
+ listRelations,
8
+ } from '../reportSemantics';
9
+ import styles from '../../styles/ReportSemantic.module.scss';
10
+
11
+ /** How many relation hops away from the root entity the picker will offer. */
12
+ export const MAX_RELATION_DEPTH = 3;
13
+
14
+ /**
15
+ * Every relation path reachable from `entityId`, given the ones already added.
16
+ * A relation is offered when its parent is the root entity or an added path,
17
+ * which lets the user chain (`adviser` then `adviser.team`) without ever
18
+ * facing the full transitive closure of the model.
19
+ */
20
+ export const availableRelationPaths = (model, entityId, addedPaths) => {
21
+ if (!model || !entityId) return [];
22
+
23
+ const options = [];
24
+ const seen = new Set();
25
+
26
+ const expand = (fromEntity, prefix, depth) => {
27
+ if (depth > MAX_RELATION_DEPTH) return;
28
+ listRelations(model, fromEntity).forEach((relation) => {
29
+ const path = prefix ? `${prefix}.${relation.id}` : relation.id;
30
+ if (seen.has(path)) return;
31
+ seen.add(path);
32
+ options.push({ ...relation, path, depth, parentPath: prefix });
33
+ if ((addedPaths || []).includes(path)) {
34
+ expand(relation.entity, path, depth + 1);
35
+ }
36
+ });
37
+ };
38
+
39
+ expand(entityId, '', 1);
40
+ return options;
41
+ };
42
+
43
+ /**
44
+ * Step 2 in semantic mode: choose which related areas to pull in.
45
+ *
46
+ * There are no join columns here — joins do not exist in semantic mode. The
47
+ * server already declared how each relation connects; the user only decides
48
+ * whether they want it.
49
+ */
50
+ const SemanticRelationsStep = ({
51
+ model,
52
+ entityId,
53
+ addedRelations,
54
+ onToggle,
55
+ }) => {
56
+ const options = availableRelationPaths(model, entityId, addedRelations);
57
+ const entityLabel = getEntityLabel(model, entityId);
58
+
59
+ if (options.length === 0) {
60
+ return (
61
+ <div className={styles.section}>
62
+ <div className={styles.sectionIntro}>
63
+ <h3>No related areas available</h3>
64
+ <p>
65
+ {entityLabel} has no connected information, so you can
66
+ move straight on to choosing what to show.
67
+ </p>
68
+ </div>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <div className={styles.section}>
75
+ <div className={styles.sectionIntro}>
76
+ <h3>Bring in related information</h3>
77
+ <p>
78
+ Add anything connected to {entityLabel.toLowerCase()} that
79
+ you want to show or filter on. This step is optional.
80
+ </p>
81
+ </div>
82
+
83
+ <div className={styles.cardGrid}>
84
+ {options.map((relation) => {
85
+ const isAdded = (addedRelations || []).includes(
86
+ relation.path
87
+ );
88
+ const parentLabel = relation.parentPath
89
+ ? options.find(
90
+ (candidate) =>
91
+ candidate.path === relation.parentPath
92
+ )?.label
93
+ : null;
94
+
95
+ return (
96
+ <button
97
+ key={relation.path}
98
+ type="button"
99
+ className={`${styles.card} ${
100
+ isAdded ? styles.selected : ''
101
+ }`}
102
+ aria-pressed={isAdded}
103
+ onClick={() => onToggle(relation.path)}
104
+ >
105
+ <span className={styles.cardTitle}>
106
+ <span>
107
+ {parentLabel
108
+ ? `${parentLabel} › ${relation.label}`
109
+ : relation.label}
110
+ </span>
111
+ {isAdded && <CheckCircle size={16} />}
112
+ </span>
113
+ {relation.description && (
114
+ <span className={styles.cardDescription}>
115
+ {relation.description}
116
+ </span>
117
+ )}
118
+ <span
119
+ className={`${styles.badge} ${
120
+ relation.cardinality === 'many'
121
+ ? styles.badgeMany
122
+ : ''
123
+ }`}
124
+ >
125
+ {cardinalityHint(relation)}
126
+ </span>
127
+ </button>
128
+ );
129
+ })}
130
+ </div>
131
+
132
+ <div className={styles.cardMeta}>
133
+ <Info size={14} /> Adding an area that has many records per{' '}
134
+ {entityLabel.toLowerCase()} can repeat rows. Summarising those
135
+ fields (count, total) at the next step keeps one row per record.
136
+ </div>
137
+ </div>
138
+ );
139
+ };
140
+
141
+ SemanticRelationsStep.propTypes = {
142
+ model: PropTypes.object,
143
+ entityId: PropTypes.string,
144
+ addedRelations: PropTypes.arrayOf(PropTypes.string),
145
+ onToggle: PropTypes.func.isRequired,
146
+ };
147
+
148
+ SemanticRelationsStep.defaultProps = {
149
+ model: null,
150
+ entityId: '',
151
+ addedRelations: [],
152
+ };
153
+
154
+ export default SemanticRelationsStep;