From 0429073774a6c1c7dc556b8beb6e8e3c7c91fbea Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 20:34:00 -0500 Subject: [PATCH] feat(20-03): add virtualized OID tree browser component - Flatten tree for tanstack/react-virtual virtualization - Expand/collapse branch nodes with chevron toggle - Checkbox selection for leaf nodes (selectable OIDs) - Search filter by name or OID substring - Expand all / collapse all toolbar buttons - ARIA tree roles for accessibility Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/settings/OIDTreeBrowser.tsx | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 frontend/src/components/settings/OIDTreeBrowser.tsx diff --git a/frontend/src/components/settings/OIDTreeBrowser.tsx b/frontend/src/components/settings/OIDTreeBrowser.tsx new file mode 100644 index 0000000..b6fbe38 --- /dev/null +++ b/frontend/src/components/settings/OIDTreeBrowser.tsx @@ -0,0 +1,239 @@ +import { useState, useRef, useMemo, useCallback } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Search } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import type { OIDNode } from '@/lib/api' + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface OIDTreeBrowserProps { + nodes: OIDNode[] + selectedOids: Set + onToggleOid: (oid: string, node: OIDNode) => void +} + +interface FlatOIDRow { + node: OIDNode + depth: number + hasChildren: boolean + isExpanded: boolean +} + +// ─── Tree Flattening ──────────────────────────────────────────────────────── + +function flattenTree( + nodes: OIDNode[], + expandedOids: Set, + depth: number = 0, +): FlatOIDRow[] { + const result: FlatOIDRow[] = [] + for (const node of nodes) { + const hasChildren = (node.children?.length ?? 0) > 0 + const isExpanded = expandedOids.has(node.oid) + result.push({ node, depth, hasChildren, isExpanded }) + if (hasChildren && isExpanded && node.children) { + result.push(...flattenTree(node.children, expandedOids, depth + 1)) + } + } + return result +} + +function countAllNodes(nodes: OIDNode[]): number { + let count = 0 + for (const node of nodes) { + count += 1 + if (node.children) count += countAllNodes(node.children) + } + return count +} + +function collectAllOids(nodes: OIDNode[], set: Set): void { + for (const node of nodes) { + set.add(node.oid) + if (node.children) collectAllOids(node.children, set) + } +} + +// ─── Component ────────────────────────────────────────────────────────────── + +export function OIDTreeBrowser({ nodes, selectedOids, onToggleOid }: OIDTreeBrowserProps) { + const scrollRef = useRef(null) + const [expandedOids, setExpandedOids] = useState>(new Set()) + const [search, setSearch] = useState('') + + const totalNodeCount = useMemo(() => countAllNodes(nodes), [nodes]) + + const flatRows = useMemo( + () => flattenTree(nodes, expandedOids), + [nodes, expandedOids], + ) + + // Filter rows when search is active + const filteredRows = useMemo(() => { + if (!search.trim()) return flatRows + const term = search.toLowerCase() + return flatRows.filter( + (row) => + row.node.name.toLowerCase().includes(term) || + row.node.oid.toLowerCase().includes(term), + ) + }, [flatRows, search]) + + const virtualizer = useVirtualizer({ + count: filteredRows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 32, + overscan: 10, + }) + + const toggleExpand = useCallback((oid: string) => { + setExpandedOids((prev) => { + const next = new Set(prev) + if (next.has(oid)) { + next.delete(oid) + } else { + next.add(oid) + } + return next + }) + }, []) + + const expandAll = useCallback(() => { + const allOids = new Set() + collectAllOids(nodes, allOids) + setExpandedOids(allOids) + }, [nodes]) + + const collapseAll = useCallback(() => { + setExpandedOids(new Set()) + }, []) + + // ─── Empty state ────────────────────────────────────────────────────── + + if (nodes.length === 0) { + return ( +
+

Upload a MIB file to browse OIDs

+
+ ) + } + + // ─── Render ─────────────────────────────────────────────────────────── + + return ( +
+ {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter by name or OID..." + className="h-7 text-xs pl-7" + /> +
+ + + + {filteredRows.length} of {totalNodeCount} nodes + +
+ + {/* Tree */} +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = filteredRows[virtualRow.index] + const isSelectable = !row.hasChildren || !!row.node.type + const isSelected = selectedOids.has(row.node.oid) + + return ( +
+ {/* Expand/collapse chevron */} + {row.hasChildren ? ( + + ) : ( + + )} + + {/* Checkbox for selectable nodes */} + {isSelectable && ( + onToggleOid(row.node.oid, row.node)} + className="flex-shrink-0" + onClick={(e) => e.stopPropagation()} + /> + )} + + {/* Name */} + + {row.node.name} + + + {/* OID */} + + {row.node.oid} + + + {/* Type badge */} + {row.node.type && ( + + {row.node.type} + + )} +
+ ) + })} +
+
+
+ ) +}