@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,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 &ldquo;Show as&rdquo; 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;