@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.
- package/README.md +161 -1
- package/package.json +1 -1
- package/src/components/Fetch.jsx +49 -19
- package/src/components/TableFilter.jsx +59 -45
- package/src/components/generic/GenericReport.jsx +835 -68
- package/src/components/generic/SectionGroupedReport.jsx +199 -226
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +81 -0
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +337 -0
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +483 -0
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +202 -0
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +157 -0
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +352 -0
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +154 -0
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +153 -0
- package/src/components/generic/reportSemantics.js +971 -0
- package/src/components/generic/useSemanticModel.js +99 -0
- package/src/components/styles/ReportSemantic.module.scss +444 -0
- package/src/index.js +4 -0
|
@@ -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;
|