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
This commit is contained in:
@@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
ControlButton: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
@@ -151,6 +153,55 @@ describe('CanvasContainer', () => {
|
||||
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
|
||||
})
|
||||
|
||||
// ── Lasso / multi-select ──────────────────────────────────────────────────
|
||||
|
||||
it('enables selectionOnDrag for lasso selection', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.selectionOnDrag).toBe(true)
|
||||
})
|
||||
|
||||
it('sets panActivationKeyCode to Space', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.panActivationKeyCode).toBe('Space')
|
||||
})
|
||||
|
||||
it('sets panOnDrag to [1, 2]', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.panOnDrag).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('sets selectionMode to Partial', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.selectionMode).toBe('partial')
|
||||
})
|
||||
|
||||
it('sets multiSelectionKeyCode to Meta and Control', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
|
||||
})
|
||||
|
||||
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
|
||||
const node = makeNode('n1')
|
||||
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
|
||||
node,
|
||||
)
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('clears selectedNode (sets null) on Cmd+click', () => {
|
||||
const node = makeNode('n1')
|
||||
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
|
||||
node,
|
||||
)
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
|
||||
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
|
||||
|
||||
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { GroupNode } from '../nodes/GroupNode'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||
),
|
||||
useReactFlow: () => ({}),
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
|
||||
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
id: 'g1',
|
||||
type: 'group',
|
||||
position: { x: 0, y: 0 },
|
||||
width: 400,
|
||||
height: 250,
|
||||
data: {
|
||||
label: 'My Group',
|
||||
type: 'group',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { show_border: true },
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
|
||||
const node = makeGroupNode(props.data)
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: storeNodes,
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
return render(
|
||||
<GroupNode
|
||||
id="g1"
|
||||
data={node.data}
|
||||
selected={false}
|
||||
dragging={false}
|
||||
zIndex={1}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('GroupNode', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the group label when show_border is true', () => {
|
||||
renderGroupNode()
|
||||
expect(screen.getByText('My Group')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides the header when show_border is false and not selected', () => {
|
||||
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
|
||||
expect(screen.queryByText('My Group')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows header when show_border is false but node is selected', () => {
|
||||
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
|
||||
expect(screen.getByText('My Group')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows NodeResizer only when selected', () => {
|
||||
const { rerender } = renderGroupNode({ selected: false })
|
||||
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
|
||||
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
rerender(
|
||||
<GroupNode
|
||||
id="g1"
|
||||
data={makeGroupNode().data}
|
||||
selected={true}
|
||||
dragging={false}
|
||||
zIndex={1}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows online/offline status summary from children', () => {
|
||||
const storeNodes = [
|
||||
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
|
||||
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
|
||||
]
|
||||
|
||||
renderGroupNode({}, storeNodes)
|
||||
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
|
||||
const statusSpans = screen.getAllByText(/● \d+/)
|
||||
expect(statusSpans).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not show status summary when group has no children', () => {
|
||||
renderGroupNode()
|
||||
expect(screen.queryByText(/●/)).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user