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.
This commit is contained in:
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||
),
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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<Node<NodeData>>) {
|
||||
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<Node<NodeData>>) {
|
||||
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]) => (
|
||||
<span key={hid}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={pos}
|
||||
id={hid}
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={pos}
|
||||
id={`${hid}-t`}
|
||||
style={{ opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
{isVisible && (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { GroupNode } from '../GroupNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}, 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<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<GroupNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user