From 14597ab86f18e3d84536f3f920bdf47a709ae196 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 15 May 2026 21:12:10 +0200 Subject: [PATCH] feat(group): add 4 side handles for edge connections Group nodes can now be source/target of edges via snap points on each side (top/right/bottom/left). Each side exposes a source handle plus an invisible target overlay, matching the pattern used by BaseNode. --- .../canvas/__tests__/GroupNode.test.tsx | 2 + .../src/components/canvas/nodes/GroupNode.tsx | 29 ++++++- .../canvas/nodes/__tests__/GroupNode.test.tsx | 81 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/canvas/nodes/__tests__/GroupNode.test.tsx diff --git a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx index f99ffae..926e4c8 100644 --- a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx @@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({ NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
), + Handle: () => null, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useReactFlow: () => ({}), })) diff --git a/frontend/src/components/canvas/nodes/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx index e8396b6..0b3c058 100644 --- a/frontend/src/components/canvas/nodes/GroupNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -1,11 +1,15 @@ import { useState } from 'react' -import { type NodeProps, type Node, NodeResizer } from '@xyflow/react' +import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react' import { Layers, Pencil, Check, X } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' import { STATUS_COLORS, type NodeData } from '@/types' export function GroupNode({ id, data, selected }: NodeProps>) { const { nodes, updateNode, snapshotHistory } = useCanvasStore() + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] const showBorder = data.custom_colors?.show_border !== false const isVisible = showBorder || selected @@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps>) { handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }} /> + {/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */} + {([ + ['group-top', Position.Top], + ['group-right', Position.Right], + ['group-bottom', Position.Bottom], + ['group-left', Position.Left], + ] as const).map(([hid, pos]) => ( + + + + + ))} + {/* Header */} {isVisible && (
= {}, selected = false) { + const fullData: NodeData = { + label: 'Group A', + type: 'group', + status: 'unknown', + services: [], + ...data, + } + const props = { + id: 'g1', + data: fullData, + selected, + type: 'group', + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + deletable: true, + draggable: true, + selectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + width: 300, + height: 200, + dragHandle: undefined, + parentId: undefined, + sourcePosition: undefined, + targetPosition: undefined, + } as unknown as NodeProps> + return render( + + + + ) +} + +describe('GroupNode', () => { + beforeEach(() => { + useCanvasStore.setState({ nodes: [], hideIp: false }) + useThemeStore.setState({ activeTheme: 'default' }) + }) + + it('renders label', () => { + const { getByText } = renderNode({ label: 'My Group' }) + expect(getByText('My Group')).toBeDefined() + }) + + it('renders 4 source handles (one per side)', () => { + const { container } = renderNode() + expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull() + }) + + it('renders 4 target handles (one per side)', () => { + const { container } = renderNode() + expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull() + }) + + it('source handles carry side-specific ids', () => { + const { container } = renderNode() + expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull() + }) +})