Files
homelable/frontend/src/components/panels/__tests__/GroupPanels.test.tsx
T
Pouzor 0b89244317 feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default)
- Add lasso/pan toggle button in canvas controls (bottom-left)
- Multi-select panel in right panel when 2+ nodes selected (including zones)
- Create named Group node from selected nodes with bounding box math
- Group node: resizable, inline rename, show/hide border toggle, status summary
- GroupDetailPanel: lists members, online/offline count, ungroup action
- Fix group persistence after save/reload (extend proxmox container map to include group nodes)
- Fix groupRect serialization to preserve parent_id
- Remove background color from group and proxmox container node wrappers
- Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
2026-04-01 23:15:52 +02:00

234 lines
8.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null }))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [] },
...overrides,
}
}
function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
return {
id,
type: 'group',
position: { x: 76, y: 52 },
data: {
label,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: showBorder },
},
}
}
const mockStore = {
nodes: [],
selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
}
function setupStore(overrides = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
...mockStore,
...overrides,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function renderPanel() {
return render(
<TooltipProvider>
<DetailPanel onEdit={vi.fn()} />
</TooltipProvider>,
)
}
describe('MultiSelectPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders multi-select panel when 2+ nodes selected', () => {
const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } })
setupStore({
nodes: [n1, n2],
selectedNodeId: null,
selectedNodeIds: ['n1', 'n2'],
})
renderPanel()
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
it('lists selected node labels in multi-select panel', () => {
const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows Create Group button', () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByRole('button', { name: /create group/i })).toBeDefined()
})
it('shows name input when Create Group is clicked', async () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await waitFor(() => {
expect(screen.getByPlaceholderText(/group name/i)).toBeDefined()
})
})
it('calls createGroup with selected ids and entered name', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
const input = await screen.findByPlaceholderText(/group name/i)
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ')
})
it('uses default name "Group" when input is empty', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await screen.findByPlaceholderText(/group name/i)
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group')
})
it('includes groupRect (zone) nodes in multi-select count', () => {
const n1 = makeNode('n1')
const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] })
renderPanel()
// groupRect included → 2 nodes selected → multi-select panel shown
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
})
describe('GroupDetailPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders group name and members heading', () => {
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Group')).toBeDefined()
expect(screen.getByText('Members')).toBeDefined()
})
it('lists children with their labels', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows online/offline count in status summary', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/1 online/)).toBeDefined()
expect(screen.getByText(/1 offline/)).toBeDefined()
})
it('shows Ungroup button', () => {
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined()
})
it('calls ungroup after confirm', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).toHaveBeenCalledWith('g1')
})
it('does not call ungroup when confirm is cancelled', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(false)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).not.toHaveBeenCalled()
})
it('shows "Hide border & title" when show_border is true', () => {
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/hide border/i)).toBeDefined()
})
it('shows "Show border & title" when show_border is false', () => {
const group = makeGroupNode('g1', 'G', false)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/show border/i)).toBeDefined()
})
it('calls updateNode to toggle show_border off', () => {
const updateNode = vi.fn()
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.click(screen.getByText(/hide border/i))
expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({
custom_colors: expect.objectContaining({ show_border: false }),
}))
})
it('calls setSelectedNode when a child node is clicked', () => {
const setSelectedNode = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode })
renderPanel()
fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1')
})
})