feat(pending): full-screen modal with grid cards, filters, bulk restore
Replaces sidebar pending/hidden panels with a wide modal showing devices as cards. Adds search, segmented source/status filters, type filter, select mode for bulk approve/hide/restore, and keyboard shortcuts. Hidden cards click-to-restore (no approval detour); approval no longer pops the edit modal. Backend: new restore + bulk-restore endpoints (hidden -> pending).
This commit is contained in:
@@ -11,22 +11,11 @@ import type { NodeData } from '@/types'
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/authStore')
|
||||
|
||||
const mockBulkApprove = vi.fn()
|
||||
const mockBulkHide = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
stop: vi.fn().mockResolvedValue({}),
|
||||
clearPending: vi.fn().mockResolvedValue({}),
|
||||
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
|
||||
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
|
||||
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
|
||||
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
|
||||
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
|
||||
},
|
||||
settingsApi: {
|
||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||
@@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
||||
TooltipContent: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||
PendingDeviceModal: () => null,
|
||||
}))
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||
@@ -86,8 +71,9 @@ const defaultProps = {
|
||||
onAddNode: vi.fn(),
|
||||
onAddGroupRect: vi.fn(),
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onNodeApproved: vi.fn(),
|
||||
onOpenPending: vi.fn(),
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
@@ -129,26 +115,22 @@ describe('Sidebar', () => {
|
||||
],
|
||||
})
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// Total (excludes groupRect)
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
// Online
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
// Offline
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes groupRect nodes from stats', () => {
|
||||
mockStore({
|
||||
nodes: [
|
||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
||||
makeNode('n1', 'unknown'),
|
||||
makeNode('zone', 'unknown', 'groupRect'),
|
||||
],
|
||||
})
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
||||
const totalRow = screen.getByText('Total').closest('div')!
|
||||
expect(totalRow).toHaveTextContent('1')
|
||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
||||
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||
})
|
||||
|
||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||
@@ -225,7 +207,6 @@ describe('Sidebar', () => {
|
||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||
mockStore({ hasUnsavedChanges: true })
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
// The badge is a span sibling of the Save Canvas button icon
|
||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||
const badge = saveBtn.querySelector('span.rounded-full')
|
||||
expect(badge).toBeInTheDocument()
|
||||
@@ -241,24 +222,24 @@ describe('Sidebar', () => {
|
||||
|
||||
// ── Scan action ────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||
it('calls onScan prop when Scan Network is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Scan Network'))
|
||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||
|
||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Pending Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||
})
|
||||
|
||||
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
||||
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||
})
|
||||
|
||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||
@@ -267,16 +248,13 @@ describe('Sidebar', () => {
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// Regression: forceView used to override local state on every render, freezing
|
||||
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
||||
// Regression: forceView must not freeze local state across rerenders.
|
||||
it('allows switching views after forceView is set by parent', async () => {
|
||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
// Parent keeps forceView as 'history'; user clicks another nav item.
|
||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||
fireEvent.click(screen.getByText('Pending Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Canvas'))
|
||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('toggles Settings panel on Settings click', async () => {
|
||||
@@ -285,7 +263,6 @@ describe('Sidebar', () => {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||
)
|
||||
// Click the nav button again to close (use role to avoid matching the panel heading)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -303,101 +280,3 @@ describe('Sidebar', () => {
|
||||
expect(mockLogout).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
||||
|
||||
const DEVICE_A = {
|
||||
id: 'dev-a',
|
||||
ip: '192.168.1.10',
|
||||
hostname: 'host-a',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'generic',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
}
|
||||
|
||||
const DEVICE_B = {
|
||||
id: 'dev-b',
|
||||
ip: '192.168.1.11',
|
||||
hostname: 'host-b',
|
||||
mac: null,
|
||||
os: null,
|
||||
services: [],
|
||||
suggested_type: 'generic',
|
||||
status: 'pending',
|
||||
discovery_source: 'arp',
|
||||
}
|
||||
|
||||
describe('PendingDevicesPanel — bulk select', () => {
|
||||
beforeEach(() => {
|
||||
mockStore()
|
||||
mockAuth()
|
||||
vi.clearAllMocks()
|
||||
mockBulkApprove.mockResolvedValue({
|
||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
||||
})
|
||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||
})
|
||||
|
||||
async function renderWithDevices() {
|
||||
const { scanApi } = await import('@/api/client')
|
||||
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
|
||||
render(<Sidebar {...defaultProps} forceView="pending" />)
|
||||
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
|
||||
}
|
||||
|
||||
it('renders checkboxes for each device', async () => {
|
||||
await renderWithDevices()
|
||||
const checkboxes = screen.getAllByRole('checkbox')
|
||||
// select-all + 2 device checkboxes
|
||||
expect(checkboxes.length).toBe(3)
|
||||
})
|
||||
|
||||
it('shows bulk action bar when a device is checked', async () => {
|
||||
await renderWithDevices()
|
||||
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(firstDeviceCheckbox)
|
||||
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
|
||||
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides bulk action bar when no device is checked', async () => {
|
||||
await renderWithDevices()
|
||||
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('select-all checks all devices', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('select-all unchecks all when all are selected', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll) // select all
|
||||
fireEvent.click(selectAll) // deselect all
|
||||
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('calls bulkApprove with checked ids and removes devices from list', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
fireEvent.click(screen.getByText(/Approve \(2\)/))
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('calls bulkHide with checked ids and removes devices from list', async () => {
|
||||
await renderWithDevices()
|
||||
const [selectAll] = screen.getAllByRole('checkbox')
|
||||
fireEvent.click(selectAll)
|
||||
fireEvent.click(screen.getByText(/Hide \(2\)/))
|
||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user