From 5ad5eba58c982c7b379fa2965db5a8b6da62e034 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 12:08:41 +0200 Subject: [PATCH] feat: add connection handles to zone nodes (closes #58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../components/__tests__/LiveView.test.tsx | 2 + .../canvas/__tests__/CanvasContainer.test.tsx | 1 + .../canvas/__tests__/GroupRectNode.test.tsx | 77 +++++++++++++++++++ .../components/canvas/nodes/GroupRectNode.tsx | 31 +++++++- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx 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)