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:
@@ -1,14 +1,17 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
ControlButton,
|
||||
BackgroundVariant,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
} from '@xyflow/react'
|
||||
import { MousePointer2, Hand } from 'lucide-react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -24,6 +27,7 @@ interface CanvasContainerProps {
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
@@ -33,8 +37,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||
setSelectedNode(node.id)
|
||||
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedNode(null)
|
||||
} else {
|
||||
setSelectedNode(node.id)
|
||||
}
|
||||
}, [setSelectedNode])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -61,6 +69,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||
selectionOnDrag={lassoMode}
|
||||
panOnDrag={lassoMode ? [1, 2] : true}
|
||||
panActivationKeyCode="Space"
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
@@ -75,7 +88,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<Controls />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
onClick={() => setLassoMode((m) => !m)}
|
||||
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
|
||||
>
|
||||
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
|
||||
</ControlButton>
|
||||
</Controls>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react'
|
||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||
|
||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||
const showBorder = data.custom_colors?.show_border !== false
|
||||
const isVisible = showBorder || selected
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [labelDraft, setLabelDraft] = useState(data.label)
|
||||
|
||||
const children = nodes.filter((n) => n.parentId === id)
|
||||
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||
const unknownCount = children.length - onlineCount - offlineCount
|
||||
|
||||
const handleRename = () => {
|
||||
if (labelDraft.trim()) {
|
||||
snapshotHistory()
|
||||
updateNode(id, { label: labelDraft.trim() })
|
||||
}
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const borderColor = selected ? '#00d4ff' : '#30363d'
|
||||
const borderStyle = selected ? 'solid' : 'dashed'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
borderRadius: 8,
|
||||
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
|
||||
background: 'transparent',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={120}
|
||||
minHeight={80}
|
||||
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
|
||||
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
{isVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: '5px 10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
|
||||
borderRadius: '6px 6px 0 0',
|
||||
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
className="nodrag"
|
||||
>
|
||||
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={labelDraft}
|
||||
onChange={(e) => setLabelDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename()
|
||||
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#e6edf3',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{data.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||
title="Rename group"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Status summary */}
|
||||
{children.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
||||
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount}</span>}
|
||||
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount}</span>}
|
||||
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}>● {unknownCount}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
@@ -20,4 +21,5 @@ export const nodeTypes = {
|
||||
docker: DockerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user