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:
@@ -123,7 +123,7 @@ export function useDiagramCommands({
|
|||||||
const canDistribute = selectedNodes.length >= 3
|
const canDistribute = selectedNodes.length >= 3
|
||||||
|
|
||||||
// ── Grouping ───────────────────────────────────────────────────────────
|
// ── Grouping ───────────────────────────────────────────────────────────
|
||||||
const groupSelection = useCallback(() => {
|
const groupSelection = useCallback((groupType: string = 'custom') => {
|
||||||
if (selectedNodes.length < 2) return
|
if (selectedNodes.length < 2) return
|
||||||
pushHistory(nodes, edges)
|
pushHistory(nodes, edges)
|
||||||
const PADDING = 24
|
const PADDING = 24
|
||||||
@@ -137,7 +137,7 @@ export function useDiagramCommands({
|
|||||||
type: 'group',
|
type: 'group',
|
||||||
position: { x: minX, y: minY },
|
position: { x: minX, y: minY },
|
||||||
style: { width: maxX - minX, height: maxY - 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,
|
selected: false,
|
||||||
}
|
}
|
||||||
setNodes(prev => [
|
setNodes(prev => [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
|
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
|
||||||
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
|
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
|
||||||
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
|
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
|
||||||
|
BoxSelect, Ungroup,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||||
@@ -31,6 +32,10 @@ interface PropertiesPanelProps {
|
|||||||
onDistributeV: () => void
|
onDistributeV: () => void
|
||||||
canAlign: boolean
|
canAlign: boolean
|
||||||
canDistribute: boolean
|
canDistribute: boolean
|
||||||
|
canGroup: boolean
|
||||||
|
canUngroup: boolean
|
||||||
|
onGroupSelection: (groupType: string) => void
|
||||||
|
onUngroupSelection: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
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({
|
export function PropertiesPanel({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
selectedEdge,
|
selectedEdge,
|
||||||
@@ -105,8 +118,13 @@ export function PropertiesPanel({
|
|||||||
onDistributeV,
|
onDistributeV,
|
||||||
canAlign,
|
canAlign,
|
||||||
canDistribute,
|
canDistribute,
|
||||||
|
canGroup,
|
||||||
|
canUngroup,
|
||||||
|
onGroupSelection,
|
||||||
|
onUngroupSelection,
|
||||||
}: PropertiesPanelProps) {
|
}: PropertiesPanelProps) {
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
|
const [pendingGroupType, setPendingGroupType] = useState('subnet')
|
||||||
|
|
||||||
// Reset confirm state whenever the selection changes
|
// Reset confirm state whenever the selection changes
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
@@ -178,6 +196,38 @@ export function PropertiesPanel({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -399,11 +399,22 @@ function DiagramEditorInner() {
|
|||||||
|
|
||||||
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setContextMenu({
|
// Group nodes pass pointer events through to children, so right-clicking a group
|
||||||
type: 'canvas',
|
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
|
||||||
position: { x: event.clientX, y: event.clientY },
|
// 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(() => {
|
const closeContextMenu = useCallback(() => {
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
@@ -735,6 +746,10 @@ function DiagramEditorInner() {
|
|||||||
onDistributeV={diagramCommands.distributeVertically}
|
onDistributeV={diagramCommands.distributeVertically}
|
||||||
canAlign={diagramCommands.canAlign}
|
canAlign={diagramCommands.canAlign}
|
||||||
canDistribute={diagramCommands.canDistribute}
|
canDistribute={diagramCommands.canDistribute}
|
||||||
|
canGroup={diagramCommands.canGroup}
|
||||||
|
canUngroup={diagramCommands.canUngroup}
|
||||||
|
onGroupSelection={diagramCommands.groupSelection}
|
||||||
|
onUngroupSelection={diagramCommands.ungroupSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
|
|||||||
Reference in New Issue
Block a user