fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize
- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so connections draw direct diagonal lines instead of orthogonal bent paths - Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes align consistently when dragged - ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into the Cloud category loop with search support and correct item count - Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ), handles visible when selected; dimensions saved/restored correctly on reload (also fixes group node load bug where type was always 'device') - DiagramNode type: add nodeType and style optional fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-04] Network Diagram Editor UX Improvements
|
||||
|
||||
### Added
|
||||
- Snap-to-grid (20px) on Network Diagram canvas — nodes align consistently when dragged
|
||||
- NodeResizer on group nodes (subnet/VLAN/site/DMZ) — select a group and drag its handles to resize
|
||||
- Group node dimensions now saved to and restored from the backend on reload
|
||||
|
||||
### Fixed
|
||||
- Connection edges now render as straight lines instead of orthogonal bent paths
|
||||
- ISP device now appears inside the Cloud category in the sidebar instead of a standalone "Internet" section; respects search and item count
|
||||
- Group nodes now restore correctly as `type: 'group'` on diagram load (previously loaded as `type: 'device'`, breaking group display after save)
|
||||
|
||||
---
|
||||
|
||||
### Added
|
||||
- Tree Templates + Import/Export marketplace (#66)
|
||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||
|
||||
@@ -85,6 +85,8 @@ export function NetworkCanvas({
|
||||
defaultEdgeOptions={{ type: 'connection' }}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
multiSelectionKeyCode="Shift"
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
fitView
|
||||
className="bg-page"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react'
|
||||
import { BaseEdge, EdgeLabelRenderer, getStraightPath, type EdgeProps } from '@xyflow/react'
|
||||
|
||||
interface ConnectionEdgeData {
|
||||
connectionType?: string
|
||||
@@ -24,10 +24,17 @@ function ConnectionEdgeComponent(props: EdgeProps) {
|
||||
const connectionType = edgeData?.connectionType || 'ethernet'
|
||||
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
|
||||
|
||||
const [edgePath, labelX, labelY] = getStraightPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<SmoothStepEdge
|
||||
{...props}
|
||||
<BaseEdge
|
||||
path={edgePath}
|
||||
style={{
|
||||
...style,
|
||||
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
|
||||
@@ -39,7 +46,7 @@ function ConnectionEdgeComponent(props: EdgeProps) {
|
||||
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${(props.sourceX + props.targetX) / 2}px, ${(props.sourceY + props.targetY) / 2}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -84,9 +84,13 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{CATEGORY_ORDER.map(cat => {
|
||||
const items = filteredByCategory[cat]
|
||||
if (!items?.length) return null
|
||||
const items = filteredByCategory[cat] || []
|
||||
const isCloud = cat === 'cloud'
|
||||
const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase())
|
||||
const showIsp = isCloud && ispMatchesSearch
|
||||
if (!items.length && !showIsp) return null
|
||||
const collapsed = collapsedCategories.has(cat)
|
||||
const totalCount = items.length + (showIsp ? 1 : 0)
|
||||
|
||||
return (
|
||||
<div key={cat} className="mb-1">
|
||||
@@ -96,7 +100,7 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
>
|
||||
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
{CATEGORY_LABELS[cat] || cat}
|
||||
<span className="ml-auto text-[10px] font-normal">{items.length}</span>
|
||||
<span className="ml-auto text-[10px] font-normal">{totalCount}</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -115,6 +119,24 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{showIsp && (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
|
||||
slug: 'isp',
|
||||
label: 'ISP',
|
||||
category: 'cloud',
|
||||
}))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
|
||||
<span>ISP</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -122,31 +144,6 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Internet section */}
|
||||
<div className="mb-1 mt-2 border-t border-default pt-2">
|
||||
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Internet
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
|
||||
slug: 'isp',
|
||||
label: 'ISP',
|
||||
category: 'cloud',
|
||||
}))
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||
>
|
||||
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
|
||||
<span>ISP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouping section */}
|
||||
<div className="mb-1 mt-2 border-t border-default pt-2">
|
||||
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode, ComponentProps } from 'react'
|
||||
import { Panel, type NodeProps, type PanelPosition } from '@xyflow/react'
|
||||
import { Panel, NodeResizer, type NodeProps, type PanelPosition } from '@xyflow/react'
|
||||
import { BaseNode } from './base-node'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -43,17 +43,26 @@ export function GroupNode({ data, selected }: NodeProps) {
|
||||
const label = nodeData.label || 'Group'
|
||||
|
||||
return (
|
||||
<BaseNode
|
||||
className={cn(
|
||||
'h-full w-full min-h-[200px] min-w-[300px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
||||
selected && 'border-accent',
|
||||
)}
|
||||
>
|
||||
<Panel className="m-0 p-0" position="top-left">
|
||||
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
||||
{label}
|
||||
</GroupNodeLabel>
|
||||
</Panel>
|
||||
</BaseNode>
|
||||
<>
|
||||
<NodeResizer
|
||||
isVisible={selected}
|
||||
minWidth={150}
|
||||
minHeight={100}
|
||||
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||
/>
|
||||
<BaseNode
|
||||
className={cn(
|
||||
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
||||
selected && 'border-accent',
|
||||
)}
|
||||
>
|
||||
<Panel className="m-0 p-0" position="top-left">
|
||||
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
||||
{label}
|
||||
</GroupNodeLabel>
|
||||
</Panel>
|
||||
</BaseNode>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,16 +115,30 @@ function DiagramEditorInner() {
|
||||
setAssetName(diagram.asset_name)
|
||||
setDescription(diagram.description)
|
||||
setNodes(
|
||||
diagram.nodes.map(n => ({
|
||||
id: n.id,
|
||||
type: 'device',
|
||||
position: n.position,
|
||||
data: {
|
||||
label: n.label,
|
||||
deviceType: n.type,
|
||||
properties: n.properties,
|
||||
} satisfies DeviceNodeData,
|
||||
}))
|
||||
diagram.nodes.map(n => {
|
||||
if (n.nodeType === 'group') {
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'group',
|
||||
position: n.position,
|
||||
style: n.style || { width: 300, height: 200 },
|
||||
data: {
|
||||
label: n.label,
|
||||
groupType: n.type,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'device',
|
||||
position: n.position,
|
||||
data: {
|
||||
label: n.label,
|
||||
deviceType: n.type,
|
||||
properties: n.properties,
|
||||
} satisfies DeviceNodeData,
|
||||
}
|
||||
})
|
||||
)
|
||||
setEdges(
|
||||
diagram.edges.map(e => ({
|
||||
@@ -155,6 +169,8 @@ function DiagramEditorInner() {
|
||||
return getNodes().map(n => {
|
||||
if (n.type === 'group') {
|
||||
const data = n.data as Record<string, unknown>
|
||||
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
|
||||
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
|
||||
return {
|
||||
id: n.id,
|
||||
type: (data.groupType as string) || 'subnet',
|
||||
@@ -162,7 +178,7 @@ function DiagramEditorInner() {
|
||||
position: n.position,
|
||||
properties: {} as DeviceProperties,
|
||||
nodeType: 'group',
|
||||
style: n.style || null,
|
||||
style: { width, height },
|
||||
}
|
||||
}
|
||||
const data = n.data as unknown as DeviceNodeData
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface DiagramNode {
|
||||
label: string
|
||||
position: { x: number; y: number }
|
||||
properties: DeviceProperties
|
||||
nodeType?: string
|
||||
style?: { width?: number; height?: number } | null
|
||||
}
|
||||
|
||||
export interface DiagramEdge {
|
||||
|
||||
Reference in New Issue
Block a user