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:
Pouzor
2026-04-05 02:40:12 +02:00
parent 08a3a689c5
commit 9cb9d02459
6 changed files with 203 additions and 49 deletions
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
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> {
return {
id,
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
})
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()
})
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()
})
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()
})
it('shows no results message when query has no matches', () => {
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' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
expect(screen.getByText(/no results match/i)).toBeDefined()
})
it('filters nodes by label', () => {
useCanvasStore.setState({
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' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
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' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
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' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
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' } })
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({
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' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8)
expect(items).toHaveLength(6)
})
it('selects node and closes on result click', () => {
const onClose = vi.fn()
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.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
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)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => {
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' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
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
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
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' } })
expect(screen.getByText('My NAS')).toBeDefined()
})