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:
@@ -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