From 7bc3e5d8a064bb9fefbc9fb657e179277a96d868 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 7 Jul 2026 11:16:29 +0200 Subject: [PATCH] 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 --- frontend/src/types/yaml.ts | 11 ++++ .../src/utils/__tests__/exportYaml.test.ts | 27 ++++++++ .../src/utils/__tests__/importYaml.test.ts | 62 +++++++++++++++++++ frontend/src/utils/exportYaml.ts | 43 +++++++++++-- frontend/src/utils/importYaml.ts | 12 +++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/frontend/src/types/yaml.ts b/frontend/src/types/yaml.ts index f5ee2f9..02758f0 100644 --- a/frontend/src/types/yaml.ts +++ b/frontend/src/types/yaml.ts @@ -4,6 +4,10 @@ export interface YamlNodeConnection { label: string linkType?: EdgeType 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 { @@ -23,4 +27,11 @@ export interface YamlNode { cpuCore?: number ram?: 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 } diff --git a/frontend/src/utils/__tests__/exportYaml.test.ts b/frontend/src/utils/__tests__/exportYaml.test.ts index 7348e7e..83a4ff2 100644 --- a/frontend/src/utils/__tests__/exportYaml.test.ts +++ b/frontend/src/utils/__tests__/exportYaml.test.ts @@ -181,4 +181,31 @@ describe('exportCanvasToYaml', () => { expect(yamlStr).toContain('4000') 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 = { + ...makeEdge('e1', 'sw', 's1', { type: 'ethernet' }), + sourceHandle: 'bottom-3', + targetHandle: 'top-t', + } + const result = yaml.load(exportCanvasToYaml([sw, s1], [edge])) as Record[] + const swEntry = result.find((e) => e.label === 'Switch')! + const link = (swEntry.links as Record[])[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[])[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') + }) }) diff --git a/frontend/src/utils/__tests__/importYaml.test.ts b/frontend/src/utils/__tests__/importYaml.test.ts index 190568e..6b4db95 100644 --- a/frontend/src/utils/__tests__/importYaml.test.ts +++ b/frontend/src/utils/__tests__/importYaml.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { parseYamlToCanvas } from '../importYaml' +import { exportCanvasToYaml } from '../exportYaml' import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData } from '@/types' @@ -306,4 +307,65 @@ describe('parseYamlToCanvas', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost')) 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 = { + id: 'sw', type: 'switch', position: { x: 0, y: 0 }, + data: { label: 'Switch', type: 'switch', status: 'online', services: [], bottom_handles: 3 }, + } + const s1: Node = { + id: 's1', type: 'server', position: { x: 0, y: 0 }, + data: { label: 'Server1', type: 'server', status: 'online', services: [] }, + } + const edge: Edge = { + 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) + }) }) diff --git a/frontend/src/utils/exportYaml.ts b/frontend/src/utils/exportYaml.ts index bbe3ae4..f2a9772 100644 --- a/frontend/src/utils/exportYaml.ts +++ b/frontend/src/utils/exportYaml.ts @@ -1,6 +1,7 @@ import type { Node, Edge } from '@xyflow/react' import type { NodeData, EdgeData, EdgeType } from '@/types' import type { YamlNode, YamlNodeConnection } from '@/types/yaml' +import { SIDES, sideDefault, sideHandleCount } from '@/utils/handleUtils' import yaml from 'js-yaml' /** Build a map of node id → label for edge resolution */ @@ -10,12 +11,32 @@ function buildIdToLabel(nodes: Node[]): Map { return m } -function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection { - return { +function makeConnection( + targetLabel: string, + edgeType: EdgeType, + edgeLabel: string | undefined, + edge?: Edge, +): YamlNodeConnection { + const conn: YamlNodeConnection = { label: targetLabel, linkType: edgeType, 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[], edges: Edge 0) entry.ram = d.ram_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, // encode it as a 'parent' connection using any virtual edge between them. if (node.parentId) { @@ -81,7 +105,16 @@ export function exportCanvasToYaml(nodes: Node[], edges: Edge[], edges: Edge[], edges: Edge