feat: arrowhead endpoints for edges + fix parallel edges not rendering
Add optional filled-triangle arrowheads at either end of an edge, independently toggleable per edge (EdgeModal) and as per-edge-type defaults (CustomStyleModal). Arrowheads are custom inline <marker> defs filled with the live stroke colour so they recolour reactively with custom_color / vlan / selected state. Persisted frontend (serializer) and backend (edge columns + schemas + runtime migration). Also fix two dedupe layers that silently dropped legitimate parallel links between the same two devices: - store: React Flow addEdge() connectionExists dropped a second edge with matching source+target when handles were null/equal. Build the edge with a unique id and append directly. - render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even when nothing was collapsed, filtering real parallel edges out of the visible set. Restrict the anti-mesh dedupe to rewired collapse stubs. Tests: marker render, per-edge/per-type UI, store apply, serializer round-trip, backend edge/canvas persistence, parallel-edge regressions. ha-relevant: yes
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||
import { HomelableEdge } from '../index'
|
||||
import type { EdgeData } from '@/types'
|
||||
|
||||
/**
|
||||
* Arrowhead endpoints: filled-triangle <marker> defs, independently toggleable
|
||||
* at start/end, filled with the live stroke color, and referenced by BaseEdge
|
||||
* via markerStart/markerEnd URLs.
|
||||
*/
|
||||
function renderEdge(data: Partial<EdgeData> = {}, selected = false) {
|
||||
const props = {
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
sourcePosition: 'bottom',
|
||||
targetPosition: 'top',
|
||||
data: { type: 'ethernet', ...data } as EdgeData,
|
||||
selected,
|
||||
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<svg>
|
||||
<HomelableEdge {...props} />
|
||||
</svg>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomelableEdge arrow markers', () => {
|
||||
it('renders no marker defs by default', () => {
|
||||
const { container } = renderEdge()
|
||||
expect(container.querySelector('marker')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders an end marker referenced by the edge path', () => {
|
||||
const { container } = renderEdge({ marker_end: true })
|
||||
const marker = container.querySelector('#arrow-end-e1')
|
||||
expect(marker).toBeTruthy()
|
||||
expect(container.querySelector('#arrow-start-e1')).toBeNull()
|
||||
const referenced = Array.from(container.querySelectorAll('path')).some(
|
||||
(p) => p.getAttribute('marker-end') === 'url(#arrow-end-e1)',
|
||||
)
|
||||
expect(referenced).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a start marker with reversed orientation', () => {
|
||||
const { container } = renderEdge({ marker_start: true })
|
||||
const marker = container.querySelector('#arrow-start-e1')
|
||||
expect(marker).toBeTruthy()
|
||||
expect(marker?.getAttribute('orient')).toBe('auto-start-reverse')
|
||||
})
|
||||
|
||||
it('renders both markers when both ends enabled', () => {
|
||||
const { container } = renderEdge({ marker_start: true, marker_end: true })
|
||||
expect(container.querySelector('#arrow-start-e1')).toBeTruthy()
|
||||
expect(container.querySelector('#arrow-end-e1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fills the marker with the resolved custom color', () => {
|
||||
const { container } = renderEdge({ marker_end: true, custom_color: '#ff6e00' })
|
||||
const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')
|
||||
expect(fill).toBe('#ff6e00')
|
||||
})
|
||||
})
|
||||
@@ -351,9 +351,48 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||
: []
|
||||
|
||||
// ── Arrowheads ─────────────────────────────────────────────────────────────
|
||||
// Custom inline <marker> defs filled with the live strokeColor so they recolor
|
||||
// reactively (custom_color / vlan / selected). Sized from the stroke width.
|
||||
const markerStart = data?.marker_start === true
|
||||
const markerEnd = data?.marker_end === true
|
||||
const strokeW = (style.strokeWidth as number) ?? 2
|
||||
const markerSize = 6 + strokeW * 2
|
||||
const startMarkerId = `arrow-start-${id}`
|
||||
const endMarkerId = `arrow-end-${id}`
|
||||
|
||||
const arrowMarker = (markerId: string, orient: string) => (
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 10 10"
|
||||
refX={9}
|
||||
refY={5}
|
||||
markerWidth={markerSize}
|
||||
markerHeight={markerSize}
|
||||
markerUnits="userSpaceOnUse"
|
||||
orient={orient}
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={strokeColor} />
|
||||
</marker>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
||||
{(markerStart || markerEnd) && (
|
||||
<defs>
|
||||
{markerStart && arrowMarker(startMarkerId, 'auto-start-reverse')}
|
||||
{markerEnd && arrowMarker(endMarkerId, 'auto')}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
|
||||
interactionWidth={16}
|
||||
markerStart={markerStart ? `url(#${startMarkerId})` : undefined}
|
||||
markerEnd={markerEnd ? `url(#${endMarkerId})` : undefined}
|
||||
/>
|
||||
|
||||
{animMode === 'basic' && (
|
||||
<path
|
||||
|
||||
@@ -64,6 +64,8 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
||||
opacity: 1,
|
||||
pathStyle: 'bezier',
|
||||
animated: 'none',
|
||||
arrowStart: false,
|
||||
arrowEnd: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +281,28 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
|
||||
<option value="snake">Snake</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-[#8b949e] mb-2">Arrows</div>
|
||||
<div className="flex gap-2">
|
||||
{([['Start', 'arrowStart'], ['End', 'arrowEnd']] as [string, 'arrowStart' | 'arrowEnd'][]).map(([label, key]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => set(key, !style[key])}
|
||||
aria-pressed={style[key]}
|
||||
className="px-3 py-1 text-xs rounded border transition-colors"
|
||||
style={{
|
||||
borderColor: style[key] ? '#00d4ff' : '#30363d',
|
||||
background: style[key] ? '#00d4ff22' : 'transparent',
|
||||
color: style[key] ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -38,6 +38,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||
const [markerStart, setMarkerStart] = useState<boolean>(initial?.marker_start ?? false)
|
||||
const [markerEnd, setMarkerEnd] = useState<boolean>(initial?.marker_end ?? false)
|
||||
|
||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||
|
||||
@@ -50,6 +52,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
custom_color: customColor,
|
||||
path_style: pathStyle,
|
||||
animated: animation !== 'none' ? animation : undefined,
|
||||
marker_start: markerStart,
|
||||
marker_end: markerEnd,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
@@ -153,6 +157,30 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Arrows</Label>
|
||||
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||
{([['Start', markerStart, setMarkerStart], ['End', markerEnd, setMarkerEnd]] as [string, boolean, (v: boolean) => void][]).map(([label, active, set], i) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => set(!active)}
|
||||
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||
tabIndex={0}
|
||||
aria-label={`Arrow ${label} ${active ? 'on' : 'off'}`}
|
||||
aria-pressed={active}
|
||||
style={{
|
||||
background: active ? '#00d4ff22' : '#21262d',
|
||||
color: active ? '#00d4ff' : '#8b949e',
|
||||
borderRight: i === 0 ? '1px solid #30363d' : undefined,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||
|
||||
@@ -124,6 +124,28 @@ describe('CustomStyleModal', () => {
|
||||
expect(markUnsaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edge editor exposes Start/End arrow toggles defaulting off', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
const startBtn = screen.getByRole('button', { name: 'Start' })
|
||||
const endBtn = screen.getByRole('button', { name: 'End' })
|
||||
expect(startBtn.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(endBtn.getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => {
|
||||
const applyTypeEdgeStyle = vi.fn()
|
||||
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'End' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true)
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false)
|
||||
})
|
||||
|
||||
it('editing path style updates the edge draft', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
|
||||
@@ -165,6 +165,41 @@ describe('EdgeModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||
})
|
||||
|
||||
// ── Arrow markers ─────────────────────────────────────────────────────────
|
||||
|
||||
it('arrows default to off', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(false)
|
||||
})
|
||||
|
||||
it('toggling End arrow sends marker_end: true', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Arrow End/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
||||
})
|
||||
|
||||
it('toggling Start arrow sends marker_start: true', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills arrows from initial', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: true, marker_end: true }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
||||
})
|
||||
|
||||
it('selecting None after Snake omits animated from payload', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
|
||||
Reference in New Issue
Block a user