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:
@@ -81,6 +81,10 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ class Edge(Base):
|
|||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||||
|
marker_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
marker_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class EdgeSave(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
|
marker_start: bool = False
|
||||||
|
marker_end: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ class EdgeBase(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str = 'none'
|
animated: str = 'none'
|
||||||
|
marker_start: bool = False
|
||||||
|
marker_end: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -37,6 +39,8 @@ class EdgeUpdate(BaseModel):
|
|||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
animated: str | None = None
|
animated: str | None = None
|
||||||
|
marker_start: bool | None = None
|
||||||
|
marker_end: bool | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
waypoints: list[dict[str, float]] | None = None
|
||||||
|
|||||||
@@ -51,6 +51,28 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
|
|||||||
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_round_trips_arrow_markers(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(label="Router", type="router")
|
||||||
|
n2 = node_payload(label="Switch", type="switch")
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=True)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||||
|
assert edge["marker_start"] is True
|
||||||
|
assert edge["marker_end"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_defaults_arrow_markers_off(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(label="Router", type="router")
|
||||||
|
n2 = node_payload(label="Switch", type="switch")
|
||||||
|
e1 = edge_payload(n1["id"], n2["id"])
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||||
|
assert edge["marker_start"] is False
|
||||||
|
assert edge["marker_end"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
||||||
# Regression (#243): top/left/right_handles must persist across save+reload,
|
# Regression (#243): top/left/right_handles must persist across save+reload,
|
||||||
# not just bottom_handles.
|
# not just bottom_handles.
|
||||||
|
|||||||
@@ -91,6 +91,31 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
|
|||||||
assert res.json()["path_style"] == "smooth"
|
assert res.json()["path_style"] == "smooth"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_edge_with_arrow_markers(client: AsyncClient, headers: dict, two_nodes):
|
||||||
|
src, tgt = two_nodes
|
||||||
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": True, "marker_end": True}, headers=headers)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["marker_start"] is True
|
||||||
|
assert res.json()["marker_end"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_edge_defaults_arrow_markers_off(client: AsyncClient, headers: dict, two_nodes):
|
||||||
|
src, tgt = two_nodes
|
||||||
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||||
|
assert res.status_code == 201
|
||||||
|
assert res.json()["marker_start"] is False
|
||||||
|
assert res.json()["marker_end"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_edge_arrow_markers(client: AsyncClient, headers: dict, two_nodes):
|
||||||
|
src, tgt = two_nodes
|
||||||
|
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
||||||
|
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": True}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["marker_end"] is True
|
||||||
|
assert res.json()["marker_start"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
||||||
src, tgt = two_nodes
|
src, tgt = two_nodes
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
||||||
|
|||||||
@@ -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)
|
? 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 (
|
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' && (
|
{animMode === 'basic' && (
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
pathStyle: 'bezier',
|
pathStyle: 'bezier',
|
||||||
animated: 'none',
|
animated: 'none',
|
||||||
|
arrowStart: false,
|
||||||
|
arrowEnd: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +281,28 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
|
|||||||
<option value="snake">Snake</option>
|
<option value="snake">Snake</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
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]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
animated: animation !== 'none' ? animation : undefined,
|
animated: animation !== 'none' ? animation : undefined,
|
||||||
|
marker_start: markerStart,
|
||||||
|
marker_end: markerEnd,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -153,6 +157,30 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
</div>
|
</div>
|
||||||
</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 flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(markUnsaved).not.toHaveBeenCalled()
|
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', () => {
|
it('editing path style updates the edge draft', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
|||||||
@@ -165,6 +165,41 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
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', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
|||||||
@@ -332,6 +332,25 @@ describe('canvasStore', () => {
|
|||||||
expect(edges[0].data?.animated).toBe('snake')
|
expect(edges[0].data?.animated).toBe('snake')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('onConnect allows multiple edges between the same two nodes (no dedupe)', () => {
|
||||||
|
// Regression: React Flow addEdge() dropped a second edge between the same
|
||||||
|
// source+target when handles were null/equal. A homelab has multiple links
|
||||||
|
// between two devices, so every connect must add an edge.
|
||||||
|
useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null })
|
||||||
|
useCanvasStore.getState().onConnect({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null })
|
||||||
|
const { edges } = useCanvasStore.getState()
|
||||||
|
expect(edges).toHaveLength(2)
|
||||||
|
expect(edges[0].id).not.toBe(edges[1].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onConnect preserves arrow markers from edge data', () => {
|
||||||
|
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', marker_start: true, marker_end: true })
|
||||||
|
useCanvasStore.getState().onConnect(conn)
|
||||||
|
const { edges } = useCanvasStore.getState()
|
||||||
|
expect(edges[0].data?.marker_start).toBe(true)
|
||||||
|
expect(edges[0].data?.marker_end).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
|
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
|
||||||
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
|
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
|
||||||
useCanvasStore.getState().onConnect(conn)
|
useCanvasStore.getState().onConnect(conn)
|
||||||
@@ -1301,14 +1320,17 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
|
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
|
||||||
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
|
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
|
||||||
|
|
||||||
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
|
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow', arrowStart: true, arrowEnd: true })
|
||||||
|
|
||||||
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
|
||||||
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
|
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
|
||||||
expect(updated1.data?.custom_color).toBe('#00ff00')
|
expect(updated1.data?.custom_color).toBe('#00ff00')
|
||||||
expect(updated1.data?.path_style).toBe('smooth')
|
expect(updated1.data?.path_style).toBe('smooth')
|
||||||
expect(updated1.data?.animated).toBe('flow')
|
expect(updated1.data?.animated).toBe('flow')
|
||||||
|
expect(updated1.data?.marker_start).toBe(true)
|
||||||
|
expect(updated1.data?.marker_end).toBe(true)
|
||||||
expect(updated2.data?.custom_color).toBeUndefined()
|
expect(updated2.data?.custom_color).toBeUndefined()
|
||||||
|
expect(updated2.data?.marker_end).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applyAllCustomStyles applies all defined types', () => {
|
it('applyAllCustomStyles applies all defined types', () => {
|
||||||
@@ -1322,7 +1344,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
|
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
|
||||||
},
|
},
|
||||||
edges: {
|
edges: {
|
||||||
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
|
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1332,6 +1354,8 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
expect(np.data.custom_colors?.border).toBe('#ff6e00')
|
expect(np.data.custom_colors?.border).toBe('#ff6e00')
|
||||||
expect(ns.data.custom_colors?.border).toBeUndefined()
|
expect(ns.data.custom_colors?.border).toBeUndefined()
|
||||||
expect(e.data?.custom_color).toBe('#aabbcc')
|
expect(e.data?.custom_color).toBe('#aabbcc')
|
||||||
|
expect(e.data?.marker_end).toBe(true)
|
||||||
|
expect(e.data?.marker_start).toBe(false)
|
||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('themeStore', () => {
|
|||||||
it('setCustomStyle replaces the entire definition', () => {
|
it('setCustomStyle replaces the entire definition', () => {
|
||||||
const def: CustomStyleDef = {
|
const def: CustomStyleDef = {
|
||||||
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
|
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
|
||||||
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
|
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none', arrowStart: false, arrowEnd: false } },
|
||||||
}
|
}
|
||||||
useThemeStore.getState().setCustomStyle(def)
|
useThemeStore.getState().setCustomStyle(def)
|
||||||
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
|
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
type Connection,
|
type Connection,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
addEdge,
|
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
|
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
@@ -268,14 +267,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const extra = connection as Connection & Partial<EdgeData>
|
const extra = connection as Connection & Partial<EdgeData>
|
||||||
const edgeType = extra.type ?? 'ethernet'
|
const edgeType = extra.type ?? 'ethernet'
|
||||||
|
// Build the edge with our own unique id and append directly instead of
|
||||||
|
// React Flow's addEdge(): addEdge silently drops any new edge whose
|
||||||
|
// source+target already match an existing edge when handles are null/equal
|
||||||
|
// (connectionExists dedupe). A homelab legitimately has multiple links
|
||||||
|
// between the same two devices, so we allow them.
|
||||||
|
const newEdge: Edge<EdgeData> = {
|
||||||
|
id: `edge-${generateUUID()}`,
|
||||||
|
source: connection.source,
|
||||||
|
target: connection.target,
|
||||||
|
sourceHandle: normalizeHandle(extra.sourceHandle),
|
||||||
|
targetHandle: normalizeHandle(extra.targetHandle),
|
||||||
|
type: edgeType,
|
||||||
|
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated, marker_start: extra.marker_start, marker_end: extra.marker_end },
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
edges: addEdge({
|
edges: [...state.edges, newEdge],
|
||||||
...connection,
|
|
||||||
sourceHandle: normalizeHandle(extra.sourceHandle),
|
|
||||||
targetHandle: normalizeHandle(extra.targetHandle),
|
|
||||||
type: edgeType,
|
|
||||||
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated },
|
|
||||||
}, state.edges),
|
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -816,6 +823,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
custom_color: applyOpacity(style.color, style.opacity),
|
custom_color: applyOpacity(style.color, style.opacity),
|
||||||
path_style: style.pathStyle,
|
path_style: style.pathStyle,
|
||||||
animated: style.animated,
|
animated: style.animated,
|
||||||
|
marker_start: style.arrowStart,
|
||||||
|
marker_end: style.arrowEnd,
|
||||||
} as EdgeData,
|
} as EdgeData,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -854,6 +863,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
custom_color: applyOpacity(style.color, style.opacity),
|
custom_color: applyOpacity(style.color, style.opacity),
|
||||||
path_style: style.pathStyle,
|
path_style: style.pathStyle,
|
||||||
animated: style.animated,
|
animated: style.animated,
|
||||||
|
marker_start: style.arrowStart,
|
||||||
|
marker_end: style.arrowEnd,
|
||||||
} as EdgeData,
|
} as EdgeData,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
|
/** Filled arrowhead at the source end. */
|
||||||
|
marker_start?: boolean
|
||||||
|
/** Filled arrowhead at the target end. */
|
||||||
|
marker_end?: boolean
|
||||||
waypoints?: Waypoint[]
|
waypoints?: Waypoint[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +261,10 @@ export interface EdgeTypeStyle {
|
|||||||
opacity: number
|
opacity: number
|
||||||
pathStyle: EdgePathStyle
|
pathStyle: EdgePathStyle
|
||||||
animated: 'none' | 'snake' | 'flow' | 'basic'
|
animated: 'none' | 'snake' | 'flow' | 'basic'
|
||||||
|
/** Default filled arrowhead at the source end for new edges of this type. */
|
||||||
|
arrowStart: boolean
|
||||||
|
/** Default filled arrowhead at the target end for new edges of this type. */
|
||||||
|
arrowEnd: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomStyleDef {
|
export interface CustomStyleDef {
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ describe('serializeEdge', () => {
|
|||||||
expect(result.animated).toBe(true)
|
expect(result.animated).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('serializes arrow markers', () => {
|
||||||
|
const edge = makeRfEdge({ data: { type: 'ethernet', marker_start: true, marker_end: true } })
|
||||||
|
const result = serializeEdge(edge)
|
||||||
|
expect(result.marker_start).toBe(true)
|
||||||
|
expect(result.marker_end).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults arrow markers to false when absent', () => {
|
||||||
|
const result = serializeEdge(makeRfEdge())
|
||||||
|
expect(result.marker_start).toBe(false)
|
||||||
|
expect(result.marker_end).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('nulls optional fields when absent', () => {
|
it('nulls optional fields when absent', () => {
|
||||||
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
|
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
|
||||||
expect(result.source_handle).toBeNull()
|
expect(result.source_handle).toBeNull()
|
||||||
|
|||||||
@@ -182,6 +182,17 @@ describe('rewireEdgesForCollapse', () => {
|
|||||||
expect(out[0]).toBe(edges[0])
|
expect(out[0]).toBe(edges[0])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps multiple parallel edges between two visible nodes (no dedupe)', () => {
|
||||||
|
// Regression: parallel links between the same two visible devices must all
|
||||||
|
// render. The seen-key dedupe applies only to rewired collapse stubs.
|
||||||
|
const nodes = [mkNode('a'), mkNode('b', { position: FAR })]
|
||||||
|
const edges = [mkEdge('e1', 'a', 'b'), mkEdge('e2', 'a', 'b'), mkEdge('e3', 'a', 'b')]
|
||||||
|
const info = computeCollapseInfo(nodes)
|
||||||
|
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
|
||||||
|
expect(out).toHaveLength(3)
|
||||||
|
expect(out.map((e) => e.id)).toEqual(['e1', 'e2', 'e3'])
|
||||||
|
})
|
||||||
|
|
||||||
it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => {
|
it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
mkNode('zone', { collapsed: true }),
|
mkNode('zone', { collapsed: true }),
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export interface ApiEdge {
|
|||||||
custom_color?: string | null
|
custom_color?: string | null
|
||||||
path_style?: string | null
|
path_style?: string | null
|
||||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||||
|
marker_start?: boolean | null
|
||||||
|
marker_end?: boolean | null
|
||||||
source_handle?: string | null
|
source_handle?: string | null
|
||||||
target_handle?: string | null
|
target_handle?: string | null
|
||||||
waypoints?: Waypoint[] | null
|
waypoints?: Waypoint[] | null
|
||||||
@@ -142,6 +144,8 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
|||||||
custom_color: e.data?.custom_color ?? null,
|
custom_color: e.data?.custom_color ?? null,
|
||||||
path_style: e.data?.path_style ?? null,
|
path_style: e.data?.path_style ?? null,
|
||||||
animated: e.data?.animated ?? false,
|
animated: e.data?.animated ?? false,
|
||||||
|
marker_start: e.data?.marker_start ?? false,
|
||||||
|
marker_end: e.data?.marker_end ?? false,
|
||||||
source_handle: normalizeHandle(e.sourceHandle),
|
source_handle: normalizeHandle(e.sourceHandle),
|
||||||
target_handle: normalizeHandle(e.targetHandle),
|
target_handle: normalizeHandle(e.targetHandle),
|
||||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||||
|
|||||||
@@ -184,11 +184,18 @@ export function rewireEdgesForCollapse(
|
|||||||
if (src === null || tgt === null) continue
|
if (src === null || tgt === null) continue
|
||||||
if (src === tgt) continue
|
if (src === tgt) continue
|
||||||
const key = `${src}->${tgt}`
|
const key = `${src}->${tgt}`
|
||||||
if (seen.has(key)) continue
|
|
||||||
seen.add(key)
|
|
||||||
if (src === e.source && tgt === e.target) {
|
if (src === e.source && tgt === e.target) {
|
||||||
|
// Real edge between two visible nodes — always keep. A homelab has
|
||||||
|
// multiple parallel links between the same two devices, so these must
|
||||||
|
// NOT be de-duplicated. Record the pair so redundant collapse stubs to
|
||||||
|
// the same pair are still suppressed.
|
||||||
|
seen.add(key)
|
||||||
out.push(e)
|
out.push(e)
|
||||||
} else {
|
} else {
|
||||||
|
// Rewired collapse stub — de-duplicate parallel stubs to the same
|
||||||
|
// visible pair (prevents a 20-device mesh rendering 20 stacked stubs).
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null })
|
out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user