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:
Pouzor
2026-07-05 01:16:26 +02:00
parent ae2d3e1eab
commit 1cf525844b
20 changed files with 371 additions and 14 deletions
@@ -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} />)