fix: preserve edge connection points in YAML export/import (#208)
YAML round-trip dropped every edge's handles: export never wrote them
and import hardcoded sourceHandle='bottom'/targetHandle='top-t'. After
import all connections collapsed onto slot 0 ("converge at a single
point"). Per-side handle counts were dropped too, so the extra bottom
slots did not even exist to attach to.
- yaml types: add sourceHandle/targetHandle to connections and
top/bottom/left/rightHandles to nodes
- export: write each edge's real handles + per-side counts (only above
the side default); orient parent-edge handles parent->child
- import: restore handle counts onto node data and use the stored
handles, falling back to the legacy defaults for pre-existing YAML
Positions remain dagre-managed (unchanged); this restores connection
points only.
ha-relevant: yes
This commit is contained in:
@@ -4,6 +4,10 @@ export interface YamlNodeConnection {
|
|||||||
label: string
|
label: string
|
||||||
linkType?: EdgeType
|
linkType?: EdgeType
|
||||||
linkLabel?: string
|
linkLabel?: string
|
||||||
|
// Connection points (React Flow handle IDs) the edge attaches to. Preserved so
|
||||||
|
// a manual layout round-trips instead of collapsing every edge onto slot 0.
|
||||||
|
sourceHandle?: string
|
||||||
|
targetHandle?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YamlNode {
|
export interface YamlNode {
|
||||||
@@ -23,4 +27,11 @@ export interface YamlNode {
|
|||||||
cpuCore?: number
|
cpuCore?: number
|
||||||
ram?: number
|
ram?: number
|
||||||
disk?: number
|
disk?: number
|
||||||
|
// Per-side connection-point counts. Only written when a side has more than its
|
||||||
|
// default (top/bottom 1, left/right 0) so the referenced handle slot exists on
|
||||||
|
// re-import — without it React Flow falls back to slot 0.
|
||||||
|
topHandles?: number
|
||||||
|
bottomHandles?: number
|
||||||
|
leftHandles?: number
|
||||||
|
rightHandles?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,4 +181,31 @@ describe('exportCanvasToYaml', () => {
|
|||||||
expect(yamlStr).toContain('4000')
|
expect(yamlStr).toContain('4000')
|
||||||
expect(yamlStr).toContain('star')
|
expect(yamlStr).toContain('star')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression for issue #208: connection points were dropped on export, so
|
||||||
|
// every edge collapsed onto slot 0 after import.
|
||||||
|
it('preserves edge connection points (source/target handles) in links', () => {
|
||||||
|
const sw = makeNode({ label: 'Switch', type: 'switch', bottom_handles: 3 }, 'sw')
|
||||||
|
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const edge: Edge<EdgeData> = {
|
||||||
|
...makeEdge('e1', 'sw', 's1', { type: 'ethernet' }),
|
||||||
|
sourceHandle: 'bottom-3',
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
}
|
||||||
|
const result = yaml.load(exportCanvasToYaml([sw, s1], [edge])) as Record<string, unknown>[]
|
||||||
|
const swEntry = result.find((e) => e.label === 'Switch')!
|
||||||
|
const link = (swEntry.links as Record<string, unknown>[])[0]
|
||||||
|
expect(link.sourceHandle).toBe('bottom-3')
|
||||||
|
expect(link.targetHandle).toBe('top-t')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports per-side handle counts only when above the side default', () => {
|
||||||
|
const nodes = [makeNode({ label: 'N', type: 'server', bottom_handles: 4, right_handles: 2, top_handles: 1, left_handles: 0 })]
|
||||||
|
const entry = (yaml.load(exportCanvasToYaml(nodes, [])) as Record<string, unknown>[])[0]
|
||||||
|
expect(entry.bottomHandles).toBe(4)
|
||||||
|
expect(entry.rightHandles).toBe(2)
|
||||||
|
// top default is 1, left default is 0 → omitted.
|
||||||
|
expect(entry).not.toHaveProperty('topHandles')
|
||||||
|
expect(entry).not.toHaveProperty('leftHandles')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { parseYamlToCanvas } from '../importYaml'
|
import { parseYamlToCanvas } from '../importYaml'
|
||||||
|
import { exportCanvasToYaml } from '../exportYaml'
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
@@ -306,4 +307,65 @@ describe('parseYamlToCanvas', () => {
|
|||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
|
||||||
warnSpy.mockRestore()
|
warnSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression for issue #208.
|
||||||
|
it('restores edge connection points from the YAML link', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "Switch"
|
||||||
|
bottomHandles: 3
|
||||||
|
links:
|
||||||
|
- label: "Server1"
|
||||||
|
linkType: ethernet
|
||||||
|
sourceHandle: bottom-3
|
||||||
|
targetHandle: top-2-t
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server1"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].sourceHandle).toBe('bottom-3')
|
||||||
|
expect(edges[0].targetHandle).toBe('top-2-t')
|
||||||
|
const sw = nodes.find((n) => n.data.label === 'Switch')!
|
||||||
|
expect(sw.data.bottom_handles).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to legacy handles when the YAML link omits them', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "Switch"
|
||||||
|
links:
|
||||||
|
- label: "Server1"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server1"
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges[0].sourceHandle).toBe('bottom')
|
||||||
|
expect(edges[0].targetHandle).toBe('top-t')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips connection points through export → import (issue #208)', () => {
|
||||||
|
const sw: Node<NodeData> = {
|
||||||
|
id: 'sw', type: 'switch', position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Switch', type: 'switch', status: 'online', services: [], bottom_handles: 3 },
|
||||||
|
}
|
||||||
|
const s1: Node<NodeData> = {
|
||||||
|
id: 's1', type: 'server', position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Server1', type: 'server', status: 'online', services: [] },
|
||||||
|
}
|
||||||
|
const edge: Edge<EdgeData> = {
|
||||||
|
id: 'e1', source: 'sw', target: 's1',
|
||||||
|
sourceHandle: 'bottom-3', targetHandle: 'top-t',
|
||||||
|
data: { type: 'ethernet' } as EdgeData,
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlStr = exportCanvasToYaml([sw, s1], [edge])
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yamlStr, empty, emptyEdges)
|
||||||
|
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].sourceHandle).toBe('bottom-3')
|
||||||
|
expect(edges[0].targetHandle).toBe('top-t')
|
||||||
|
expect(nodes.find((n) => n.data.label === 'Switch')!.data.bottom_handles).toBe(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, EdgeType } from '@/types'
|
import type { NodeData, EdgeData, EdgeType } from '@/types'
|
||||||
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import { SIDES, sideDefault, sideHandleCount } from '@/utils/handleUtils'
|
||||||
import yaml from 'js-yaml'
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
/** Build a map of node id → label for edge resolution */
|
/** Build a map of node id → label for edge resolution */
|
||||||
@@ -10,12 +11,32 @@ function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
|
function makeConnection(
|
||||||
return {
|
targetLabel: string,
|
||||||
|
edgeType: EdgeType,
|
||||||
|
edgeLabel: string | undefined,
|
||||||
|
edge?: Edge<EdgeData>,
|
||||||
|
): YamlNodeConnection {
|
||||||
|
const conn: YamlNodeConnection = {
|
||||||
label: targetLabel,
|
label: targetLabel,
|
||||||
linkType: edgeType,
|
linkType: edgeType,
|
||||||
linkLabel: edgeLabel ?? '',
|
linkLabel: edgeLabel ?? '',
|
||||||
}
|
}
|
||||||
|
// Preserve the exact connection points so the layout round-trips.
|
||||||
|
if (edge?.sourceHandle) conn.sourceHandle = edge.sourceHandle
|
||||||
|
if (edge?.targetHandle) conn.targetHandle = edge.targetHandle
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write each side's handle count onto the entry when it exceeds the side default. */
|
||||||
|
function attachHandleCounts(entry: YamlNode, data: NodeData): void {
|
||||||
|
for (const side of SIDES) {
|
||||||
|
const count = sideHandleCount(data, side)
|
||||||
|
if (count > sideDefault(side)) {
|
||||||
|
entry[`${side}Handles` as 'topHandles' | 'bottomHandles' | 'leftHandles' | 'rightHandles'] =
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +89,9 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
|
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
|
||||||
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
|
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
|
||||||
|
|
||||||
|
// Custom connection-point counts, so imported edges land on real slots.
|
||||||
|
attachHandleCounts(entry, d)
|
||||||
|
|
||||||
// Parent relationship: if this node has a parentId in React Flow,
|
// Parent relationship: if this node has a parentId in React Flow,
|
||||||
// encode it as a 'parent' connection using any virtual edge between them.
|
// encode it as a 'parent' connection using any virtual edge between them.
|
||||||
if (node.parentId) {
|
if (node.parentId) {
|
||||||
@@ -81,7 +105,16 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
|
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
|
||||||
const linkLabel = pEdge?.data?.label ?? ''
|
const linkLabel = pEdge?.data?.label ?? ''
|
||||||
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
|
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
|
||||||
if (pEdge) serializedEdges.add(pEdge.id)
|
// Import always rebuilds this edge parent→child, so orient the handles the
|
||||||
|
// same way (swap when the stored edge runs child→parent).
|
||||||
|
if (pEdge) {
|
||||||
|
const parentToChild = pEdge.source === node.parentId
|
||||||
|
const srcH = parentToChild ? pEdge.sourceHandle : pEdge.targetHandle
|
||||||
|
const tgtH = parentToChild ? pEdge.targetHandle : pEdge.sourceHandle
|
||||||
|
if (srcH) entry.parent.sourceHandle = srcH
|
||||||
|
if (tgtH) entry.parent.targetHandle = tgtH
|
||||||
|
serializedEdges.add(pEdge.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing edges (this node is the source):
|
// Outgoing edges (this node is the source):
|
||||||
@@ -95,7 +128,7 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
if (!targetLabel) continue
|
if (!targetLabel) continue
|
||||||
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
||||||
const edgeLabel = e.data?.label as string | undefined
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
|
const conn = makeConnection(targetLabel, edgeType, edgeLabel, e)
|
||||||
if (edgeType === 'cluster') {
|
if (edgeType === 'cluster') {
|
||||||
if (!entry.clusterR) entry.clusterR = conn
|
if (!entry.clusterR) entry.clusterR = conn
|
||||||
} else {
|
} else {
|
||||||
@@ -112,7 +145,7 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
const sourceLabel = idToLabel.get(e.source)
|
const sourceLabel = idToLabel.get(e.source)
|
||||||
if (!sourceLabel) continue
|
if (!sourceLabel) continue
|
||||||
const edgeLabel = e.data?.label as string | undefined
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
|
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel, e)
|
||||||
serializedEdges.add(e.id)
|
serializedEdges.add(e.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export function parseYamlToCanvas(
|
|||||||
...(yn.ram ? { ram_gb: yn.ram } : {}),
|
...(yn.ram ? { ram_gb: yn.ram } : {}),
|
||||||
...(yn.disk ? { disk_gb: yn.disk } : {}),
|
...(yn.disk ? { disk_gb: yn.disk } : {}),
|
||||||
...(hasHardware ? { show_hardware: true } : {}),
|
...(hasHardware ? { show_hardware: true } : {}),
|
||||||
|
// Restore custom connection-point counts so the edges below attach to the
|
||||||
|
// slots they were exported on instead of collapsing onto slot 0.
|
||||||
|
...(yn.topHandles ? { top_handles: yn.topHandles } : {}),
|
||||||
|
...(yn.bottomHandles ? { bottom_handles: yn.bottomHandles } : {}),
|
||||||
|
...(yn.leftHandles ? { left_handles: yn.leftHandles } : {}),
|
||||||
|
...(yn.rightHandles ? { right_handles: yn.rightHandles } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
newNodes.push({
|
newNodes.push({
|
||||||
@@ -104,12 +110,14 @@ export function parseYamlToCanvas(
|
|||||||
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
|
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
|
||||||
edgePairs.add(key)
|
edgePairs.add(key)
|
||||||
const edgeType = conn.linkType ?? 'ethernet'
|
const edgeType = conn.linkType ?? 'ethernet'
|
||||||
|
// Prefer the exported connection points; fall back to the legacy defaults for
|
||||||
|
// YAML written before handles were persisted.
|
||||||
newEdges.push({
|
newEdges.push({
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
source: sourceId,
|
source: sourceId,
|
||||||
target: targetId,
|
target: targetId,
|
||||||
sourceHandle,
|
sourceHandle: conn.sourceHandle ?? sourceHandle,
|
||||||
targetHandle,
|
targetHandle: conn.targetHandle ?? targetHandle,
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
data: {
|
data: {
|
||||||
type: edgeType,
|
type: edgeType,
|
||||||
|
|||||||
Reference in New Issue
Block a user