-
Notifications
You must be signed in to change notification settings - Fork 3
/
TreeViewList.tsx
513 lines (465 loc) · 16.8 KB
/
TreeViewList.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
import { Box, Tooltip, Typography, alpha, debounce } from "@mui/material";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
import { TreeNodeItem, TreeViewListProps } from "./TreeViewList.types";
import AddIcon from "@mui/icons-material/Add";
import RemoveIcon from "@mui/icons-material/Remove";
import SearchBar from "../SearchBar/SearchBar";
/**
* A component that renders a tree view list.
*
* @param props - The properties for the tree view list.
* @property props.enableSearch - If true, the search input is displayed. Defaults to false.
* @property props.expandSearchResults - If true, the tree nodes will be automatically expanded when a search term is entered. Defaults to false.
* @property props.expandItems - The number of items to expand
* @property props.items - The items to display in the tree view list.
* @property props.onNodeSelect - The function to call when a node is selected.
* @property props.onNodeToggle - The function to call when a node is toggled.
* @property props.selected - The ID of the currently selected item.
* @property props.height - The height of the tree view list.
* @property props.width - The width of the tree view list.
* @returns The tree view list component.
*/
const TreeViewList = ({
enableSearch = false,
expandSearchResults = false,
expandItems = 1,
items,
onNodeSelect,
onNodeToggle,
selected,
height = "100%",
width = "100%"
}: TreeViewListProps) => {
// state for items to display in the tree
const [treeDisplayItems, setTreeDisplayItems] =
useState<TreeViewListProps["items"]>(items);
// state for selected node
const [selectedNode, setSelectedNode] = useState<string>(selected ?? "");
// local state for selected node
const [currentSelection, setCurrentSelection] = useState(selected ?? "");
// state for search input value
const [searchValue, setSearchValue] = useState("");
// state for expanded nodes
const [expandedNodes, setExpandedNodes] = useState<string[]>([]);
// state for user expanded nodes
const [userExpanded, setUserExpanded] = useState<string[]>([]);
// state for the node we are hovered over
const [hoveredNode, setHoveredNode] = useState<string>("");
// reference to the box element
const boxRef = useRef<HTMLDivElement>(null);
const [boxWidth, setBoxWidth] = useState(0);
useEffect(() => {
const updateWidth = () => {
if (boxRef.current) {
setBoxWidth(boxRef.current.offsetWidth);
}
};
window.addEventListener("resize", updateWidth);
updateWidth();
return () => {
window.removeEventListener("resize", updateWidth);
};
}, [width]);
// update tree display items when the items prop changes or when the search input changes
useEffect(() => {
// if search is enabled and the search input is not empty, display only items that match the search terms, otherwise display all items
if (enableSearch && searchValue !== "") {
// split the search into individual words and filter out any empty strings
const searchTerms = searchValue.split(" ").filter(term => term);
// filter the items to contain only those that match each search term
let filteredItems = items;
for (const term of searchTerms) {
filteredItems = filterBySearchTerm(filteredItems, term);
}
setTreeDisplayItems(filteredItems);
} else {
setTreeDisplayItems(items);
}
}, [enableSearch, items, searchValue]);
// count the number of last children in the tree
const countLastChild = useCallback((items: TreeNodeItem[]) => {
let count = 0;
for (const item of items) {
// Check if the object has a 'children' property and it's not empty
if (item.children && item.children.length > 0) {
// If the object has children, recursively call the function on its children
count += countLastChild(item.children);
} else {
// If the object has no children, it is a last child
count += 1;
}
}
return count;
}, []);
// expand the nodes when the search input changes
const expandNodes = useCallback(() => {
// use a Set to store the searched nodes to prevent duplicates
const searchedNodes = new Set<string>();
const expandChildNodes = (items: TreeNodeItem[]) => {
// If the condition is not met for the entire items array, return early
if (countLastChild(items) > expandItems) {
// reset the searched nodes array
searchedNodes.clear();
return;
}
// if the number of last children is less than or equal to the expandItems, expand the nodes
if (countLastChild(items) <= expandItems) {
for (const item of items) {
// if the node has children, expand it and call the function on its children
if (item.children) {
searchedNodes.add(item.nodeId);
expandChildNodes(item.children);
}
}
}
};
// recursively expand the child nodes
expandChildNodes(treeDisplayItems);
// update the expanded nodes with the searched nodes
setExpandedNodes(prevState => {
const merged = new Set([...prevState, ...searchedNodes]);
return Array.from(merged);
});
}, [countLastChild, expandItems, treeDisplayItems]);
// update the expanded nodes when the user expanded nodes change or when the search input changes
useEffect(() => {
if (searchValue !== "") {
// if the search input is not empty, update expanded nodes with the user expanded nodes
setExpandedNodes(prevState => [...prevState, ...userExpanded]);
} else {
// if the search input is empty, update expanded nodes with the user expanded nodes
setExpandedNodes(userExpanded);
}
}, [userExpanded, searchValue]);
// debounce the expandNodes function to prevent it from being called too frequently
const debouncedExpandAllNodes = useCallback(
() => debounce(expandNodes, 100)(),
[expandNodes]
);
useEffect(() => {
if (enableSearch && expandSearchResults && searchValue !== "") {
debouncedExpandAllNodes();
} else {
// if enableSearch is false and expandSearchResults is false, searchValue is empty, and there isn't a selectedNode update the expanded nodes with the user expanded nodes
if (selectedNode === "") {
setExpandedNodes(userExpanded);
}
}
}, [
userExpanded,
debouncedExpandAllNodes,
enableSearch,
expandSearchResults,
searchValue,
selectedNode
]);
// update the selected node when the selected prop changes
useEffect(() => {
// if the selected node is not empty, update the selected node
if (selected) {
setSelectedNode(selected);
}
}, [selected]);
// update the expanded nodes when the selected node changes
useEffect(() => {
if (selectedNode) {
// update the expanded nodes when the selected node changes
const parentNodeIds = findPathToNodeId(items, selectedNode);
// update the expanded nodes with the parent node ids
setExpandedNodes(prev => [...prev, ...parentNodeIds]);
}
}, [items, selectedNode]);
// render the tree nodes with optional tooltips
const renderTree = (nodes: TreeNodeItem[]) =>
nodes.map(node => (
<TooltipTreeItem
disabled={node.disabled}
hoveredNode={hoveredNode}
key={node.nodeId}
label={node.label}
nodeId={node.nodeId}
setHoveredNode={setHoveredNode}
tooltip={node.tooltip}
>
{Array.isArray(node.children) && node.children.length > 0
? renderTree(node.children)
: null}
</TooltipTreeItem>
));
return (
<>
<Box
ref={boxRef}
sx={theme => ({
background: theme.palette.background.paper,
display: "flex",
flexDirection: "column",
height,
overflow: "hidden",
width
})}
>
<Box sx={{ flexShrink: 0 }}>
<Box
sx={theme => ({
background: theme.palette.background.paper,
marginRight: 0.2,
position: "sticky",
top: 0,
zIndex: 2
})}
>
{/* Additional layer to block content behind */}
<Box
sx={theme => ({
background: theme.palette.background.paper, // Matching background color
height: "100%",
left: 0,
position: "absolute",
right: 0,
top: 0,
zIndex: -1 // Behind the sticky box content
})}
></Box>
{enableSearch ? (
<SearchBar
value={searchValue}
onChange={event => {
setSelectedNode("");
setSearchValue(event.target.value);
}}
/>
) : null}
</Box>
</Box>
<Box sx={{ flexGrow: 1, overflowY: "auto" }}>
<SimpleTreeView
sx={{
"& .css-9l5vo-MuiCollapse-wrapperInner": {
width: boxWidth <= 280 ? "auto" : "100%"
}
}}
slots={{ collapseIcon: RemoveIcon, expandIcon: AddIcon }}
expandedItems={expandedNodes}
selectedItems={currentSelection}
onSelectedItemsChange={(event, nodeId) => {
if (nodeId) {
const node = getNodeById(treeDisplayItems, nodeId);
const isChild = Boolean(
node && (!node.children || node.children.length === 0)
);
if (onNodeSelect) {
const nodeDetails = { isChild };
// update the selected node when a node is selected and it is a child
if (isChild) {
setCurrentSelection(nodeId);
setSelectedNode(nodeId);
}
onNodeSelect(event, nodeId, nodeDetails);
}
}
}}
onExpandedItemsChange={(event, nodeId) => {
// reset the selected node when a node is toggled
setSelectedNode("");
// update the user expanded nodes when a node is toggled
setUserExpanded(nodeId);
// update the expanded nodes when a node is toggled
setExpandedNodes(nodeId);
// call the onNodeToggle function if it is defined
if (onNodeToggle) {
setSelectedNode(selectedNode);
onNodeToggle(event, nodeId);
}
}}
>
{renderTree(treeDisplayItems)}
{items.length > 0 && treeDisplayItems.length === 0 && (
<Typography
data-testid="none-selected"
sx={{ color: theme => theme.palette.grey[500], pl: 0.5 }}
>
No search results.
</Typography>
)}
{items.length === 0 && (
<Typography
data-testid="none-selected"
sx={{ color: theme => theme.palette.grey[500], pl: 0.5 }}
>
No data is available.
</Typography>
)}
</SimpleTreeView>
</Box>
</Box>
</>
);
};
/**
* Recursively finds a node by its ID.
*
* @param nodes - The nodes to search.
* @param nodeId - The ID of the node to find.
* @returns The node with the specified ID, or null if not found.
*/
const getNodeById = (
nodes: TreeNodeItem[],
nodeId: string
): TreeNodeItem | null => {
for (const node of nodes) {
if (node.nodeId === nodeId) {
return node;
} else if (node.children) {
const result = getNodeById(node.children, nodeId);
if (result) {
return result;
}
}
}
return null;
};
/**
* Creates a tree view list item with an optional tooltip.
*
* @param props - The properties for the tooltip tree item.
* @property props.disabled - If true, the tree item is disabled.
* @property props.label - The label of the tree item.
* @property props.nodeId - The unique ID of the tree item.
* @property props.tooltip - The tooltip to display.
* @returns The tree item wrapped in a tooltip.
*/
const TooltipTreeItem = (
props: Pick<TreeNodeItem, "disabled" | "label" | "nodeId" | "tooltip"> & {
children?: React.ReactNode;
hoveredNode: string;
setHoveredNode: (nodeId: string) => void;
}
) => {
// Destructure the nodeId and the other props
const { hoveredNode, setHoveredNode, nodeId, tooltip, ...rest } = props;
return (
<Tooltip
title={tooltip ? <>{tooltip}</> : ""}
placement="right-start"
open={hoveredNode === nodeId}
disableFocusListener
>
<TreeItem
{...rest}
itemId={nodeId}
sx={theme => ({
"& .MuiTreeItem-label": {
margin: 0,
padding: "2px 2px"
},
"& .MuiTreeItem-root": {
borderLeft: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
marginLeft: "4px"
}
})}
onMouseOver={event => {
event.stopPropagation();
const target = event.target as Element;
if (target.classList.contains("MuiTreeItem-label")) {
setHoveredNode(nodeId);
}
}}
onMouseOut={event => {
event.stopPropagation();
setHoveredNode("");
}}
/>
</Tooltip>
);
};
/**
* Filter items to contain only those that match the searchTerm
* @param items - The items to filter.
* @param searchTerm - The search term to match.
* @returns The filtered items.
* @example filterBySearchTerm(items, "search term");
*/
const filterBySearchTerm = (items: TreeNodeItem[], searchTerm: string) => {
// take a copy of the items array to avoid mutating the original
const itemsCopy = JSON.parse(JSON.stringify(items)) as TreeNodeItem[];
// function to check if a node item matches the search term
const matchesSearch = (node: TreeNodeItem, searchTerm: string) => {
return node.label.toLowerCase().includes(searchTerm.toLowerCase());
};
// interim type to add the active property to the node item
type TreeNodeItemWithActive = TreeNodeItem & { active?: boolean };
// function to recursively loop through all node items and check if they match the search term
const checkNodes = (items: TreeNodeItemWithActive[], searchTerm: string) => {
for (const item of items) {
if (matchesSearch(item, searchTerm)) {
item.active = true;
} else {
item.active = false;
}
if (item.children) {
checkNodes(item.children, searchTerm);
}
}
};
checkNodes(itemsCopy, searchTerm);
// function to recursively filter out all inactive items, making sure to keep parent items if they have active children
const removeInactiveItems = (
items: TreeNodeItemWithActive[]
): TreeNodeItemWithActive[] => {
return items.filter((item: TreeNodeItemWithActive) => {
// if the item is active, we want to keep it and all its children
if (item.active) {
return true;
}
// if we have any children, we want to recursively call this function on them and keep the parent if it has any active children left
if (item.children) {
item.children = removeInactiveItems(item.children);
return item.children && item.children.length > 0;
}
// otherwise we want to remove it
return false;
});
};
const filteredItems = removeInactiveItems(itemsCopy);
// remove the active property from the filtered items
const removeActiveProperty = (items: TreeNodeItemWithActive[]) => {
for (const item of items) {
delete item.active;
if (item.children) {
removeActiveProperty(item.children);
}
}
return items;
};
removeActiveProperty(filteredItems);
// return the filtered items
return filteredItems as TreeNodeItem[];
};
/**
* Recursively find the path from the root to the given nodeId
* @param items - The items to search.
* @param nodeId - The node id to find.
* @returns The path from the root to the node id.
* @example findPathToNodeId(items, "node_id");
*/
const findPathToNodeId = (items: TreeNodeItem[], nodeId: string): string[] => {
for (const item of items) {
if (item.nodeId === nodeId) {
// if the current item is the one we're looking for, return an array containing only its id
return [item.nodeId];
}
if (item.children) {
// if the current item has children, recursively call this function on them
const pathFromChild = findPathToNodeId(item.children, nodeId);
if (pathFromChild.length > 0) {
// if we found the node in the children, return an array containing the id of the current item and the path from the child
return [item.nodeId, ...pathFromChild];
}
}
}
// if no node found, return an empty array
return [];
};
export default TreeViewList;