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:
@@ -243,6 +243,19 @@ describe('serializeEdge', () => {
|
||||
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', () => {
|
||||
const result = serializeEdge(makeRfEdge({ sourceHandle: undefined, targetHandle: undefined }))
|
||||
expect(result.source_handle).toBeNull()
|
||||
|
||||
@@ -182,6 +182,17 @@ describe('rewireEdgesForCollapse', () => {
|
||||
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', () => {
|
||||
const nodes = [
|
||||
mkNode('zone', { collapsed: true }),
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface ApiEdge {
|
||||
custom_color?: string | null
|
||||
path_style?: string | null
|
||||
animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
|
||||
marker_start?: boolean | null
|
||||
marker_end?: boolean | null
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
waypoints?: Waypoint[] | null
|
||||
@@ -142,6 +144,8 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
custom_color: e.data?.custom_color ?? null,
|
||||
path_style: e.data?.path_style ?? null,
|
||||
animated: e.data?.animated ?? false,
|
||||
marker_start: e.data?.marker_start ?? false,
|
||||
marker_end: e.data?.marker_end ?? false,
|
||||
source_handle: normalizeHandle(e.sourceHandle),
|
||||
target_handle: normalizeHandle(e.targetHandle),
|
||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||
|
||||
@@ -184,11 +184,18 @@ export function rewireEdgesForCollapse(
|
||||
if (src === null || tgt === null) continue
|
||||
if (src === tgt) continue
|
||||
const key = `${src}->${tgt}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
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)
|
||||
} 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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user