@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.
@@ -1,13 +1,118 @@
1
1
  import React, { useState } from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { ChevronDown, ChevronRight, MessageSquare } from 'lucide-react';
4
- import { formatCellValue, getNestedValue } from './shared/groupingUtils';
3
+ import { ChevronDown, ChevronRight } from 'lucide-react';
4
+ import { formatCellValue } from './shared/groupingUtils';
5
5
  import debugLog from '../utils/debugLog';
6
6
  import './groupedReport.css';
7
7
 
8
+ // Resolve a dotted path against nested objects, e.g. 'adviser.name'.
9
+ const getNestedProperty = (obj, path) => {
10
+ return path.split('.').reduce((current, key) => {
11
+ return current && current[key] !== undefined ? current[key] : undefined;
12
+ }, obj);
13
+ };
14
+
15
+ // The same logical column can arrive under a handful of key shapes depending
16
+ // on which backend produced the rows, so exact-key candidates are tried in
17
+ // priority order. `column.key` is the explicit opt-out: when the caller knows
18
+ // the row key it wins over every heuristic below it.
19
+ const CANDIDATE_KEY_BUILDERS = [
20
+ (column) => column.key,
21
+ (column) => column.displayName,
22
+ (column) => (column.displayName ? `\`${column.displayName}\`` : null),
23
+ (column) =>
24
+ column.table && column.column
25
+ ? `${column.table}_${column.column}`
26
+ : null,
27
+ (column) =>
28
+ column.table && column.column
29
+ ? `\`${column.table}_${column.column}\``
30
+ : null,
31
+ (column) => column.column,
32
+ (column) => (column.column ? `\`${column.column}\`` : null),
33
+ (column) => column.id,
34
+ ];
35
+
36
+ // Dotted paths are walked into nested objects rather than looked up literally.
37
+ const buildNestedPatterns = (column) => {
38
+ const patterns = [];
39
+
40
+ if (column.table && column.column) {
41
+ patterns.push(
42
+ `${column.table}.${column.column}`,
43
+ `${column.table}.${column.column}.name`,
44
+ `${column.table}.${column.column}.label`
45
+ );
46
+ }
47
+
48
+ if (typeof column.column === 'string' && column.column.includes('.')) {
49
+ patterns.push(column.column);
50
+ }
51
+
52
+ return patterns;
53
+ };
54
+
55
+ const resolveColumnValue = (row, column) => {
56
+ for (const buildKey of CANDIDATE_KEY_BUILDERS) {
57
+ const key = buildKey(column);
58
+ if (key && row[key] !== undefined) {
59
+ return row[key];
60
+ }
61
+ }
62
+
63
+ const nestedPatterns = buildNestedPatterns(column);
64
+ for (const pattern of nestedPatterns) {
65
+ const nestedValue = getNestedProperty(row, pattern);
66
+ if (nestedValue !== undefined) {
67
+ return nestedValue;
68
+ }
69
+ }
70
+
71
+ // Last resort: any row key that case-insensitively mentions the column.
72
+ const needles = [column.column, column.displayName, column.header]
73
+ .filter((name) => typeof name === 'string' && name !== '')
74
+ .map((name) => name.toLowerCase());
75
+ const matchedKey = Object.keys(row).find((key) =>
76
+ needles.some((needle) => key.toLowerCase().includes(needle))
77
+ );
78
+ if (matchedKey) {
79
+ return row[matchedKey];
80
+ }
81
+
82
+ if (Object.keys(row).length > 0) {
83
+ debugLog('Grouped report: no value found for column', {
84
+ column: column.header || column.displayName || column.column,
85
+ availableKeys: Object.keys(row),
86
+ testedPatterns: nestedPatterns,
87
+ });
88
+ }
89
+
90
+ return undefined;
91
+ };
92
+
93
+ const getRowClassName = (row, config) => {
94
+ if (row.__empty_row__) {
95
+ return `empty-row empty-row--${config.emptyRowStyle || 'light'}`;
96
+ }
97
+ return 'data-row';
98
+ };
99
+
100
+ // Columns may carry no table/column at all (semantic mode supplies key/header
101
+ // only), so React keys fall back through whatever identifies the column.
102
+ const getColumnKey = (col, colIndex) =>
103
+ col.key || col.displayName || col.column || colIndex;
104
+
105
+ const renderCellValue = (row, column) => {
106
+ if (row.__empty_row__) {
107
+ return '';
108
+ }
109
+
110
+ return formatCellValue(resolveColumnValue(row, column), column);
111
+ };
112
+
8
113
  const SectionGroupedReport = ({ data, config, columns, onExport }) => {
9
114
  const [collapsedGroups, setCollapsedGroups] = useState(new Set());
10
-
115
+
11
116
  const toggleGroupCollapse = (groupName) => {
12
117
  const newCollapsed = new Set(collapsedGroups);
13
118
  if (newCollapsed.has(groupName)) {
@@ -17,178 +122,10 @@ const SectionGroupedReport = ({ data, config, columns, onExport }) => {
17
122
  }
18
123
  setCollapsedGroups(newCollapsed);
19
124
  };
20
- // Helper function to get nested object property
21
- const getNestedProperty = (obj, path) => {
22
- return path.split('.').reduce((current, key) => {
23
- return current && current[key] !== undefined ? current[key] : undefined;
24
- }, obj);
25
- };
26
-
27
- const renderCellValue = (row, column) => {
28
- if (row.__empty_row__) {
29
- return '';
30
- }
31
-
32
- // Try multiple field access patterns to find the data
33
- let value;
34
-
35
- // First try displayName with backticks (as seen in debug)
36
- if (column.displayName && row[`\`${column.displayName}\``] !== undefined) {
37
- value = row[`\`${column.displayName}\``];
38
- }
39
- // Then try displayName without backticks
40
- else if (column.displayName && row[column.displayName] !== undefined) {
41
- value = row[column.displayName];
42
- }
43
- // Then try table_column format
44
- else if (column.table && row[`${column.table}_${column.column}`] !== undefined) {
45
- value = row[`${column.table}_${column.column}`];
46
- }
47
- // Try column with table prefix and backticks
48
- else if (column.table && row[`\`${column.table}_${column.column}\``] !== undefined) {
49
- value = row[`\`${column.table}_${column.column}\``];
50
- }
51
- // Then try just column name
52
- else if (row[column.column] !== undefined) {
53
- value = row[column.column];
54
- }
55
- // Try column with backticks
56
- else if (row[`\`${column.column}\``] !== undefined) {
57
- value = row[`\`${column.column}\``];
58
- }
59
- // Handle calculated fields
60
- else if (column.id && row[column.id] !== undefined) {
61
- value = row[column.id];
62
- }
63
- // Try nested property access based on common patterns
64
- let nestedPatterns = [];
65
- if (column.table && column.column) {
66
- // Try patterns like lead.client.name, users.name, etc.
67
- nestedPatterns = [
68
- `${column.table}.${column.column}`,
69
- `${column.table}.${column.column}.name`,
70
- `${column.table}.${column.column}.label`,
71
- column.displayName === 'User' ? 'lead.client.name' : null,
72
- column.displayName === 'Project' ? 'lead.project_name' : null,
73
- column.displayName === 'Start Date' ? 'lead.start_date' : null,
74
- column.displayName === 'End Date' ? 'lead.end_date' : null,
75
- column.displayName === 'Database Updated' ? 'lead.updated_at' : null,
76
- column.displayName === 'Comments' ? 'lead.comments' : null,
77
- // Try alternative field patterns
78
- column.column === 'name' && column.table === 'users' ? 'lead.client.name' : null,
79
- column.column === 'updated_at' && column.table === 'leads' ? 'lead.updated_at' : null
80
- ].filter(Boolean);
81
-
82
- for (const pattern of nestedPatterns) {
83
- const nestedValue = getNestedProperty(row, pattern);
84
- if (nestedValue !== undefined) {
85
- value = nestedValue;
86
- break;
87
- }
88
- }
89
- }
90
-
91
- // Special handling for Comments field (appears in multiple formats)
92
- if (value === undefined && column.displayName === 'Comments') {
93
- value = row['Comments'] || row['comments'] || row['latest_comment'] || '';
94
- }
95
-
96
- // Format Comments field with enhanced styling
97
- if (column.displayName === 'Comments' && value) {
98
- const comments = value.split('• ').filter(comment => comment.trim());
99
-
100
- return (
101
- <div style={{
102
- display: 'flex',
103
- alignItems: 'flex-start',
104
- gap: '8px',
105
- maxWidth: '300px'
106
- }}>
107
- <MessageSquare
108
- size={16}
109
- style={{
110
- marginTop: '4px',
111
- color: '#3b82f6',
112
- flexShrink: 0
113
- }}
114
- />
115
- <div style={{
116
- flex: 1,
117
- fontSize: '13px',
118
- lineHeight: '1.5'
119
- }}>
120
- {comments.length > 1 ? (
121
- <div>
122
- {comments.map((comment, index) => (
123
- <div
124
- key={index}
125
- style={{
126
- marginBottom: index < comments.length - 1 ? '8px' : '0',
127
- padding: '6px 10px',
128
- backgroundColor: '#f8fafc',
129
- border: '1px solid #e2e8f0',
130
- borderRadius: '6px',
131
- fontSize: '12px',
132
- color: '#475569'
133
- }}
134
- >
135
- {comment.trim()}
136
- </div>
137
- ))}
138
- </div>
139
- ) : (
140
- <div style={{
141
- padding: '6px 10px',
142
- backgroundColor: '#f8fafc',
143
- border: '1px solid #e2e8f0',
144
- borderRadius: '6px',
145
- fontSize: '12px',
146
- color: '#475569'
147
- }}>
148
- {value}
149
- </div>
150
- )}
151
- </div>
152
- </div>
153
- );
154
- }
155
-
156
- // Try to find any key that contains the column name
157
- if (value === undefined) {
158
- const possibleKeys = Object.keys(row).filter(key =>
159
- key.toLowerCase().includes(column.column?.toLowerCase()) ||
160
- key.toLowerCase().includes(column.displayName?.toLowerCase())
161
- );
162
- if (possibleKeys.length > 0) {
163
- value = row[possibleKeys[0]];
164
- }
165
- }
166
-
167
- if (
168
- value === undefined &&
169
- !row.__empty_row__ &&
170
- Object.keys(row).length > 0
171
- ) {
172
- debugLog('Grouped report: no value found for column', {
173
- column: column.displayName || column.column,
174
- availableKeys: Object.keys(row),
175
- testedPatterns: nestedPatterns,
176
- });
177
- }
178
125
 
179
- return formatCellValue(value, column);
180
- };
181
-
182
- const getRowClassName = (row, config) => {
183
- if (row.__empty_row__) {
184
- return `empty-row empty-row--${config.emptyRowStyle || 'light'}`;
185
- }
186
- return 'data-row';
187
- };
188
-
189
126
  const renderExportButton = () => {
190
127
  if (!onExport) return null;
191
-
128
+
192
129
  return (
193
130
  <div className="grouped-report-actions">
194
131
  <button
@@ -202,18 +139,19 @@ const SectionGroupedReport = ({ data, config, columns, onExport }) => {
202
139
  </div>
203
140
  );
204
141
  };
205
-
142
+
206
143
  return (
207
144
  <div className="section-grouped-report">
208
145
  <div className="grouped-report-header">
209
146
  <div className="grouped-report-summary">
210
147
  <span className="summary-text">
211
- {data.totalRecords} records across {data.totalGroups} {config.groupDisplayName?.toLowerCase() || 'group'}(s)
148
+ {data.totalRecords} records across {data.totalGroups}{' '}
149
+ {config.groupDisplayName?.toLowerCase() || 'group'}(s)
212
150
  </span>
213
151
  </div>
214
152
  {renderExportButton()}
215
153
  </div>
216
-
154
+
217
155
  <div className="grouped-report-content">
218
156
  {(data.groups || []).map((group, groupIndex) => (
219
157
  <GroupSection
@@ -224,7 +162,9 @@ const SectionGroupedReport = ({ data, config, columns, onExport }) => {
224
162
  index={groupIndex}
225
163
  renderCellValue={renderCellValue}
226
164
  isCollapsed={collapsedGroups.has(group.groupName)}
227
- onToggleCollapse={() => toggleGroupCollapse(group.groupName)}
165
+ onToggleCollapse={() =>
166
+ toggleGroupCollapse(group.groupName)
167
+ }
228
168
  />
229
169
  ))}
230
170
  </div>
@@ -232,19 +172,19 @@ const SectionGroupedReport = ({ data, config, columns, onExport }) => {
232
172
  );
233
173
  };
234
174
 
235
- const GroupSection = ({ group, config, columns, index, renderCellValue, isCollapsed, onToggleCollapse }) => {
175
+ const GroupSection = ({
176
+ group,
177
+ config,
178
+ columns,
179
+ renderCellValue,
180
+ isCollapsed,
181
+ onToggleCollapse,
182
+ }) => {
236
183
  const allRows = [...(group.rows || []), ...(group.emptyRows || [])];
237
184
  const headerStyleClass = `group-section--${
238
185
  config.groupHeaderStyle || 'primary'
239
186
  }`;
240
-
241
- const getRowClassName = (row, config) => {
242
- if (row.__empty_row__) {
243
- return `empty-row empty-row--${config.emptyRowStyle || 'light'}`;
244
- }
245
- return 'data-row';
246
- };
247
-
187
+
248
188
  return (
249
189
  <div className={`group-section ${headerStyleClass}`}>
250
190
  <button
@@ -273,62 +213,92 @@ const GroupSection = ({ group, config, columns, index, renderCellValue, isCollap
273
213
  )}
274
214
  </span>
275
215
  </button>
276
-
216
+
277
217
  {!isCollapsed && (
278
- <div className="group-content">
279
- <div className="table-container">
280
- <table className="group-table">
281
- <thead>
282
- <tr>
283
- {columns.map((col, colIndex) => (
284
- <th
285
- key={`${col.table}_${col.column}_${colIndex}`}
286
- className={col.required ? 'required-column' : ''}
287
- >
288
- {col.displayName}
289
- </th>
290
- ))}
291
- </tr>
292
- </thead>
293
- <tbody>
294
- {allRows.map((row, rowIndex) => (
295
- <tr
296
- key={row.id || `${group.groupName}_row_${rowIndex}`}
297
- className={getRowClassName(row, config)}
298
- >
299
- {columns.map((col, colIndex) => (
300
- <td
301
- key={`${col.table}_${col.column}_${rowIndex}_${colIndex}`}
302
- className={col.required ? 'required-column' : ''}
218
+ <div className="group-content">
219
+ <div className="table-container">
220
+ <table className="group-table">
221
+ <thead>
222
+ <tr>
223
+ {columns.map((col, colIndex) => (
224
+ <th
225
+ key={getColumnKey(col, colIndex)}
226
+ className={
227
+ col.required
228
+ ? 'required-column'
229
+ : ''
230
+ }
231
+ >
232
+ {col.header ||
233
+ col.displayName ||
234
+ col.column}
235
+ </th>
236
+ ))}
237
+ </tr>
238
+ </thead>
239
+ <tbody>
240
+ {allRows.map((row, rowIndex) => (
241
+ <tr
242
+ key={
243
+ row.id ||
244
+ `${group.groupName}_row_${rowIndex}`
245
+ }
246
+ className={getRowClassName(row, config)}
303
247
  >
304
- {renderCellValue(row, col)}
305
- </td>
248
+ {columns.map((col, colIndex) => (
249
+ <td
250
+ key={`${rowIndex}_${getColumnKey(
251
+ col,
252
+ colIndex
253
+ )}`}
254
+ className={
255
+ col.required
256
+ ? 'required-column'
257
+ : ''
258
+ }
259
+ >
260
+ {renderCellValue(row, col)}
261
+ </td>
262
+ ))}
263
+ </tr>
306
264
  ))}
307
- </tr>
308
- ))}
309
- </tbody>
310
- </table>
265
+ </tbody>
266
+ </table>
267
+ </div>
311
268
  </div>
312
- </div>
313
269
  )}
314
270
  </div>
315
271
  );
316
272
  };
317
273
 
274
+ // Columns stay intentionally loose: callers supply either the legacy
275
+ // table/column pair or a semantic key/header pair, and unknown extras are fine.
276
+ const columnsPropType = PropTypes.arrayOf(
277
+ PropTypes.shape({
278
+ key: PropTypes.string,
279
+ header: PropTypes.string,
280
+ displayName: PropTypes.string,
281
+ table: PropTypes.string,
282
+ column: PropTypes.string,
283
+ required: PropTypes.bool,
284
+ type: PropTypes.string,
285
+ })
286
+ );
287
+
318
288
  SectionGroupedReport.propTypes = {
319
289
  data: PropTypes.shape({
320
290
  groups: PropTypes.array,
321
291
  totalGroups: PropTypes.number,
322
- totalRecords: PropTypes.number
292
+ totalRecords: PropTypes.number,
323
293
  }).isRequired,
324
294
  config: PropTypes.shape({
325
295
  groupDisplayName: PropTypes.string,
326
296
  showGroupTotals: PropTypes.bool,
327
297
  groupHeaderStyle: PropTypes.string,
328
- emptyRowStyle: PropTypes.string
298
+ emptyRowStyle: PropTypes.string,
329
299
  }).isRequired,
330
- columns: PropTypes.array.isRequired,
331
- onExport: PropTypes.func
300
+ columns: columnsPropType.isRequired,
301
+ onExport: PropTypes.func,
332
302
  };
333
303
 
334
304
  GroupSection.propTypes = {
@@ -338,11 +308,14 @@ GroupSection.propTypes = {
338
308
  rows: PropTypes.array,
339
309
  emptyRows: PropTypes.array,
340
310
  totalRows: PropTypes.number,
341
- maxRows: PropTypes.number
311
+ maxRows: PropTypes.number,
342
312
  }).isRequired,
343
313
  config: PropTypes.object.isRequired,
344
- columns: PropTypes.array.isRequired,
345
- index: PropTypes.number.isRequired
314
+ columns: columnsPropType.isRequired,
315
+ index: PropTypes.number,
316
+ renderCellValue: PropTypes.func.isRequired,
317
+ isCollapsed: PropTypes.bool,
318
+ onToggleCollapse: PropTypes.func,
346
319
  };
347
320
 
348
- export default SectionGroupedReport;
321
+ export default SectionGroupedReport;
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { CheckCircle } from 'lucide-react';
4
+ import { listEntities } from '../reportSemantics';
5
+ import styles from '../../styles/ReportSemantic.module.scss';
6
+
7
+ /**
8
+ * Step 1 in semantic mode: pick the thing the report is about.
9
+ *
10
+ * Only business labels and descriptions are shown — the entity id is never
11
+ * rendered, because it is an opaque server handle.
12
+ */
13
+ const SemanticEntityStep = ({ model, selectedEntity, onSelect }) => {
14
+ const entities = listEntities(model);
15
+
16
+ if (entities.length === 0) {
17
+ return (
18
+ <div className={styles.empty}>
19
+ No data is available to report on yet.
20
+ </div>
21
+ );
22
+ }
23
+
24
+ return (
25
+ <div className={styles.section}>
26
+ <div className={styles.sectionIntro}>
27
+ <h3>What is this report about?</h3>
28
+ <p>
29
+ Pick the main thing you want to list. You can pull in
30
+ related information at the next step.
31
+ </p>
32
+ </div>
33
+
34
+ <div className={styles.cardGrid}>
35
+ {entities.map((entity) => {
36
+ const isSelected = entity.id === selectedEntity;
37
+ return (
38
+ <button
39
+ key={entity.id}
40
+ type="button"
41
+ className={`${styles.card} ${
42
+ isSelected ? styles.selected : ''
43
+ }`}
44
+ aria-pressed={isSelected}
45
+ onClick={() => onSelect(entity.id)}
46
+ >
47
+ <span className={styles.cardTitle}>
48
+ <span>{entity.plural}</span>
49
+ {isSelected && <CheckCircle size={16} />}
50
+ </span>
51
+ {entity.description && (
52
+ <span className={styles.cardDescription}>
53
+ {entity.description}
54
+ </span>
55
+ )}
56
+ <span className={styles.cardMeta}>
57
+ {entity.fieldCount} details available
58
+ {entity.relationCount > 0
59
+ ? ` · ${entity.relationCount} related areas`
60
+ : ''}
61
+ </span>
62
+ </button>
63
+ );
64
+ })}
65
+ </div>
66
+ </div>
67
+ );
68
+ };
69
+
70
+ SemanticEntityStep.propTypes = {
71
+ model: PropTypes.object,
72
+ selectedEntity: PropTypes.string,
73
+ onSelect: PropTypes.func.isRequired,
74
+ };
75
+
76
+ SemanticEntityStep.defaultProps = {
77
+ model: null,
78
+ selectedEntity: '',
79
+ };
80
+
81
+ export default SemanticEntityStep;