@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,483 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import {
|
|
5
|
+
operatorArity,
|
|
6
|
+
operatorLabel,
|
|
7
|
+
operatorsForType,
|
|
8
|
+
parameterTypeFor,
|
|
9
|
+
resolveFieldPath,
|
|
10
|
+
suggestParameterId,
|
|
11
|
+
} from '../reportSemantics';
|
|
12
|
+
import { buildFieldSources } from './SemanticFieldsStep';
|
|
13
|
+
import SemanticValueInput, {
|
|
14
|
+
SemanticValueListInput,
|
|
15
|
+
} from './SemanticValueInput';
|
|
16
|
+
import styles from '../../styles/ReportSemantic.module.scss';
|
|
17
|
+
|
|
18
|
+
/** Flat, grouped list of every field the user may filter on. */
|
|
19
|
+
export const buildFilterFieldOptions = (model, entityId, addedRelations) =>
|
|
20
|
+
buildFieldSources(model, entityId, addedRelations).map((source) => ({
|
|
21
|
+
label: source.label,
|
|
22
|
+
options: source.fields.map((entry) => ({
|
|
23
|
+
value: entry.path,
|
|
24
|
+
label: entry.label,
|
|
25
|
+
})),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const SemanticFilterCondition = ({
|
|
29
|
+
model,
|
|
30
|
+
entityId,
|
|
31
|
+
condition,
|
|
32
|
+
fieldGroups,
|
|
33
|
+
onChange,
|
|
34
|
+
onRemove,
|
|
35
|
+
}) => {
|
|
36
|
+
const resolved = resolveFieldPath(model, entityId, condition.field);
|
|
37
|
+
const type = resolved?.type || 'text';
|
|
38
|
+
const operators = operatorsForType(type);
|
|
39
|
+
const arity = operatorArity(condition.operator);
|
|
40
|
+
|
|
41
|
+
const handleFieldChange = (path) => {
|
|
42
|
+
const nextResolved = resolveFieldPath(model, entityId, path);
|
|
43
|
+
const nextOperators = operatorsForType(nextResolved?.type || 'text');
|
|
44
|
+
// Keep the operator only when it is still valid for the new type,
|
|
45
|
+
// otherwise fall back to the first one the type offers.
|
|
46
|
+
const operator = nextOperators.includes(condition.operator)
|
|
47
|
+
? condition.operator
|
|
48
|
+
: nextOperators[0];
|
|
49
|
+
onChange({
|
|
50
|
+
...condition,
|
|
51
|
+
field: path,
|
|
52
|
+
operator,
|
|
53
|
+
value: '',
|
|
54
|
+
values: [],
|
|
55
|
+
param: condition.askEachTime
|
|
56
|
+
? suggestParameterId(path, operator)
|
|
57
|
+
: '',
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const handleOperatorChange = (operator) => {
|
|
62
|
+
onChange({
|
|
63
|
+
...condition,
|
|
64
|
+
operator,
|
|
65
|
+
value: '',
|
|
66
|
+
values: [],
|
|
67
|
+
param: condition.askEachTime
|
|
68
|
+
? suggestParameterId(condition.field, operator)
|
|
69
|
+
: '',
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleAskEachTime = (askEachTime) => {
|
|
74
|
+
onChange({
|
|
75
|
+
...condition,
|
|
76
|
+
askEachTime,
|
|
77
|
+
param: askEachTime
|
|
78
|
+
? condition.param ||
|
|
79
|
+
suggestParameterId(condition.field, condition.operator)
|
|
80
|
+
: '',
|
|
81
|
+
value: '',
|
|
82
|
+
values: [],
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const renderValues = () => {
|
|
87
|
+
if (arity === 0) return null;
|
|
88
|
+
if (condition.askEachTime) {
|
|
89
|
+
return (
|
|
90
|
+
<span className={styles.cardMeta}>
|
|
91
|
+
You will be asked for this value each time the report runs (
|
|
92
|
+
{parameterTypeFor(type, condition.operator).replace(
|
|
93
|
+
/_/g,
|
|
94
|
+
' '
|
|
95
|
+
)}
|
|
96
|
+
).
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (arity === 'list') {
|
|
102
|
+
return (
|
|
103
|
+
<SemanticValueListInput
|
|
104
|
+
type={type}
|
|
105
|
+
field={resolved?.field}
|
|
106
|
+
values={condition.values}
|
|
107
|
+
onChange={(values) => onChange({ ...condition, values })}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (arity === 2) {
|
|
113
|
+
return (
|
|
114
|
+
<span className={styles.conditionValues}>
|
|
115
|
+
<SemanticValueInput
|
|
116
|
+
type={type}
|
|
117
|
+
field={resolved?.field}
|
|
118
|
+
value={condition.values?.[0] ?? ''}
|
|
119
|
+
ariaLabel="From"
|
|
120
|
+
onChange={(value) =>
|
|
121
|
+
onChange({
|
|
122
|
+
...condition,
|
|
123
|
+
values: [value, condition.values?.[1] ?? ''],
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
/>
|
|
127
|
+
<span>and</span>
|
|
128
|
+
<SemanticValueInput
|
|
129
|
+
type={type}
|
|
130
|
+
field={resolved?.field}
|
|
131
|
+
value={condition.values?.[1] ?? ''}
|
|
132
|
+
ariaLabel="To"
|
|
133
|
+
onChange={(value) =>
|
|
134
|
+
onChange({
|
|
135
|
+
...condition,
|
|
136
|
+
values: [condition.values?.[0] ?? '', value],
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
/>
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<SemanticValueInput
|
|
146
|
+
type={type}
|
|
147
|
+
field={resolved?.field}
|
|
148
|
+
value={condition.value}
|
|
149
|
+
ariaLabel="Value"
|
|
150
|
+
onChange={(value) => onChange({ ...condition, value })}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div className={styles.condition}>
|
|
157
|
+
<div className={styles.conditionRow}>
|
|
158
|
+
<select
|
|
159
|
+
className={styles.select}
|
|
160
|
+
aria-label="Field to filter on"
|
|
161
|
+
value={condition.field}
|
|
162
|
+
onChange={(event) => handleFieldChange(event.target.value)}
|
|
163
|
+
>
|
|
164
|
+
<option value="">Choose what to filter on…</option>
|
|
165
|
+
{fieldGroups.map((group) => (
|
|
166
|
+
<optgroup key={group.label} label={group.label}>
|
|
167
|
+
{group.options.map((option) => (
|
|
168
|
+
<option key={option.value} value={option.value}>
|
|
169
|
+
{option.label}
|
|
170
|
+
</option>
|
|
171
|
+
))}
|
|
172
|
+
</optgroup>
|
|
173
|
+
))}
|
|
174
|
+
</select>
|
|
175
|
+
|
|
176
|
+
<select
|
|
177
|
+
className={styles.select}
|
|
178
|
+
aria-label="Condition"
|
|
179
|
+
value={condition.operator}
|
|
180
|
+
disabled={!condition.field}
|
|
181
|
+
onChange={(event) =>
|
|
182
|
+
handleOperatorChange(event.target.value)
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
<option value="">Choose a condition…</option>
|
|
186
|
+
{operators.map((operator) => (
|
|
187
|
+
<option key={operator} value={operator}>
|
|
188
|
+
{operatorLabel(operator)}
|
|
189
|
+
</option>
|
|
190
|
+
))}
|
|
191
|
+
</select>
|
|
192
|
+
|
|
193
|
+
{condition.operator && (
|
|
194
|
+
<span className={styles.conditionValues}>
|
|
195
|
+
{renderValues()}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
className={styles.removeButton}
|
|
202
|
+
aria-label="Remove this condition"
|
|
203
|
+
onClick={onRemove}
|
|
204
|
+
>
|
|
205
|
+
<X size={14} />
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{condition.field && condition.operator && arity !== 0 && (
|
|
210
|
+
<label className={styles.askEachTime}>
|
|
211
|
+
<input
|
|
212
|
+
type="checkbox"
|
|
213
|
+
checked={!!condition.askEachTime}
|
|
214
|
+
onChange={(event) =>
|
|
215
|
+
handleAskEachTime(event.target.checked)
|
|
216
|
+
}
|
|
217
|
+
/>
|
|
218
|
+
<span>Ask me for this each time I run it</span>
|
|
219
|
+
</label>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
SemanticFilterCondition.propTypes = {
|
|
226
|
+
model: PropTypes.object,
|
|
227
|
+
entityId: PropTypes.string,
|
|
228
|
+
condition: PropTypes.object.isRequired,
|
|
229
|
+
fieldGroups: PropTypes.array.isRequired,
|
|
230
|
+
onChange: PropTypes.func.isRequired,
|
|
231
|
+
onRemove: PropTypes.func.isRequired,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
SemanticFilterCondition.defaultProps = {
|
|
235
|
+
model: null,
|
|
236
|
+
entityId: '',
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const SemanticFilterGroup = ({
|
|
240
|
+
model,
|
|
241
|
+
entityId,
|
|
242
|
+
group,
|
|
243
|
+
fieldGroups,
|
|
244
|
+
depth,
|
|
245
|
+
onChangeNode,
|
|
246
|
+
onRemoveNode,
|
|
247
|
+
onAddCondition,
|
|
248
|
+
onAddGroup,
|
|
249
|
+
}) => (
|
|
250
|
+
<div
|
|
251
|
+
className={`${styles.filterGroup} ${
|
|
252
|
+
depth > 0 ? styles.filterGroupNested : ''
|
|
253
|
+
}`}
|
|
254
|
+
>
|
|
255
|
+
<div className={styles.filterGroupHeader}>
|
|
256
|
+
<span className={styles.matchToggle}>
|
|
257
|
+
<span>Records must match</span>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
className={`${styles.toggleButton} ${
|
|
261
|
+
group.op !== 'or' ? styles.toggleActive : ''
|
|
262
|
+
}`}
|
|
263
|
+
aria-pressed={group.op !== 'or'}
|
|
264
|
+
onClick={() => onChangeNode(group._id, { op: 'and' })}
|
|
265
|
+
>
|
|
266
|
+
all of these
|
|
267
|
+
</button>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className={`${styles.toggleButton} ${
|
|
271
|
+
group.op === 'or' ? styles.toggleActive : ''
|
|
272
|
+
}`}
|
|
273
|
+
aria-pressed={group.op === 'or'}
|
|
274
|
+
onClick={() => onChangeNode(group._id, { op: 'or' })}
|
|
275
|
+
>
|
|
276
|
+
any of these
|
|
277
|
+
</button>
|
|
278
|
+
</span>
|
|
279
|
+
|
|
280
|
+
{depth > 0 && (
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
className={styles.removeButton}
|
|
284
|
+
aria-label="Remove this group"
|
|
285
|
+
onClick={() => onRemoveNode(group._id)}
|
|
286
|
+
>
|
|
287
|
+
<X size={16} />
|
|
288
|
+
</button>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
<div className={styles.filterItems}>
|
|
293
|
+
{(group.items || []).length === 0 && (
|
|
294
|
+
<span className={styles.cardMeta}>
|
|
295
|
+
No conditions yet — every record will be included.
|
|
296
|
+
</span>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{(group.items || []).map((item) =>
|
|
300
|
+
item.kind === 'group' ? (
|
|
301
|
+
<SemanticFilterGroup
|
|
302
|
+
key={item._id}
|
|
303
|
+
model={model}
|
|
304
|
+
entityId={entityId}
|
|
305
|
+
group={item}
|
|
306
|
+
fieldGroups={fieldGroups}
|
|
307
|
+
depth={depth + 1}
|
|
308
|
+
onChangeNode={onChangeNode}
|
|
309
|
+
onRemoveNode={onRemoveNode}
|
|
310
|
+
onAddCondition={onAddCondition}
|
|
311
|
+
onAddGroup={onAddGroup}
|
|
312
|
+
/>
|
|
313
|
+
) : (
|
|
314
|
+
<SemanticFilterCondition
|
|
315
|
+
key={item._id}
|
|
316
|
+
model={model}
|
|
317
|
+
entityId={entityId}
|
|
318
|
+
condition={item}
|
|
319
|
+
fieldGroups={fieldGroups}
|
|
320
|
+
onChange={(next) => onChangeNode(item._id, next)}
|
|
321
|
+
onRemove={() => onRemoveNode(item._id)}
|
|
322
|
+
/>
|
|
323
|
+
)
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<div className={styles.filterActions}>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
className={styles.linkButton}
|
|
331
|
+
onClick={() => onAddCondition(group._id)}
|
|
332
|
+
>
|
|
333
|
+
+ Add a condition
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
className={styles.linkButton}
|
|
338
|
+
onClick={() => onAddGroup(group._id)}
|
|
339
|
+
>
|
|
340
|
+
+ Add a nested group
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
SemanticFilterGroup.propTypes = {
|
|
347
|
+
model: PropTypes.object,
|
|
348
|
+
entityId: PropTypes.string,
|
|
349
|
+
group: PropTypes.object.isRequired,
|
|
350
|
+
fieldGroups: PropTypes.array.isRequired,
|
|
351
|
+
depth: PropTypes.number,
|
|
352
|
+
onChangeNode: PropTypes.func.isRequired,
|
|
353
|
+
onRemoveNode: PropTypes.func.isRequired,
|
|
354
|
+
onAddCondition: PropTypes.func.isRequired,
|
|
355
|
+
onAddGroup: PropTypes.func.isRequired,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
SemanticFilterGroup.defaultProps = {
|
|
359
|
+
model: null,
|
|
360
|
+
entityId: '',
|
|
361
|
+
depth: 0,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Step 4 in semantic mode: build the filter tree.
|
|
366
|
+
*
|
|
367
|
+
* The AND/OR grouping the user builds here is preserved verbatim in the saved
|
|
368
|
+
* definition — nothing is flattened. Conditions marked "ask me each time"
|
|
369
|
+
* become runtime parameters, whose labels can be edited below the tree.
|
|
370
|
+
*/
|
|
371
|
+
const SemanticFiltersStep = ({
|
|
372
|
+
model,
|
|
373
|
+
entityId,
|
|
374
|
+
addedRelations,
|
|
375
|
+
filterTree,
|
|
376
|
+
parameters,
|
|
377
|
+
onChangeNode,
|
|
378
|
+
onRemoveNode,
|
|
379
|
+
onAddCondition,
|
|
380
|
+
onAddGroup,
|
|
381
|
+
onChangeParameter,
|
|
382
|
+
}) => {
|
|
383
|
+
const fieldGroups = useMemo(
|
|
384
|
+
() => buildFilterFieldOptions(model, entityId, addedRelations),
|
|
385
|
+
[model, entityId, addedRelations]
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div className={styles.section}>
|
|
390
|
+
<div className={styles.sectionIntro}>
|
|
391
|
+
<h3>Narrow down the records</h3>
|
|
392
|
+
<p>
|
|
393
|
+
Add conditions to include only the records you care about.
|
|
394
|
+
This step is optional.
|
|
395
|
+
</p>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
<SemanticFilterGroup
|
|
399
|
+
model={model}
|
|
400
|
+
entityId={entityId}
|
|
401
|
+
group={filterTree}
|
|
402
|
+
fieldGroups={fieldGroups}
|
|
403
|
+
depth={0}
|
|
404
|
+
onChangeNode={onChangeNode}
|
|
405
|
+
onRemoveNode={onRemoveNode}
|
|
406
|
+
onAddCondition={onAddCondition}
|
|
407
|
+
onAddGroup={onAddGroup}
|
|
408
|
+
/>
|
|
409
|
+
|
|
410
|
+
{(parameters || []).length > 0 && (
|
|
411
|
+
<div className={styles.section}>
|
|
412
|
+
<div className={styles.sectionIntro}>
|
|
413
|
+
<h3>Questions asked at run time</h3>
|
|
414
|
+
<p>
|
|
415
|
+
Give each question a clear name — this is what
|
|
416
|
+
people will see when they run the report.
|
|
417
|
+
</p>
|
|
418
|
+
</div>
|
|
419
|
+
<div className={styles.parameterList}>
|
|
420
|
+
{parameters.map((parameter) => (
|
|
421
|
+
<div
|
|
422
|
+
key={parameter.id}
|
|
423
|
+
className={styles.parameterRow}
|
|
424
|
+
>
|
|
425
|
+
<span className={styles.parameterLabel}>
|
|
426
|
+
Question
|
|
427
|
+
</span>
|
|
428
|
+
<input
|
|
429
|
+
type="text"
|
|
430
|
+
className={styles.input}
|
|
431
|
+
aria-label={`Label for ${parameter.id}`}
|
|
432
|
+
value={parameter.label}
|
|
433
|
+
onChange={(event) =>
|
|
434
|
+
onChangeParameter(parameter.id, {
|
|
435
|
+
label: event.target.value,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
/>
|
|
439
|
+
<label className={styles.askEachTime}>
|
|
440
|
+
<input
|
|
441
|
+
type="checkbox"
|
|
442
|
+
checked={parameter.required !== false}
|
|
443
|
+
onChange={(event) =>
|
|
444
|
+
onChangeParameter(parameter.id, {
|
|
445
|
+
required: event.target.checked,
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
/>
|
|
449
|
+
<span>Must be answered</span>
|
|
450
|
+
</label>
|
|
451
|
+
<span className={styles.cardMeta}>
|
|
452
|
+
{String(parameter.type).replace(/_/g, ' ')}
|
|
453
|
+
</span>
|
|
454
|
+
</div>
|
|
455
|
+
))}
|
|
456
|
+
</div>
|
|
457
|
+
</div>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
SemanticFiltersStep.propTypes = {
|
|
464
|
+
model: PropTypes.object,
|
|
465
|
+
entityId: PropTypes.string,
|
|
466
|
+
addedRelations: PropTypes.arrayOf(PropTypes.string),
|
|
467
|
+
filterTree: PropTypes.object.isRequired,
|
|
468
|
+
parameters: PropTypes.array,
|
|
469
|
+
onChangeNode: PropTypes.func.isRequired,
|
|
470
|
+
onRemoveNode: PropTypes.func.isRequired,
|
|
471
|
+
onAddCondition: PropTypes.func.isRequired,
|
|
472
|
+
onAddGroup: PropTypes.func.isRequired,
|
|
473
|
+
onChangeParameter: PropTypes.func.isRequired,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
SemanticFiltersStep.defaultProps = {
|
|
477
|
+
model: null,
|
|
478
|
+
entityId: '',
|
|
479
|
+
addedRelations: [],
|
|
480
|
+
parameters: [],
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
export default SemanticFiltersStep;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import { selectionHeader } from '../reportSemantics';
|
|
5
|
+
import styles from '../../styles/ReportSemantic.module.scss';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Step 5 in semantic mode: grouping and ordering.
|
|
9
|
+
*
|
|
10
|
+
* Grouping can only use fields the user already chose to show — grouping by
|
|
11
|
+
* something invisible produces a report nobody can read. Aggregate columns are
|
|
12
|
+
* excluded for the same reason: they are the summary, not the grouping key.
|
|
13
|
+
*/
|
|
14
|
+
const SemanticGroupingStep = ({
|
|
15
|
+
model,
|
|
16
|
+
entityId,
|
|
17
|
+
selections,
|
|
18
|
+
groupBy,
|
|
19
|
+
sort,
|
|
20
|
+
onToggleGroupBy,
|
|
21
|
+
onAddSort,
|
|
22
|
+
onChangeSort,
|
|
23
|
+
onRemoveSort,
|
|
24
|
+
}) => {
|
|
25
|
+
const groupable = (selections || []).filter((selection) => !selection.agg);
|
|
26
|
+
const sortable = selections || [];
|
|
27
|
+
const usedSortFields = (sort || []).map((entry) => entry.field);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={styles.section}>
|
|
31
|
+
<div className={styles.sectionIntro}>
|
|
32
|
+
<h3>Group and order the results</h3>
|
|
33
|
+
<p>
|
|
34
|
+
Grouping splits the report into sections. Ordering controls
|
|
35
|
+
the order of the rows. Both are optional.
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
{groupable.length === 0 ? (
|
|
40
|
+
<div className={styles.empty}>
|
|
41
|
+
Choose at least one plain column at the previous step to
|
|
42
|
+
group by it.
|
|
43
|
+
</div>
|
|
44
|
+
) : (
|
|
45
|
+
<div className={styles.section}>
|
|
46
|
+
<div className={styles.sectionIntro}>
|
|
47
|
+
<h3>Group into sections by</h3>
|
|
48
|
+
</div>
|
|
49
|
+
<div className={styles.selectedList}>
|
|
50
|
+
{groupable.map((selection) => (
|
|
51
|
+
<label
|
|
52
|
+
key={selection.path}
|
|
53
|
+
className={styles.selectedItem}
|
|
54
|
+
>
|
|
55
|
+
<input
|
|
56
|
+
type="checkbox"
|
|
57
|
+
checked={(groupBy || []).includes(
|
|
58
|
+
selection.path
|
|
59
|
+
)}
|
|
60
|
+
onChange={() =>
|
|
61
|
+
onToggleGroupBy(selection.path)
|
|
62
|
+
}
|
|
63
|
+
/>
|
|
64
|
+
<span className={styles.selectedLabel}>
|
|
65
|
+
{selectionHeader(
|
|
66
|
+
model,
|
|
67
|
+
entityId,
|
|
68
|
+
selection
|
|
69
|
+
)}
|
|
70
|
+
</span>
|
|
71
|
+
</label>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<div className={styles.section}>
|
|
78
|
+
<div className={styles.sectionIntro}>
|
|
79
|
+
<h3>Sort rows by</h3>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{(sort || []).length === 0 && (
|
|
83
|
+
<span className={styles.cardMeta}>
|
|
84
|
+
No sorting applied — the server decides the order.
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<div className={styles.parameterList}>
|
|
89
|
+
{(sort || []).map((entry, index) => (
|
|
90
|
+
<div
|
|
91
|
+
key={`${entry.field}_${index}`}
|
|
92
|
+
className={styles.parameterRow}
|
|
93
|
+
>
|
|
94
|
+
<select
|
|
95
|
+
className={styles.select}
|
|
96
|
+
aria-label="Sort field"
|
|
97
|
+
value={entry.field}
|
|
98
|
+
onChange={(event) =>
|
|
99
|
+
onChangeSort(index, {
|
|
100
|
+
field: event.target.value,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
>
|
|
104
|
+
{sortable.map((selection) => (
|
|
105
|
+
<option
|
|
106
|
+
key={`${selection.path}_${
|
|
107
|
+
selection.agg || 'value'
|
|
108
|
+
}`}
|
|
109
|
+
value={
|
|
110
|
+
selection.agg
|
|
111
|
+
? selection.label
|
|
112
|
+
: selection.path
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{selectionHeader(
|
|
116
|
+
model,
|
|
117
|
+
entityId,
|
|
118
|
+
selection
|
|
119
|
+
)}
|
|
120
|
+
</option>
|
|
121
|
+
))}
|
|
122
|
+
</select>
|
|
123
|
+
|
|
124
|
+
<select
|
|
125
|
+
className={styles.select}
|
|
126
|
+
aria-label="Sort direction"
|
|
127
|
+
value={entry.dir}
|
|
128
|
+
onChange={(event) =>
|
|
129
|
+
onChangeSort(index, {
|
|
130
|
+
dir: event.target.value,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
>
|
|
134
|
+
<option value="asc">A to Z / low to high</option>
|
|
135
|
+
<option value="desc">
|
|
136
|
+
Z to A / high to low
|
|
137
|
+
</option>
|
|
138
|
+
</select>
|
|
139
|
+
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
className={styles.removeButton}
|
|
143
|
+
aria-label="Remove this sort"
|
|
144
|
+
onClick={() => onRemoveSort(index)}
|
|
145
|
+
>
|
|
146
|
+
<X size={14} />
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className={styles.filterActions}>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
className={styles.linkButton}
|
|
156
|
+
disabled={sortable.length === 0}
|
|
157
|
+
onClick={() => {
|
|
158
|
+
const next = sortable.find(
|
|
159
|
+
(selection) =>
|
|
160
|
+
!usedSortFields.includes(
|
|
161
|
+
selection.agg
|
|
162
|
+
? selection.label
|
|
163
|
+
: selection.path
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
if (next) {
|
|
167
|
+
onAddSort(
|
|
168
|
+
next.agg ? next.label : next.path,
|
|
169
|
+
'asc'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
+ Add a sort
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
SemanticGroupingStep.propTypes = {
|
|
183
|
+
model: PropTypes.object,
|
|
184
|
+
entityId: PropTypes.string,
|
|
185
|
+
selections: PropTypes.array,
|
|
186
|
+
groupBy: PropTypes.arrayOf(PropTypes.string),
|
|
187
|
+
sort: PropTypes.array,
|
|
188
|
+
onToggleGroupBy: PropTypes.func.isRequired,
|
|
189
|
+
onAddSort: PropTypes.func.isRequired,
|
|
190
|
+
onChangeSort: PropTypes.func.isRequired,
|
|
191
|
+
onRemoveSort: PropTypes.func.isRequired,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
SemanticGroupingStep.defaultProps = {
|
|
195
|
+
model: null,
|
|
196
|
+
entityId: '',
|
|
197
|
+
selections: [],
|
|
198
|
+
groupBy: [],
|
|
199
|
+
sort: [],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export default SemanticGroupingStep;
|