feat: extend search (Ctrl+F and Ctrl+K) to include pending devices
Both SearchBar and SearchModal now fetch and search pending devices by IP, hostname, and service name. Selecting a pending result opens the sidebar to the Pending tab and highlights the matching device.
This commit is contained in:
+29
-2
@@ -44,6 +44,8 @@ export default function App() {
|
|||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [sidebarForceView, setSidebarForceView] = useState<'pending' | undefined>(undefined)
|
||||||
|
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
@@ -370,6 +372,8 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
onNodeApproved={setEditNodeId}
|
||||||
|
forceView={sidebarForceView}
|
||||||
|
highlightPendingId={highlightPendingId}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -386,7 +390,19 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
<CanvasContainer
|
||||||
|
onConnect={handleEdgeConnect}
|
||||||
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={snapshotHistory}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -484,7 +500,18 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
<SearchModal
|
||||||
|
open={searchOpen}
|
||||||
|
onClose={() => setSearchOpen(false)}
|
||||||
|
onOpenPending={(deviceId) => {
|
||||||
|
setHighlightPendingId(undefined)
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => {
|
||||||
|
setHighlightPendingId(deviceId)
|
||||||
|
setSidebarForceView('pending')
|
||||||
|
}, 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ interface CanvasContainerProps {
|
|||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -101,7 +102,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
|||||||
@@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { useReactFlow } from '@xyflow/react'
|
import { useReactFlow } from '@xyflow/react'
|
||||||
import { Search, X } from 'lucide-react'
|
import { Search, X } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
import { NODE_TYPE_LABELS } from '@/types'
|
import { NODE_TYPE_LABELS } from '@/types'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
export function SearchBar() {
|
interface SearchBarProps {
|
||||||
|
onOpenPending?: (deviceId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const { nodes, setSelectedNode } = useCanvasStore()
|
const { nodes, setSelectedNode } = useCanvasStore()
|
||||||
const { setCenter } = useReactFlow()
|
const { setCenter } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
@@ -31,7 +43,7 @@ export function SearchBar() {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const q = query.toLowerCase().trim()
|
const q = query.toLowerCase().trim()
|
||||||
const results = q
|
const nodeResults = q
|
||||||
? nodes.filter((n) => {
|
? nodes.filter((n) => {
|
||||||
if (n.data.type === 'groupRect') return false
|
if (n.data.type === 'groupRect') return false
|
||||||
return (
|
return (
|
||||||
@@ -43,6 +55,19 @@ export function SearchBar() {
|
|||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
const pendingResults = q
|
||||||
|
? pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
const goToNode = (id: string) => {
|
const goToNode = (id: string) => {
|
||||||
const node = nodes.find((n) => n.id === id)
|
const node = nodes.find((n) => n.id === id)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
@@ -101,7 +126,7 @@ export function SearchBar() {
|
|||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
||||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
{totalResults} result{totalResults !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -113,9 +138,9 @@ export function SearchBar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{totalResults > 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
{results.map((n) => (
|
{nodeResults.map((n) => (
|
||||||
<button
|
<button
|
||||||
key={n.id}
|
key={n.id}
|
||||||
onClick={() => goToNode(n.id)}
|
onClick={() => goToNode(n.id)}
|
||||||
@@ -146,10 +171,43 @@ export function SearchBar() {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||||
|
)}
|
||||||
|
{pendingResults.map((d) => {
|
||||||
|
const serviceName = d.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '7px 12px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{d.hostname ?? d.ip}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
|
{serviceName ?? d.ip}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q && results.length === 0 && (
|
{q && totalResults === 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
||||||
No results for “{query}”
|
No results for “{query}”
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,61 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
import { useReactFlow } from '@xyflow/react'
|
import { useReactFlow } from '@xyflow/react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
interface SearchModalProps {
|
interface SearchModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
onOpenPending: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchModal({ open, onClose }: SearchModalProps) {
|
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||||
const nodes = useCanvasStore((s) => s.nodes)
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
|
||||||
|
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
n.data.label?.toLowerCase().includes(q) ||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
n.data.ip?.toLowerCase().includes(q) ||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
n.data.hostname?.toLowerCase().includes(q)
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
).slice(0, 8)
|
).slice(0, 6)
|
||||||
|
|
||||||
const handleSelect = useCallback((nodeId: string) => {
|
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||||
|
d.ip.toLowerCase().includes(q) ||
|
||||||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.services.some((s) =>
|
||||||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
|
s.category?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
).slice(0, 4)
|
||||||
|
|
||||||
|
const totalResults = nodeResults.length + pendingResults.length
|
||||||
|
|
||||||
|
const handleSelectNode = useCallback((nodeId: string) => {
|
||||||
setSelectedNode(nodeId)
|
setSelectedNode(nodeId)
|
||||||
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
onClose()
|
onClose()
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}, [fitView, setSelectedNode, onClose])
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
const handleSelectPending = useCallback((deviceId: string) => {
|
||||||
|
onOpenPending(deviceId)
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [onOpenPending, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search nodes by label, IP, hostname…"
|
placeholder="Search nodes, pending devices by IP or service…"
|
||||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||||
|
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{results.length > 0 && (
|
{totalResults > 0 && (
|
||||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
<ul className="py-1 max-h-72 overflow-y-auto">
|
||||||
{results.map((node) => (
|
{nodeResults.map((node) => (
|
||||||
<li
|
<li
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
onClick={() => handleSelect(node.id)}
|
onClick={() => handleSelectNode(node.id)}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||||
|
<li className="px-4 py-1">
|
||||||
|
<div className="h-px bg-border" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{pendingResults.map((device) => {
|
||||||
|
const serviceName = device.services.find((s) => s.service_name)?.service_name
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={device.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelectPending(device.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length > 0 && results.length === 0 && (
|
{q.length > 0 && totalResults === 0 && (
|
||||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{q.length === 0 && (
|
{q.length === 0 && (
|
||||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices…</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
useReactFlow: () => ({ fitView: mockFitView }),
|
useReactFlow: () => ({ fitView: mockFitView }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockOnOpenPending = vi.fn()
|
||||||
|
|
||||||
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders nothing when closed', () => {
|
it('renders nothing when closed', () => {
|
||||||
render(<SearchModal open={false} onClose={vi.fn()} />)
|
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders search input when open', () => {
|
it('renders search input when open', () => {
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows "Type to search" hint when query is empty', () => {
|
it('shows "Type to search" hint when query is empty', () => {
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
expect(screen.getByText(/type to search/i)).toBeDefined()
|
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows no results message when query has no matches', () => {
|
it('shows no results message when query has no matches', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters nodes by label', () => {
|
it('filters nodes by label', () => {
|
||||||
useCanvasStore.setState({
|
useCanvasStore.setState({
|
||||||
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||||
expect(screen.getByText('My Router')).toBeDefined()
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
expect(screen.queryByText('NAS Server')).toBeNull()
|
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||||
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
|
|||||||
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||||
expect(screen.getByText('Box A')).toBeDefined()
|
expect(screen.getByText('Box A')).toBeDefined()
|
||||||
expect(screen.queryByText('Box B')).toBeNull()
|
expect(screen.queryByText('Box B')).toBeNull()
|
||||||
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
|
|||||||
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||||
expect(screen.getByText('A')).toBeDefined()
|
expect(screen.getByText('A')).toBeDefined()
|
||||||
expect(screen.queryByText('B')).toBeNull()
|
expect(screen.queryByText('B')).toBeNull()
|
||||||
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
|
|||||||
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('limits results to 8 nodes', () => {
|
it('limits node results to 6', () => {
|
||||||
useCanvasStore.setState({
|
useCanvasStore.setState({
|
||||||
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||||
})
|
})
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||||
const items = screen.getAllByText(/Server \d/)
|
const items = screen.getAllByText(/Server \d/)
|
||||||
expect(items).toHaveLength(8)
|
expect(items).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('selects node and closes on result click', () => {
|
it('selects node and closes on result click', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||||
fireEvent.click(screen.getByText('Proxmox'))
|
fireEvent.click(screen.getByText('Proxmox'))
|
||||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||||
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
|
|||||||
it('selects first result and closes on Enter key', () => {
|
it('selects first result and closes on Enter key', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
const input = screen.getByPlaceholderText(/search nodes/i)
|
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||||
fireEvent.change(input, { target: { value: 'switch' } })
|
fireEvent.change(input, { target: { value: 'switch' } })
|
||||||
fireEvent.keyDown(input, { key: 'Enter' })
|
fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
|
|||||||
|
|
||||||
it('closes on Escape key', () => {
|
it('closes on Escape key', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes when clicking backdrop', () => {
|
it('closes when clicking backdrop', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
// The backdrop is the fixed inset div — clicking it fires onClose
|
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||||
fireEvent.click(backdrop)
|
fireEvent.click(backdrop)
|
||||||
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
|
|||||||
|
|
||||||
it('does not close when clicking inside the search box', () => {
|
it('does not close when clicking inside the search box', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<SearchModal open onClose={onClose} />)
|
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('search is case-insensitive', () => {
|
it('search is case-insensitive', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||||
render(<SearchModal open onClose={vi.fn()} />)
|
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||||
expect(screen.getByText('My NAS')).toBeDefined()
|
expect(screen.getByText('My NAS')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ interface SidebarProps {
|
|||||||
onScan: () => void
|
onScan: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
onNodeApproved: (nodeId: string) => void
|
||||||
|
forceView?: SidebarView
|
||||||
|
highlightPendingId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [_collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||||
|
|
||||||
|
// When forceView is set, override local state without useEffect
|
||||||
|
const collapsed = forceView ? false : _collapsed
|
||||||
|
const activeView = forceView ?? _activeView
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
@@ -92,7 +99,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />}
|
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
@@ -156,11 +163,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) {
|
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -190,6 +198,11 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
if (scanEventTs > 0) load()
|
if (scanEventTs > 0) load()
|
||||||
}, [scanEventTs, load])
|
}, [scanEventTs, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || loading) return
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, [highlightId, loading])
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
@@ -271,11 +284,13 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
|||||||
const virtualBadge = detectVirtualBadge(d.mac)
|
const virtualBadge = detectVirtualBadge(d.mac)
|
||||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
||||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||||
|
const isHighlighted = d.id === highlightId
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={d.id}
|
key={d.id}
|
||||||
|
ref={isHighlighted ? highlightRef : null}
|
||||||
onClick={() => setSelected(d)}
|
onClick={() => setSelected(d)}
|
||||||
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]"
|
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||||
|
|||||||
Reference in New Issue
Block a user