feat: add connection handles to zone nodes (closes #58)
- GroupRectNode now renders source+target handles on all four sides
(top, right, bottom, left) using IDs zone-{side} / zone-{side}-t
- Handles are hover-only: opacity 0 by default, fade in on mouse enter
- Handle color matches the zone border color (respects custom_colors)
- Zone↔zone and zone↔node connections both allowed; edge type picker
(EdgeModal) opens on connect so user chooses ethernet/wifi/vlan/etc.
- Add GroupRectNode.test.tsx: verifies 8 handles rendered (4 source + 4 target)
- Fix @xyflow/react mocks in LiveView and CanvasContainer tests to include Position
This commit is contained in:
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { GroupRectNode } from '../nodes/GroupRectNode'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
NodeResizer: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
|
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
|
||||||
|
sel({ setEditingGroupRectId: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'zone1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderZone(overrides: Partial<NodeData> = {}) {
|
||||||
|
const node = makeNode(overrides)
|
||||||
|
return render(
|
||||||
|
<GroupRectNode
|
||||||
|
id={node.id}
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
type="groupRect"
|
||||||
|
dragging={false}
|
||||||
|
zIndex={0}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupRectNode — handles', () => {
|
||||||
|
it('renders source handles on all four sides', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders target handles on all four sides', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
|
||||||
|
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders 8 handles total (4 source + 4 target)', () => {
|
||||||
|
renderZone()
|
||||||
|
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GroupRectNode — label', () => {
|
||||||
|
it('renders inside label by default', () => {
|
||||||
|
renderZone({ label: 'DMZ' })
|
||||||
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label when label is empty', () => {
|
||||||
|
renderZone({ label: '' })
|
||||||
|
expect(screen.queryByText('DMZ')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { useState } from 'react'
|
||||||
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import type { NodeData, TextPosition } from '@/types'
|
import type { NodeData, TextPosition } from '@/types'
|
||||||
|
|
||||||
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
|||||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HANDLE_SIDES = [
|
||||||
|
{ id: 'zone-top', position: Position.Top },
|
||||||
|
{ id: 'zone-right', position: Position.Right },
|
||||||
|
{ id: 'zone-bottom', position: Position.Bottom },
|
||||||
|
{ id: 'zone-left', position: Position.Left },
|
||||||
|
] as const
|
||||||
|
|
||||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStyle: React.CSSProperties = {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
background: borderColor,
|
||||||
|
border: '2px solid #0d1117',
|
||||||
|
borderRadius: '50%',
|
||||||
|
opacity: hovered ? 1 : 0,
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
}}
|
}}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{HANDLE_SIDES.map(({ id: hid, position }) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle type="source" id={hid} position={position} style={handleStyle} />
|
||||||
|
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingGroupRectId(id)
|
setEditingGroupRectId(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user