fix(network): context menu on groups + group/ungroup in properties panel

Context menu fix:
- Group nodes pass pointer events through to children in React Flow, so
  right-clicking a group fires onPaneContextMenu instead of onNodeContextMenu
- handlePaneContextMenu now checks for selected nodes and shows the node
  context menu (with align/group options) when any nodes are selected

Properties panel multi-select:
- Add Group section with type dropdown (Subnet, VLAN, Site, DMZ, Custom)
- "Group into [Type]" button creates a group of the chosen type
- Ungroup button appears when a group node is in the selection
- useDiagramCommands.groupSelection now accepts a groupType param and
  uses it as the label and color key for the new group node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-14 00:55:34 +00:00
parent 684fb07e47
commit dfcad531e2
3 changed files with 72 additions and 7 deletions

View File

@@ -123,7 +123,7 @@ export function useDiagramCommands({
const canDistribute = selectedNodes.length >= 3
// ── Grouping ───────────────────────────────────────────────────────────
const groupSelection = useCallback(() => {
const groupSelection = useCallback((groupType: string = 'custom') => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const PADDING = 24
@@ -137,7 +137,7 @@ export function useDiagramCommands({
type: 'group',
position: { x: minX, y: minY },
style: { width: maxX - minX, height: maxY - minY },
data: { label: 'Group', groupType: 'custom' },
data: { label: groupType.charAt(0).toUpperCase() + groupType.slice(1), groupType },
selected: false,
}
setNodes(prev => [

View File

@@ -4,6 +4,7 @@ import {
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
BoxSelect, Ungroup,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types'
@@ -31,6 +32,10 @@ interface PropertiesPanelProps {
onDistributeV: () => void
canAlign: boolean
canDistribute: boolean
canGroup: boolean
canUngroup: boolean
onGroupSelection: (groupType: string) => void
onUngroupSelection: () => void
}
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
@@ -84,6 +89,14 @@ function SectionDivider({ label }: { label: string }) {
)
}
const GROUP_TYPES = [
{ value: 'subnet', label: 'Subnet' },
{ value: 'vlan', label: 'VLAN' },
{ value: 'site', label: 'Site' },
{ value: 'dmz', label: 'DMZ' },
{ value: 'custom', label: 'Custom' },
]
export function PropertiesPanel({
selectedNode,
selectedEdge,
@@ -105,8 +118,13 @@ export function PropertiesPanel({
onDistributeV,
canAlign,
canDistribute,
canGroup,
canUngroup,
onGroupSelection,
onUngroupSelection,
}: PropertiesPanelProps) {
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [pendingGroupType, setPendingGroupType] = useState('subnet')
// Reset confirm state whenever the selection changes
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -178,6 +196,38 @@ export function PropertiesPanel({
</div>
</div>
)}
{(canGroup || canUngroup) && (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-medium text-muted uppercase tracking-wider">Grouping</div>
{canGroup && (
<div className="flex flex-col gap-1.5">
<select
value={pendingGroupType}
onChange={e => setPendingGroupType(e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{GROUP_TYPES.map(gt => (
<option key={gt.value} value={gt.value}>{gt.label}</option>
))}
</select>
<button
onClick={() => onGroupSelection(pendingGroupType)}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<BoxSelect size={13} /> Group into {GROUP_TYPES.find(g => g.value === pendingGroupType)?.label}
</button>
</div>
)}
{canUngroup && (
<button
onClick={onUngroupSelection}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<Ungroup size={13} /> Ungroup
</button>
)}
</div>
)}
</div>
</div>
)

View File

@@ -399,11 +399,22 @@ function DiagramEditorInner() {
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
event.preventDefault()
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}, [])
// Group nodes pass pointer events through to children, so right-clicking a group
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
// show the node context menu so group/align/ungroup options are accessible.
const selected = getNodes().filter(n => n.selected)
if (selected.length > 0) {
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
})
} else {
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}
}, [getNodes])
const closeContextMenu = useCallback(() => {
setContextMenu(null)
@@ -735,6 +746,10 @@ function DiagramEditorInner() {
onDistributeV={diagramCommands.distributeVertically}
canAlign={diagramCommands.canAlign}
canDistribute={diagramCommands.canDistribute}
canGroup={diagramCommands.canGroup}
canUngroup={diagramCommands.canUngroup}
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
/>
</div>
{contextMenu && (