Skip to content

Commit 32d7050

Browse files
fix(library): preserve legacy diagram imports
1 parent 36642ff commit 32d7050

12 files changed

Lines changed: 167 additions & 31 deletions

File tree

.changeset/orthogonal-edge-routing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@tumaet/apollon": minor
33
---
44

5-
Edges now route themselves far more cleanly. They avoid unnecessary corners and crossings, run straight when two boxes line up, leave a node from a suitable side, and divide each available node side into balanced grid-aligned stretches — one edge centres itself, two edges make three even gaps, and larger groups spread the same way — instead of piling into a corner, stepping when they could be straight, or looping around another edge. Dragging an endpoint preserves its attachment while the route remains eligible for layout; dragging a bend preserves the route you authored. In both cases, neighbouring edges adapt without jumping on release, and node dragging remains substantially more responsive in edge-dense diagrams. Automatic results are deterministic, so every collaborator and a reloaded page see the same picture.
5+
Edges now route themselves far more cleanly. They avoid unnecessary corners and crossings, run straight when two boxes line up, leave a node from a suitable side, and divide each available node side into balanced grid-aligned stretches — one edge centres itself, two edges make three even gaps, and larger groups spread the same way — instead of piling into a corner, stepping when they could be straight, or looping around another edge. Dragging an endpoint preserves its attachment while the route remains eligible for layout; dragging a bend preserves the route you authored. In both cases, neighbouring edges adapt without jumping on release, and node dragging remains substantially more responsive in edge-dense diagrams. Automatic results are deterministic, so every collaborator and a reloaded page see the same picture. Existing v3 diagrams and older v4 files with pre-routing edge data continue to open and are upgraded automatically.

library/lib/services/migration/EdgeTransformer.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import type { ApollonEdge, UMLModel, OrthogonalEdgeData } from "@/typings"
1010

1111
/**
1212
* Returns a new edge whose data is free of stale runtime geometry. Does NOT
13-
* mutate the input. A null/absent `data` is normalized to an empty object.
13+
* mutate the input. Null/absent legacy data and points are normalized to the
14+
* canonical empty point list.
1415
*/
1516
export function hydrateEdgeData(edge: ApollonEdge): ApollonEdge {
1617
// Legacy/malformed payloads can carry a null or absent `data`; treat it as
@@ -22,13 +23,18 @@ export function hydrateEdgeData(edge: ApollonEdge): ApollonEdge {
2223
"computedSegments"
2324
)
2425

25-
// Already a clean object with nothing to strip — leave it untouched.
26-
if (edge.data != null && !hasComputedSegments) {
26+
// Already a clean object with nothing to strip or hydrate — leave it untouched.
27+
if (
28+
edge.data != null &&
29+
Array.isArray(sourceData.points) &&
30+
!hasComputedSegments
31+
) {
2732
return edge
2833
}
2934

3035
const data = { ...sourceData } as OrthogonalEdgeData & Record<string, unknown>
3136
delete data.computedSegments
37+
if (!Array.isArray(data.points)) data.points = []
3238

3339
return { ...edge, data }
3440
}

library/lib/utils/versionConverter.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -906,8 +906,8 @@ export function isV4Format(data: any): data is UMLModel {
906906
(edge: unknown) =>
907907
edge != null &&
908908
typeof edge === "object" &&
909-
(edge as { data?: unknown }).data != null &&
910-
typeof (edge as { data: unknown }).data === "object"
909+
((edge as { data?: unknown }).data == null ||
910+
typeof (edge as { data: unknown }).data === "object")
911911
)
912912
)
913913
}
@@ -964,13 +964,58 @@ export function normalizeElementTags(model: UMLModel): UMLModel {
964964
return model
965965
}
966966

967+
/**
968+
* Remove React Flow interaction state that older exports and captured fixtures
969+
* could persist. Imported models should contain only durable diagram data, not
970+
* the selection or drag state of the editor that produced the file.
971+
*/
972+
function stripRuntimeInteractionState(model: UMLModel): UMLModel {
973+
let changed = false
974+
975+
const nodes = model.nodes.map((node) => {
976+
if (
977+
!("selected" in node) &&
978+
!("dragging" in node) &&
979+
!("resizing" in node)
980+
) {
981+
return node
982+
}
983+
984+
changed = true
985+
const persistentNode = { ...node } as ApollonNode & {
986+
selected?: unknown
987+
dragging?: unknown
988+
resizing?: unknown
989+
}
990+
delete persistentNode.selected
991+
delete persistentNode.dragging
992+
delete persistentNode.resizing
993+
return persistentNode
994+
})
995+
996+
const edges = model.edges.map((edge) => {
997+
if (!("selected" in edge)) return edge
998+
999+
changed = true
1000+
const persistentEdge = { ...edge } as ApollonEdge & {
1001+
selected?: unknown
1002+
}
1003+
delete persistentEdge.selected
1004+
return persistentEdge
1005+
})
1006+
1007+
return changed ? { ...model, nodes, edges } : model
1008+
}
1009+
9671010
/**
9681011
* The single normalization pass every incoming model must pass through, whatever
9691012
* its origin. Keep it idempotent and version-agnostic so already-saved `4.0.0`
9701013
* files are repaired on load rather than gated behind a version check.
9711014
*/
9721015
export function normalizeModel(model: UMLModel): UMLModel {
973-
return normalizeElementTags(normalizeClassStereotypes(model))
1016+
return stripRuntimeInteractionState(
1017+
normalizeElementTags(normalizeClassStereotypes(model))
1018+
)
9741019
}
9751020

