@visns-studio/visns-components 6.1.8 → 6.2.1

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,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;