1
- import React , { useMemo , useRef , useEffect , useState } from 'react' ;
1
+ import React , { useMemo , useRef , useEffect , useState , useCallback } from 'react' ;
2
2
import { FiChevronDown , FiArrowUp , FiArrowDown , FiChevronLeft , FiChevronRight , FiChevronsLeft , FiChevronsRight } from 'react-icons/fi' ;
3
3
import { HierarchicalTableProvider , useHierarchicalTable } from './HierarchicalTableContext' ;
4
4
import { Checkbox } from "components/common/Checkbox"
5
+ // Update the SmoothLine component
6
+ const SmoothLine = ( { startX, startY, endX : endXPreprocess , endY, color, animated, opacity, offset } ) => {
7
+ const endX = endXPreprocess ;
5
8
9
+ const endYAdjustment = ! animated ? 0 : - 5
10
+ const midX = startX - offset ;
6
11
7
- const TableRow = ( { item, schema, level = 0 , onRowClick, columnWidths, updateWidth, rowClassName } ) => {
8
- const { expandedRows, selectedRows, toggleRow, toggleSelection, isItemSelected } = useHierarchicalTable ( ) ;
12
+ const path = `
13
+ M ${ startX } ${ startY }
14
+ C ${ midX } ${ startY } , ${ midX } ${ startY } , ${ midX } ${ ( startY + endY ) / 2 }
15
+ S ${ midX } ${ endY } , ${ endX + endYAdjustment } ${ endY }
16
+ ` ;
17
+ const duration = '1s' ;
18
+ return (
19
+ < g >
20
+ < path
21
+ d = { path }
22
+ stroke = { color }
23
+ fill = "none"
24
+ strokeWidth = "1"
25
+ strokeDasharray = { animated ? "5,5" : "none" }
26
+ className = { `transition-all duration-${ duration } ease-in-out ${ animated ? "animated-dash" : "" } ` }
27
+ opacity = { opacity }
28
+ />
29
+ { animated && (
30
+ < >
31
+ < path
32
+ d = { path }
33
+ stroke = { color }
34
+ fill = "none"
35
+ strokeWidth = "2"
36
+ strokeDasharray = "5,5"
37
+ className = { `animated-dash-overlay transition-opacity duration-${ duration } ease-in-out` }
38
+ opacity = { opacity }
39
+ />
40
+ < marker
41
+ id = "arrowhead"
42
+ markerWidth = "6"
43
+ markerHeight = "4"
44
+ refX = "0"
45
+ refY = "2"
46
+ orient = "auto"
47
+ >
48
+ < polygon points = "0 0, 6 2, 0 4" fill = { color } />
49
+ </ marker >
50
+ < path
51
+ d = { path }
52
+ stroke = { color }
53
+ fill = "none"
54
+ strokeWidth = "1.5"
55
+ markerEnd = "url(#arrowhead)"
56
+ className = { `animated-dash transition-opacity duration-${ duration } ease-in-out` }
57
+ opacity = { opacity }
58
+ />
59
+ </ >
60
+ ) }
61
+ </ g >
62
+ ) ;
63
+ } ;
64
+
65
+
66
+
67
+ const TableRow = ( { item, schema, level = 0 , onRowClick, columnWidths, updateWidth, rowClassName, setRowRef, links, linkColumn } ) => {
68
+ const { expandedRows, selectedRows, toggleRow, toggleSelection, isItemSelected, setHoveredRow, sortedData } = useHierarchicalTable ( ) ;
9
69
const hasChildren = item . children && item . children . length > 0 ;
10
70
const isExpanded = expandedRows [ item . id ] ;
11
71
const isSelected = isItemSelected ( item ) ;
12
72
const [ isNew , setIsNew ] = useState ( true ) ;
13
-
14
73
15
74
const customRowClassName = rowClassName ? rowClassName ( item ) : '' ;
16
75
@@ -21,6 +80,37 @@ const TableRow = ({ item, schema, level = 0, onRowClick, columnWidths, updateWid
21
80
}
22
81
} , [ isNew ] ) ;
23
82
83
+ const rowRef = useRef ( null ) ;
84
+
85
+ useEffect ( ( ) => {
86
+ if ( ! rowRef . current ) return ;
87
+
88
+ const updatePosition = ( ) => {
89
+ const tableRect = rowRef . current . closest ( 'table' ) . getBoundingClientRect ( ) ;
90
+ const rowRect = rowRef . current . getBoundingClientRect ( ) ;
91
+ const relativeX = rowRect . left - tableRect . left ;
92
+ const relativeY = rowRect . top - tableRect . top + rowRect . height / 2 ;
93
+ setRowRef ( item . id , { id : item . id , x : relativeX , y : relativeY , visible : true } ) ;
94
+ } ;
95
+
96
+ // Initial position update
97
+ updatePosition ( ) ;
98
+
99
+ // Create a ResizeObserver
100
+ const resizeObserver = new ResizeObserver ( updatePosition ) ;
101
+
102
+ // Observe both the row and the table
103
+ resizeObserver . observe ( rowRef . current ) ;
104
+ resizeObserver . observe ( rowRef . current . closest ( 'table' ) ) ;
105
+
106
+ // Clean up
107
+ return ( ) => {
108
+ setRowRef ( item . id , { visible : false } ) ;
109
+ resizeObserver . disconnect ( ) ;
110
+ } ;
111
+ } , [ item . id , setRowRef , sortedData . length , expandedRows ] ) ;
112
+
113
+
24
114
return (
25
115
< React . Fragment >
26
116
< tr
@@ -31,6 +121,8 @@ const TableRow = ({ item, schema, level = 0, onRowClick, columnWidths, updateWid
31
121
onClick = { ( ) => {
32
122
if ( onRowClick ) onRowClick ( item ) ;
33
123
} }
124
+ onMouseEnter = { ( ) => setHoveredRow ( item . id ) }
125
+ onMouseLeave = { ( ) => setHoveredRow ( null ) }
34
126
>
35
127
< td className = "py-3 px-4 w-12" >
36
128
< Checkbox
@@ -40,15 +132,15 @@ const TableRow = ({ item, schema, level = 0, onRowClick, columnWidths, updateWid
40
132
/>
41
133
</ td >
42
134
< td className = "py-3 px-4 w-12 relative" style = { { paddingLeft : `${ level * 20 + 16 } px` } } >
43
- { hasChildren ? (
44
- < span onClick = { ( e ) => { e . stopPropagation ( ) ; toggleRow ( item . id ) ; } } >
45
- { isExpanded ? < FiChevronDown className = "text-gray-400 text-base" /> : < FiChevronRight className = "text-gray-400 text-base" /> }
46
- </ span >
47
- ) : (
48
- < span className = "w-4 h-4 inline-block relative" >
49
- < span className = "absolute left-1/2 top-1/2 w-1.5 h-1.5 bg-gray-600 rounded-full transform -translate-x-1/2 -translate-y-1/2" > </ span >
50
- </ span >
51
- ) }
135
+ < div className = "flex items-center" >
136
+ { hasChildren && (
137
+ < span onClick = { ( e ) => { e . stopPropagation ( ) ; toggleRow ( item . id ) ; } }
138
+ >
139
+ { isExpanded ? < FiChevronDown className = "text-gray-400 text-base" /> : < FiChevronRight className = "text-gray-400 text-base" /> }
140
+ </ span >
141
+ ) }
142
+ </ div >
143
+
52
144
</ td >
53
145
{ schema . columns . map ( ( column , index ) => {
54
146
const content = column . render ? column . render ( item ) : item [ column . key ] ;
@@ -60,18 +152,26 @@ const TableRow = ({ item, schema, level = 0, onRowClick, columnWidths, updateWid
60
152
style = { {
61
153
...column . style ,
62
154
maxWidth : maxWidth !== Infinity ? `${ maxWidth } px` : undefined ,
63
- width : `${ Math . min ( columnWidths [ column . key ] || 0 , maxWidth ) } px`
155
+ width : `${ Math . min ( columnWidths [ column . key ] || 0 , maxWidth ) } px` ,
64
156
} }
157
+
65
158
title = { typeof content === 'string' ? content : '' }
66
159
>
67
- { content }
160
+ < div style = { {
161
+ marginLeft : column . key === linkColumn ? `${ level * 20 + 16 } px` : 0 ,
162
+ width : column . key === linkColumn ? '100%' : 'auto'
163
+ } } >
164
+ < div ref = { column . key === linkColumn ? rowRef : null } >
165
+ { content }
166
+ </ div >
167
+ </ div >
68
168
</ td >
69
169
</ React . Fragment >
70
170
) ;
71
171
} ) }
72
172
</ tr >
73
173
{ hasChildren && isExpanded && item . children . map ( child => (
74
- < TableRow key = { child . id } item = { child } schema = { schema } level = { level + 1 } onRowClick = { onRowClick } columnWidths = { columnWidths } updateWidth = { updateWidth } rowClassName = { rowClassName } />
174
+ < TableRow key = { child . id } item = { child } schema = { schema } level = { level + 1 } onRowClick = { onRowClick } columnWidths = { columnWidths } updateWidth = { updateWidth } rowClassName = { rowClassName } setRowRef = { setRowRef } links = { links } linkColumn = { linkColumn } />
75
175
) ) }
76
176
</ React . Fragment >
77
177
) ;
@@ -120,8 +220,14 @@ const TableHeader = ({ schema, columnWidths, updateWidth }) => {
120
220
) ;
121
221
} ;
122
222
123
- const TableBody = ( { schema, onRowClick, columnWidths, updateWidth, rowClassName } ) => {
223
+ const TableBody = ( { schema, onRowClick, columnWidths, updateWidth, rowClassName, setRowRef , links , linkColumn } ) => {
124
224
const { sortedData } = useHierarchicalTable ( ) ;
225
+ const [ , forceUpdate ] = useState ( { } ) ;
226
+
227
+ useEffect ( ( ) => {
228
+ // Force a re-render to trigger position updates
229
+ forceUpdate ( { } ) ;
230
+ } , [ sortedData ] ) ;
125
231
126
232
return (
127
233
< tbody >
@@ -134,6 +240,9 @@ const TableBody = ({ schema, onRowClick, columnWidths, updateWidth, rowClassName
134
240
columnWidths = { columnWidths }
135
241
updateWidth = { updateWidth }
136
242
rowClassName = { rowClassName }
243
+ setRowRef = { setRowRef }
244
+ links = { links }
245
+ linkColumn = { linkColumn }
137
246
/>
138
247
) ) }
139
248
</ tbody >
@@ -190,10 +299,10 @@ const PaginationControls = ({ currentPage, totalPages, onPageChange, pageSize, t
190
299
) ;
191
300
} ;
192
301
193
- const HierarchicalTable = ( { schema, data, onRowClick, onSelectionChange, initialSortConfig, rowClassName, currentPage, onPageChange, pageSize, totalItems, omitColumns, expandAll } ) => {
302
+ const HierarchicalTable = ( { schema, data, onRowClick, onSelectionChange, initialSortConfig, rowClassName, currentPage, onPageChange, pageSize, totalItems, omitColumns, expandAll, links , linkColumn } ) => {
194
303
const [ columnWidths , setColumnWidths ] = useState ( { } ) ;
195
304
const [ isExpanded , setIsExpanded ] = useState ( false ) ;
196
-
305
+ const [ rowRefs , setRowRefs ] = useState ( { } ) ;
197
306
198
307
const updateWidth = ( key , width , maxWidth ) => {
199
308
setColumnWidths ( prev => ( {
@@ -202,6 +311,25 @@ const HierarchicalTable = ({ schema, data, onRowClick, onSelectionChange, initia
202
311
} ) ) ;
203
312
} ;
204
313
314
+ const tableRef = useRef ( null ) ;
315
+ const [ tableOffset , setTableOffset ] = useState ( { x : 0 , y : 0 } ) ;
316
+
317
+ useEffect ( ( ) => {
318
+ if ( tableRef . current ) {
319
+ const rect = tableRef . current . getBoundingClientRect ( ) ;
320
+ setTableOffset ( { x : rect . left , y : rect . top } ) ;
321
+ }
322
+ } , [ ] ) ;
323
+
324
+ const setRowRef = useCallback ( ( id , ref ) => {
325
+ setRowRefs ( prev => {
326
+ if ( JSON . stringify ( prev [ id ] ) === JSON . stringify ( ref ) ) {
327
+ return prev ;
328
+ }
329
+ return { ...prev , [ id ] : ref } ;
330
+ } ) ;
331
+ } , [ ] ) ;
332
+
205
333
useEffect ( ( ) => {
206
334
const initialWidths = { } ;
207
335
schema . columns . forEach ( column => {
@@ -233,7 +361,7 @@ const HierarchicalTable = ({ schema, data, onRowClick, onSelectionChange, initia
233
361
setIsExpanded = { setIsExpanded }
234
362
expandAll = { expandAll }
235
363
>
236
- < div className = "overflow-x-auto hide-scrollbar" >
364
+ < div className = "overflow-x-auto hide-scrollbar relative" ref = { tableRef } >
237
365
< table className = "w-full" >
238
366
< TableHeader
239
367
schema = { filteredSchema }
@@ -246,8 +374,23 @@ const HierarchicalTable = ({ schema, data, onRowClick, onSelectionChange, initia
246
374
columnWidths = { columnWidths }
247
375
updateWidth = { updateWidth }
248
376
rowClassName = { rowClassName }
377
+ setRowRef = { setRowRef }
378
+ links = { links }
379
+ linkColumn = { linkColumn }
249
380
/>
250
381
</ table >
382
+
383
+ { /* Update SVG rendering for direct lines */ }
384
+ < svg
385
+ className = "absolute top-0 left-0 w-full h-full pointer-events-none"
386
+ style = { { overflow : 'visible' } }
387
+ >
388
+ < LinkLines
389
+ links = { links }
390
+ rowRefs = { rowRefs }
391
+ tableOffset = { tableOffset }
392
+ />
393
+ </ svg >
251
394
</ div >
252
395
{ onPageChange && (
253
396
< PaginationControls
@@ -261,5 +404,69 @@ const HierarchicalTable = ({ schema, data, onRowClick, onSelectionChange, initia
261
404
</ HierarchicalTableProvider >
262
405
) ;
263
406
} ;
407
+ const LinkLines = ( { links, rowRefs, tableOffset } ) => {
408
+ const { hoveredRow, expandedRows } = useHierarchicalTable ( ) ;
409
+ // Memoize the grouping and sorting of links
410
+ const groupedLinks = useMemo ( ( ) => {
411
+ const grouped = links ?. reduce ( ( acc , link ) => {
412
+ if ( ! acc [ link . from ] ) acc [ link . from ] = [ ] ;
413
+ // Check for uniqueness of the link
414
+ const isUnique = ! acc [ link . from ] . some ( existingLink =>
415
+ existingLink . from === link . from && existingLink . to === link . to
416
+ ) ;
417
+
418
+ if ( isUnique ) {
419
+ acc [ link . from ] . push ( link ) ;
420
+ } else {
421
+ console . warn ( `Duplicate link found: from ${ link . from } to ${ link . to } ` ) ;
422
+ }
423
+ return acc ;
424
+ } , { } ) ;
425
+
426
+ // Sort each group by distance
427
+ Object . values ( grouped ) . forEach ( group => {
428
+ group . sort ( ( a , b ) => {
429
+ const distA = Math . abs ( rowRefs [ a . to ] ?. y - rowRefs [ a . from ] ?. y ) ;
430
+ const distB = Math . abs ( rowRefs [ b . to ] ?. y - rowRefs [ b . from ] ?. y ) ;
431
+ return distA - distB ;
432
+ } ) ;
433
+ } ) ;
434
+
435
+ return grouped ;
436
+ } , [ links , rowRefs ] ) ;
437
+
438
+
439
+
440
+ return links ?. map ( ( link , index ) => {
441
+ const startRow = rowRefs [ link . from ] ;
442
+ const endRow = rowRefs [ link . to ] ;
443
+
444
+
445
+ // Only render the link if both rows are expanded
446
+ if ( startRow && endRow && startRow ?. visible && endRow ?. visible ) {
447
+ const isHighlighted = hoveredRow === link . from ||
448
+ hoveredRow === link . to ;
449
+ const offset = ( groupedLinks [ link . from ] . indexOf ( link ) + 2 ) * 5 ; // Multiply by 20 for spacing
450
+ const color = isHighlighted
451
+ ? ( hoveredRow === link . from ? '#f97316' : '#3b82f6' ) // Orange if going from, Blue if coming to
452
+ : '#4a5568' ;
453
+ return (
454
+ < SmoothLine
455
+ key = { index }
456
+ startX = { startRow . x }
457
+ startY = { startRow . y }
458
+ endX = { endRow . x }
459
+ endY = { endRow . y }
460
+ color = { color }
461
+ animated = { isHighlighted }
462
+ opacity = { isHighlighted ? 1 : 0.3 }
463
+ offset = { offset }
464
+ />
465
+ ) ;
466
+ }
467
+
468
+ return null ;
469
+ } ) ;
470
+ } ;
264
471
265
472
export default HierarchicalTable ;
0 commit comments