9761021
/**

library/tests/unit/EdgeTransformer.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,28 @@ describe("EdgeTransformer", () => {
6969
expect(edge.data).toHaveProperty("computedSegments")
7070
})
7171

72-
it("normalizes null/absent data to an empty object without throwing", () => {
72+
it("normalizes null/absent data to canonical edge data without throwing", () => {
7373
const malformed = makeEdge({
7474
data: null as unknown as ApollonEdge["data"],
7575
})
7676

7777
expect(() => hydrateEdgeData(malformed)).not.toThrow()
78-
expect(hydrateEdgeData(malformed).data).toEqual({})
78+
expect(hydrateEdgeData(malformed).data).toEqual({ points: [] })
79+
})
80+
81+
it("hydrates missing or malformed legacy points", () => {
82+
const missing = makeEdge({
83+
data: { label: "legacy" } as ApollonEdge["data"],
84+
})
85+
const malformed = makeEdge({
86+
data: { points: "legacy" } as unknown as ApollonEdge["data"],
87+
})
88+
89+
expect(hydrateEdgeData(missing).data).toEqual({
90+
label: "legacy",
91+
points: [],
92+
})
93+
expect(hydrateEdgeData(malformed).data).toEqual({ points: [] })
7994
})
8095
})
8196

library/tests/unit/modelSchema.test.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,25 @@ describe("published model JSON schema", () => {
100100
}, 60_000)
101101
})
102102

103-
// Contract lock: every real diagram model the app round-trips must validate
104-
// after importDiagram. This is what proves the open `data` envelope is correct
105-
// — it guarantees no real submission is rejected, and guards against anyone
106-
// later tightening the schema in a way that would. (The assets/diagramTemplates
107-
// starter files are intentionally excluded: they carry edges without `data`, so
108-
// they aren't conformant v4 submission models — a separate, pre-existing matter.)
103+
// Contract lock: every real diagram model the app round-trips, including the
104+
// bundled starter templates, must validate after importDiagram. This proves the
105+
// open `data` envelope is correct, guarantees templates work through both the
106+
// preset and direct-file import paths, and guards against future schema drift.
109107
describe("schema accepts every real diagram model (fixtures)", () => {
110-
const dir = join(
111-
import.meta.dirname,
112-
"../../../standalone/webapp/tests/fixtures"
108+
const dirs = [
109+
join(import.meta.dirname, "../../../standalone/webapp/tests/fixtures"),
110+
join(
111+
import.meta.dirname,
112+
"../../../standalone/webapp/assets/diagramTemplates"
113+
),
114+
]
115+
const models = dirs.flatMap((dir) =>
116+
existsSync(dir)
117+
? readdirSync(dir)
118+
.filter((f) => f.endsWith(".json"))
119+
.map((f) => [f, join(dir, f)] as const)
120+
: []
113121
)
114-
const models = existsSync(dir)
115-
? readdirSync(dir)
116-
.filter((f) => f.endsWith(".json"))
117-
.map((f) => [f, join(dir, f)] as const)
118-
: []
119122

120123
it("found fixtures to validate", () => {
121124
expect(models.length).toBeGreaterThan(10)

library/tests/unit/versionConverter.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,22 @@ describe("isV4Format", () => {
12211221
expect(isV4Format(makeV4Model({ version: "4.1.0" }))).toBe(true)
12221222
})
12231223

1224+
it("accepts legacy V4 edges with absent or null data for migration", () => {
1225+
expect(
1226+
isV4Format(
1227+
makeV4Model({
1228+
edges: [{ id: "without-data" }, { id: "null-data", data: null }],
1229+
})
1230+
)
1231+
).toBe(true)
1232+
})
1233+
1234+
it("rejects V4 edges with non-object data", () => {
1235+
expect(
1236+
isV4Format(makeV4Model({ edges: [{ id: "bad-data", data: "invalid" }] }))
1237+
).toBe(false)
1238+
})
1239+
12241240
it("returns false for V3 data", () => {
12251241
expect(isV4Format(makeV3Model())).toBe(false)
12261242
})
@@ -1262,6 +1278,38 @@ describe("importDiagram", () => {
12621278
expect(result).toBe(v4) // Same reference
12631279
})
12641280

1281+
it("hydrates legacy V4 edges with absent or null data", () => {
1282+
const result = importDiagram(
1283+
makeV4Model({
1284+
edges: [{ id: "without-data" }, { id: "null-data", data: null }],
1285+
})
1286+
)
1287+
1288+
expect(result.edges).toEqual([
1289+
{ id: "without-data", data: { points: [] } },
1290+
{ id: "null-data", data: { points: [] } },
1291+
])
1292+
})
1293+
1294+
it("strips transient interaction state from imported V4 models", () => {
1295+
const result = importDiagram(
1296+
makeV4Model({
1297+
nodes: [
1298+
{
1299+
id: "node",
1300+
selected: false,
1301+
dragging: true,
1302+
resizing: false,
1303+
},
1304+
],
1305+
edges: [{ id: "edge", selected: true, data: {} }],
1306+
})
1307+
)
1308+
1309+
expect(result.nodes[0]).toEqual({ id: "node" })
1310+
expect(result.edges[0]).toEqual({ id: "edge", data: { points: [] } })
1311+
})
1312+
12651313
it("converts V3 wrapped format", () => {
12661314
const v3 = makeV3Wrapped()
12671315
const result = importDiagram(v3)

standalone/webapp/assets/diagramTemplates/Adapter.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"targetHandle": "left",
101101
"type": "ClassUnidirectional",
102102
"selected": false,
103+
"data": { "points": [] },
103104
"id": "xy-edge__286257b1-ebd3-424f-b3e4-c1c2a722531bright-2eb1ceb2-266e-4669-89f1-c61e246cb10cleft"
104105
},
105106
{

standalone/webapp/assets/diagramTemplates/Bridge.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
"targetHandle": "left",
142142
"type": "ClassBidirectional",
143143
"selected": false,
144+
"data": { "points": [] },
144145
"id": "xy-edge__286257b1-ebd3-424f-b3e4-c1c2a722531bright-2eb1ceb2-266e-4669-89f1-c61e246cb10cleft"
145146
},
146147
{
@@ -206,7 +207,8 @@
206207
"targetHandle": "left",
207208
"id": "1737993220311-2eb1ceb2-266e-4669-89f1-c61e246cb10c-889cb1f6-175a-45c0-ae06-d1acab5dcbdc",
208209
"type": "ClassBidirectional",
209-
"selected": false
210+
"selected": false,
211+
"data": { "points": [] }
210212
}
211213
],
212214
"assessments": {}

standalone/webapp/assets/diagramTemplates/Command.json

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"targetHandle": "left",
108108
"id": "1737994716146-8acfa3c3-de5b-424a-a555-cb68855538d0-7351d563-b929-460e-a09d-e2b463bfceea",
109109
"type": "ClassUnidirectional",
110-
"selected": false
110+
"selected": false,
111+
"data": { "points": [] }
111112
},
112113
{
113114
"source": "8acfa3c3-de5b-424a-a555-cb68855538d0",
@@ -116,7 +117,8 @@
116117
"targetHandle": "left",
117118
"id": "1737994722983-8acfa3c3-de5b-424a-a555-cb68855538d0-41a9495d-9993-449e-a3b5-a7a704130380",
118119
"type": "ClassUnidirectional",
119-
"selected": false
120+
"selected": false,
121+
"data": { "points": [] }
120122
},
121123
{
122124
"source": "f235a28a-6d9b-482c-9e67-546dfd92e0d5",
@@ -139,7 +141,8 @@
139141
"targetHandle": "right",
140142
"id": "1737994981686-f235a28a-6d9b-482c-9e67-546dfd92e0d5-41a9495d-9993-449e-a3b5-a7a704130380",
141143
"type": "ClassUnidirectional",
142-
"selected": false
144+
"selected": false,
145+
"data": { "points": [] }
143146
},
144147
{
145148
"source": "7351d563-b929-460e-a09d-e2b463bfceea",
@@ -148,7 +151,8 @@
148151
"targetHandle": "left",
149152
"id": "1737995015106-7351d563-b929-460e-a09d-e2b463bfceea-52e53f8b-cac0-4816-a70a-c2f154f3fb67",
150153
"type": "ClassUnidirectional",
151-
"selected": false
154+
"selected": false,
155+
"data": { "points": [] }
152156
},
153157
{
154158
"source": "8acfa3c3-de5b-424a-a555-cb68855538d0",
@@ -157,7 +161,8 @@
157161
"targetHandle": "bottom",
158162
"id": "1737995032625-8acfa3c3-de5b-424a-a555-cb68855538d0-f235a28a-6d9b-482c-9e67-546dfd92e0d5",
159163
"type": "ClassDependency",
160-
"selected": false
164+
"selected": false,
165+
"data": { "points": [] }
161166
}
162167
],
163168
"assessments": {}

standalone/webapp/assets/diagramTemplates/Factory.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"targetHandle": "left",
125125
"type": "ClassBidirectional",
126126
"selected": false,
127+
"data": { "points": [] },
127128
"id": "xy-edge__cfc9b781-debe-4ca3-903c-9a9e555f6812right-1c0330be-83dd-4d92-9bd1-d62a36e90c7bleft"
128129
},
129130
{

0 commit comments

Comments
 (0)