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) <noreply@anthropic.com>
This commit is contained in:
239
frontend/src/components/settings/OIDTreeBrowser.tsx
Normal file
239
frontend/src/components/settings/OIDTreeBrowser.tsx
Normal file
@@ -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<string>
|
||||
onToggleOid: (oid: string, node: OIDNode) => void
|
||||
}
|
||||
|
||||
interface FlatOIDRow {
|
||||
node: OIDNode
|
||||
depth: number
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
}
|
||||
|
||||
// ─── Tree Flattening ────────────────────────────────────────────────────────
|
||||
|
||||
function flattenTree(
|
||||
nodes: OIDNode[],
|
||||
expandedOids: Set<string>,
|
||||
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<string>): 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<HTMLDivElement>(null)
|
||||
const [expandedOids, setExpandedOids] = useState<Set<string>>(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<string>()
|
||||
collectAllOids(nodes, allOids)
|
||||
setExpandedOids(allOids)
|
||||
}, [nodes])
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
setExpandedOids(new Set())
|
||||
}, [])
|
||||
|
||||
// ─── Empty state ──────────────────────────────────────────────────────
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-text-muted">Upload a MIB file to browse OIDs</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-text-muted" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter by name or OID..."
|
||||
className="h-7 text-xs pl-7"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5" /> Expand
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
|
||||
<ChevronsDownUp className="h-3.5 w-3.5" /> Collapse
|
||||
</Button>
|
||||
<span className="text-[10px] text-text-muted whitespace-nowrap">
|
||||
{filteredRows.length} of {totalNodeCount} nodes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="tree"
|
||||
className="h-[400px] overflow-auto border border-border rounded-sm"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = filteredRows[virtualRow.index]
|
||||
const isSelectable = !row.hasChildren || !!row.node.type
|
||||
const isSelected = selectedOids.has(row.node.oid)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.node.oid}
|
||||
role="treeitem"
|
||||
aria-expanded={row.hasChildren ? row.isExpanded : undefined}
|
||||
aria-level={row.depth + 1}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className={`flex items-center gap-2 h-8 hover:bg-surface-hover cursor-pointer ${isSelected ? 'bg-accent/10' : ''}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
paddingLeft: `${row.depth * 20 + 8}px`,
|
||||
}}
|
||||
>
|
||||
{/* Expand/collapse chevron */}
|
||||
{row.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 p-0.5 hover:bg-surface-raised rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleExpand(row.node.oid)
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 text-text-muted transition-transform ${row.isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Checkbox for selectable nodes */}
|
||||
{isSelectable && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => onToggleOid(row.node.oid, row.node)}
|
||||
className="flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className="text-sm text-text-primary font-mono truncate"
|
||||
title={row.node.description ?? undefined}
|
||||
>
|
||||
{row.node.name}
|
||||
</span>
|
||||
|
||||
{/* OID */}
|
||||
<span className="text-xs text-text-muted font-mono flex-shrink-0">
|
||||
{row.node.oid}
|
||||
</span>
|
||||
|
||||
{/* Type badge */}
|
||||
{row.node.type && (
|
||||
<span className="text-[10px] bg-surface-raised px-1 py-0.5 rounded flex-shrink-0">
|
||||
{row.node.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user