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:
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user