@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,337 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { ChevronDown, ChevronRight, X } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
aggregateOptionLabel,
|
|
6
|
+
aggregatesForField,
|
|
7
|
+
categorizeSemanticFields,
|
|
8
|
+
getEntityLabel,
|
|
9
|
+
listEntityFields,
|
|
10
|
+
resolveRelationPath,
|
|
11
|
+
} from '../reportSemantics';
|
|
12
|
+
import styles from '../../styles/ReportSemantic.module.scss';
|
|
13
|
+
|
|
14
|
+
const TYPE_LABELS = {
|
|
15
|
+
text: 'text',
|
|
16
|
+
number: 'number',
|
|
17
|
+
money: 'amount',
|
|
18
|
+
percent: 'percentage',
|
|
19
|
+
date: 'date',
|
|
20
|
+
datetime: 'date & time',
|
|
21
|
+
boolean: 'yes/no',
|
|
22
|
+
enum: 'choice',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The sources a user can pick fields from: the main entity plus every related
|
|
27
|
+
* area they added at step 2.
|
|
28
|
+
*/
|
|
29
|
+
export const buildFieldSources = (model, entityId, addedRelations) => {
|
|
30
|
+
if (!model || !entityId) return [];
|
|
31
|
+
|
|
32
|
+
const sources = [
|
|
33
|
+
{
|
|
34
|
+
id: '__root__',
|
|
35
|
+
path: '',
|
|
36
|
+
label: getEntityLabel(model, entityId),
|
|
37
|
+
cardinality: 'one',
|
|
38
|
+
fields: listEntityFields(model, entityId, ''),
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
(addedRelations || []).forEach((relationPath) => {
|
|
43
|
+
const resolved = resolveRelationPath(model, entityId, relationPath);
|
|
44
|
+
if (!resolved) return;
|
|
45
|
+
sources.push({
|
|
46
|
+
id: relationPath,
|
|
47
|
+
path: relationPath,
|
|
48
|
+
label: resolved.label,
|
|
49
|
+
cardinality: resolved.cardinality,
|
|
50
|
+
fields: listEntityFields(
|
|
51
|
+
model,
|
|
52
|
+
resolved.entityId,
|
|
53
|
+
relationPath
|
|
54
|
+
),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return sources;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Step 3 in semantic mode: choose what to show.
|
|
63
|
+
*
|
|
64
|
+
* Every field carries a "Show as" picker so the same control covers plain
|
|
65
|
+
* values and aggregates (sum / count / average / min / max). Aggregates are
|
|
66
|
+
* only offered where the contract allows them.
|
|
67
|
+
*/
|
|
68
|
+
const SemanticFieldsStep = ({
|
|
69
|
+
model,
|
|
70
|
+
entityId,
|
|
71
|
+
addedRelations,
|
|
72
|
+
selections,
|
|
73
|
+
onToggleField,
|
|
74
|
+
onChangeAggregate,
|
|
75
|
+
onRemoveSelection,
|
|
76
|
+
onMoveSelection,
|
|
77
|
+
}) => {
|
|
78
|
+
const sources = useMemo(
|
|
79
|
+
() => buildFieldSources(model, entityId, addedRelations),
|
|
80
|
+
[model, entityId, addedRelations]
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const [collapsed, setCollapsed] = useState(() => new Set());
|
|
84
|
+
|
|
85
|
+
const toggleSource = (sourceId) => {
|
|
86
|
+
setCollapsed((previous) => {
|
|
87
|
+
const next = new Set(previous);
|
|
88
|
+
if (next.has(sourceId)) {
|
|
89
|
+
next.delete(sourceId);
|
|
90
|
+
} else {
|
|
91
|
+
next.add(sourceId);
|
|
92
|
+
}
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const selectionFor = (path) =>
|
|
98
|
+
(selections || []).find((selection) => selection.path === path) || null;
|
|
99
|
+
|
|
100
|
+
if (sources.length === 0) {
|
|
101
|
+
return (
|
|
102
|
+
<div className={styles.empty}>
|
|
103
|
+
Choose what the report is about first.
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={styles.section}>
|
|
110
|
+
<div className={styles.sectionIntro}>
|
|
111
|
+
<h3>What do you want to see?</h3>
|
|
112
|
+
<p>
|
|
113
|
+
Tick the details to include. Use “Show as” to
|
|
114
|
+
summarise a field instead of listing it.
|
|
115
|
+
</p>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{sources.map((source) => {
|
|
119
|
+
const isCollapsed = collapsed.has(source.id);
|
|
120
|
+
const categories = categorizeSemanticFields(source.fields);
|
|
121
|
+
const chosen = source.fields.filter((entry) =>
|
|
122
|
+
selectionFor(entry.path)
|
|
123
|
+
).length;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div key={source.id} className={styles.sourceGroup}>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
className={styles.sourceHeader}
|
|
130
|
+
aria-expanded={!isCollapsed}
|
|
131
|
+
onClick={() => toggleSource(source.id)}
|
|
132
|
+
>
|
|
133
|
+
<span>
|
|
134
|
+
{isCollapsed ? (
|
|
135
|
+
<ChevronRight size={16} />
|
|
136
|
+
) : (
|
|
137
|
+
<ChevronDown size={16} />
|
|
138
|
+
)}{' '}
|
|
139
|
+
{source.label}
|
|
140
|
+
{source.cardinality === 'many' && (
|
|
141
|
+
<span className={styles.badge}>
|
|
142
|
+
{' '}
|
|
143
|
+
many per record
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</span>
|
|
147
|
+
<span className={styles.cardMeta}>
|
|
148
|
+
{chosen} of {source.fields.length} selected
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
|
|
152
|
+
{!isCollapsed &&
|
|
153
|
+
categories.map((category) => (
|
|
154
|
+
<div
|
|
155
|
+
key={category.id}
|
|
156
|
+
className={styles.categoryGroup}
|
|
157
|
+
>
|
|
158
|
+
<h5>{category.label}</h5>
|
|
159
|
+
{category.fields.map((entry) => {
|
|
160
|
+
const selection = selectionFor(
|
|
161
|
+
entry.path
|
|
162
|
+
);
|
|
163
|
+
const aggregates = aggregatesForField(
|
|
164
|
+
entry.field
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div
|
|
169
|
+
key={entry.path}
|
|
170
|
+
className={styles.fieldRow}
|
|
171
|
+
>
|
|
172
|
+
<label
|
|
173
|
+
className={
|
|
174
|
+
styles.fieldCheckbox
|
|
175
|
+
}
|
|
176
|
+
>
|
|
177
|
+
<input
|
|
178
|
+
type="checkbox"
|
|
179
|
+
checked={!!selection}
|
|
180
|
+
onChange={() =>
|
|
181
|
+
onToggleField(
|
|
182
|
+
entry.path
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
/>
|
|
186
|
+
<span>{entry.label}</span>
|
|
187
|
+
<span
|
|
188
|
+
className={
|
|
189
|
+
styles.fieldTypeTag
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
{TYPE_LABELS[
|
|
193
|
+
entry.type
|
|
194
|
+
] || entry.type}
|
|
195
|
+
</span>
|
|
196
|
+
</label>
|
|
197
|
+
|
|
198
|
+
{selection &&
|
|
199
|
+
aggregates.length > 0 && (
|
|
200
|
+
<span
|
|
201
|
+
className={
|
|
202
|
+
styles.aggPicker
|
|
203
|
+
}
|
|
204
|
+
>
|
|
205
|
+
<span>
|
|
206
|
+
Show as:
|
|
207
|
+
</span>
|
|
208
|
+
<select
|
|
209
|
+
className={
|
|
210
|
+
styles.select
|
|
211
|
+
}
|
|
212
|
+
aria-label={`Show ${entry.label} as`}
|
|
213
|
+
value={
|
|
214
|
+
selection.agg ||
|
|
215
|
+
''
|
|
216
|
+
}
|
|
217
|
+
onChange={(
|
|
218
|
+
event
|
|
219
|
+
) =>
|
|
220
|
+
onChangeAggregate(
|
|
221
|
+
entry.path,
|
|
222
|
+
event
|
|
223
|
+
.target
|
|
224
|
+
.value
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
>
|
|
228
|
+
<option value="">
|
|
229
|
+
Value
|
|
230
|
+
</option>
|
|
231
|
+
{aggregates.map(
|
|
232
|
+
(agg) => (
|
|
233
|
+
<option
|
|
234
|
+
key={
|
|
235
|
+
agg
|
|
236
|
+
}
|
|
237
|
+
value={
|
|
238
|
+
agg
|
|
239
|
+
}
|
|
240
|
+
>
|
|
241
|
+
{aggregateOptionLabel(
|
|
242
|
+
agg,
|
|
243
|
+
entry.field
|
|
244
|
+
)}
|
|
245
|
+
</option>
|
|
246
|
+
)
|
|
247
|
+
)}
|
|
248
|
+
</select>
|
|
249
|
+
</span>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
})}
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
|
|
260
|
+
{(selections || []).length > 0 && (
|
|
261
|
+
<div className={styles.section}>
|
|
262
|
+
<div className={styles.sectionIntro}>
|
|
263
|
+
<h3>Columns in your report</h3>
|
|
264
|
+
<p>
|
|
265
|
+
These appear left to right in the order shown below.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
<div className={styles.selectedList}>
|
|
269
|
+
{selections.map((selection, index) => (
|
|
270
|
+
<div
|
|
271
|
+
key={`${selection.path}_${selection.agg || 'value'}`}
|
|
272
|
+
className={styles.selectedItem}
|
|
273
|
+
>
|
|
274
|
+
<span className={styles.selectedLabel}>
|
|
275
|
+
{selection.label || selection.path}
|
|
276
|
+
</span>
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
className={styles.linkButton}
|
|
280
|
+
disabled={index === 0}
|
|
281
|
+
onClick={() =>
|
|
282
|
+
onMoveSelection(index, index - 1)
|
|
283
|
+
}
|
|
284
|
+
>
|
|
285
|
+
Up
|
|
286
|
+
</button>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className={styles.linkButton}
|
|
290
|
+
disabled={index === selections.length - 1}
|
|
291
|
+
onClick={() =>
|
|
292
|
+
onMoveSelection(index, index + 1)
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
Down
|
|
296
|
+
</button>
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
className={styles.removeButton}
|
|
300
|
+
aria-label={`Remove ${selection.label}`}
|
|
301
|
+
onClick={() =>
|
|
302
|
+
onRemoveSelection(
|
|
303
|
+
selection.path,
|
|
304
|
+
selection.agg
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
>
|
|
308
|
+
<X size={14} />
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
))}
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
SemanticFieldsStep.propTypes = {
|
|
320
|
+
model: PropTypes.object,
|
|
321
|
+
entityId: PropTypes.string,
|
|
322
|
+
addedRelations: PropTypes.arrayOf(PropTypes.string),
|
|
323
|
+
selections: PropTypes.array,
|
|
324
|
+
onToggleField: PropTypes.func.isRequired,
|
|
325
|
+
onChangeAggregate: PropTypes.func.isRequired,
|
|
326
|
+
onRemoveSelection: PropTypes.func.isRequired,
|
|
327
|
+
onMoveSelection: PropTypes.func.isRequired,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
SemanticFieldsStep.defaultProps = {
|
|
331
|
+
model: null,
|
|
332
|
+
entityId: '',
|
|
333
|
+
addedRelations: [],
|
|
334
|
+
selections: [],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
export default SemanticFieldsStep;
|