diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx
index f3c97de..603d1fa 100644
--- a/frontend/src/components/__tests__/LiveView.test.tsx
+++ b/frontend/src/components/__tests__/LiveView.test.tsx
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
+ Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
+ Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx
index 332e983..caf9ac8 100644
--- a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx
+++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
+ Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
diff --git a/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx
new file mode 100644
index 0000000..f7db318
--- /dev/null
+++ b/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx
@@ -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 }) =>
,
+ 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 = {}): Node {
+ return {
+ id: 'zone1',
+ type: 'groupRect',
+ position: { x: 0, y: 0 },
+ data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
+ }
+}
+
+function renderZone(overrides: Partial = {}) {
+ const node = makeNode(overrides)
+ return render(
+
+ )
+}
+
+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()
+ })
+})
diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx
index 33709db..574145e 100644
--- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx
+++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx
@@ -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 type { NodeData, TextPosition } from '@/types'
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record = {
'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>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
+ const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff'
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps>)
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 (
<>
>)
}}
lineStyle={{ borderColor: 'transparent' }}
/>
+
+ {HANDLE_SIDES.map(({ id: hid, position }) => (
+
+
+
+
+ ))}
+
>)
boxSizing: 'border-box',
cursor: 'default',
}}
+ onMouseEnter={() => setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingGroupRectId(id)