Skip to content

Commit 32fd3c9

Browse files
fix(webapp): keep template routing clean and automatic
1 parent 4617db5 commit 32fd3c9

16 files changed

Lines changed: 381 additions & 61 deletions

.changeset/clear-signs-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tumaet/webapp": patch
3+
---
4+
5+
Start every bundled design-pattern template in a clean editing state, with automatic edges ready to rebalance as the diagram changes.

standalone/webapp/src/components/modals/NewDiagramModal.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
TabsTrigger,
1313
} from "@tumaet/ui/components/tabs"
1414
import { log } from "@/logger"
15+
import { prepareTemplateModel } from "@/utils/templateModels"
1516
import {
1617
HomeDialogActions,
1718
HomeDialogContent,
@@ -174,16 +175,15 @@ export const NewDiagramModal = () => {
174175
throw new Error("Selected template data not found")
175176
}
176177

177-
const templateModel =
178-
typeof structuredClone === "function"
179-
? structuredClone(jsonData)
180-
: JSON.parse(JSON.stringify(jsonData))
181-
182-
templateModel.title = newDiagramTitle
183178
// Templates ship with fixed ids (several even share one), so give each
184179
// created diagram a fresh id — otherwise creating two collides on the
185-
// same store key and one silently overwrites the other.
186-
templateModel.id = crypto.randomUUID()
180+
// same store key and one silently overwrites the other. Preparing also
181+
// removes stale React Flow interaction flags captured in the JSON asset
182+
// without discarding deliberate pinned endpoints or authored bends.
183+
const templateModel = prepareTemplateModel(jsonData, {
184+
id: crypto.randomUUID(),
185+
title: newDiagramTitle,
186+
})
187187

188188
createModel(templateModel)
189189
closeModal()
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { UMLModel } from "@tumaet/apollon"
2+
3+
type RuntimeNodeState = {
4+
selected?: boolean
5+
dragging?: boolean
6+
resizing?: boolean
7+
}
8+
9+
type RuntimeEdgeState = {
10+
selected?: boolean
11+
}
12+
13+
export type TemplateRoutingState = "automatic" | "pinned" | "authored"
14+
15+
/**
16+
* Classify the routing authority stored on an edge.
17+
*
18+
* Authored bends outrank endpoint pins in the solver, so they do here as well.
19+
* Keeping this vocabulary outside the visual tests makes their fixture policy
20+
* match the editor's actual routing semantics.
21+
*/
22+
export const getTemplateEdgeRoutingState = (
23+
edge: UMLModel["edges"][number]
24+
): TemplateRoutingState => {
25+
if (Array.isArray(edge.data?.points) && edge.data.points.length > 0) {
26+
return "authored"
27+
}
28+
29+
if (edge.data?.sourceAnchor != null || edge.data?.targetAnchor != null) {
30+
return "pinned"
31+
}
32+
33+
return "automatic"
34+
}
35+
36+
/**
37+
* Turn a bundled template asset into a clean model instance.
38+
*
39+
* The source JSON was originally captured from a live React Flow session and
40+
* still contains transient selection/drag/resize flags. Those flags describe
41+
* the editor that saved the asset, not the preset a user is creating. Strip
42+
* them while preserving deliberate routing authority:
43+
*
44+
* - no anchors + no points: the holistic router owns the edge;
45+
* - sourceAnchor/targetAnchor: the chosen endpoint seats stay pinned;
46+
* - non-empty points: the authored bend topology stays authoritative.
47+
*
48+
* Missing legacy edge data is normalized to an empty automatic point list so a
49+
* newly created preset satisfies the current UMLModel contract immediately.
50+
*/
51+
export const prepareTemplateModel = (
52+
source: UMLModel,
53+
overrides: Partial<Pick<UMLModel, "id" | "title">> = {}
54+
): UMLModel => {
55+
const clone =
56+
typeof structuredClone === "function"
57+
? structuredClone(source)
58+
: (JSON.parse(JSON.stringify(source)) as UMLModel)
59+
60+
return {
61+
...clone,
62+
...overrides,
63+
nodes: clone.nodes.map((node) => {
64+
const persistentNode = {
65+
...node,
66+
} as UMLModel["nodes"][number] & RuntimeNodeState
67+
delete persistentNode.selected
68+
delete persistentNode.dragging
69+
delete persistentNode.resizing
70+
return persistentNode
71+
}),
72+
edges: clone.edges.map((edge) => {
73+
const persistentEdge = {
74+
...edge,
75+
} as UMLModel["edges"][number] & RuntimeEdgeState
76+
delete persistentEdge.selected
77+
return {
78+
...persistentEdge,
79+
data: {
80+
...(persistentEdge.data ?? {}),
81+
points: Array.isArray(persistentEdge.data?.points)
82+
? persistentEdge.data.points
83+
: [],
84+
},
85+
}
86+
}),
87+
}
88+
}

standalone/webapp/src/utils/templateThumbnails.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { UMLModel } from "@tumaet/apollon"
22
import { renderThumbnailSvgFromModel } from "@/utils/thumbnailSvg"
33
import { waitForIdle } from "@/utils/idle"
44
import { log } from "@/logger"
5+
import { prepareTemplateModel } from "@/utils/templateModels"
56

67
/**
78
* Renders the bundled design-pattern templates to preview SVGs for the New
@@ -33,9 +34,7 @@ const importTemplateModel = async (name: string): Promise<UMLModel> => {
3334
if (!jsonData) {
3435
throw new Error(`Template "${name}" not found`)
3536
}
36-
return typeof structuredClone === "function"
37-
? structuredClone(jsonData)
38-
: (JSON.parse(JSON.stringify(jsonData)) as UMLModel)
37+
return prepareTemplateModel(jsonData as UMLModel)
3938
}
4039

4140
const notify = (name: string, lightSvg: string | null) => {

standalone/webapp/tests/e2e/edge-auto-reset.spec.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import { test, expect, type Page } from "@playwright/test"
1+
import { test, expect } from "@playwright/test"
22
import * as fs from "node:fs"
33
import * as path from "node:path"
44
import { fileURLToPath } from "node:url"
5-
import { waitForCanvasReady, openFixtureInLocalEditor } from "../helpers/canvas"
5+
import {
6+
waitForCanvasReady,
7+
openFixtureInLocalEditor,
8+
selectEdgeOnPath,
9+
} from "../helpers/canvas"
610

711
/**
812
* The "Reset routing" affordance and the pinned-endpoint highlight.
@@ -21,23 +25,6 @@ const load = (name: string) =>
2125

2226
const pinnedFixture = load("edge-pinned-anchors.json")
2327

24-
async function selectEdgeOnPath(page: Page, id: string): Promise<void> {
25-
const pt = await page.evaluate((eid) => {
26-
const p = document.querySelector(
27-
`.react-flow__edge[data-id="${eid}"] path.react-flow__edge-path`
28-
) as SVGPathElement | null
29-
if (!p) return null
30-
const ctm = p.getScreenCTM()
31-
if (!ctm) return null
32-
const q = p.getPointAtLength(p.getTotalLength() / 2)
33-
const m = new DOMPoint(q.x, q.y).matrixTransform(ctm)
34-
return { x: m.x, y: m.y }
35-
}, id)
36-
if (!pt) throw new Error(`edge ${id} path not found`)
37-
await page.mouse.click(pt.x, pt.y)
38-
await page.waitForTimeout(200)
39-
}
40-
4128
test("a pinned-anchor edge exposes the reset button and highlights both anchored ends", async ({
4229
page,
4330
}) => {

standalone/webapp/tests/helpers/canvas.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,76 @@ export async function openFixtureInLocalEditor(
9595
await injectFixtureIntoLocalStorage(page, fixture)
9696
await page.goto(`/local/${fixture.id as string}`)
9797
}
98+
99+
/**
100+
* Create one of the bundled presets through the real dashboard dialog.
101+
*
102+
* Template visual tests use this instead of injecting the asset directly:
103+
* cloning, transient-state cleanup, fresh-id assignment, persistence and route
104+
* navigation are all part of what a user sees when choosing a preset.
105+
*/
106+
export async function createTemplateInLocalEditor(
107+
page: Page,
108+
templateName: string
109+
) {
110+
await page.addInitScript(() => {
111+
localStorage.setItem(
112+
"persistenceModelStore",
113+
JSON.stringify({
114+
state: { models: {}, currentModelId: null },
115+
version: 3,
116+
})
117+
)
118+
})
119+
await page.goto("/")
120+
await page
121+
.getByRole("heading", { level: 1, name: "Your diagrams" })
122+
.waitFor({ timeout: 15_000 })
123+
124+
await page.getByRole("button", { name: "New diagram" }).first().click()
125+
const dialog = page.getByRole("dialog")
126+
await dialog.getByRole("tab", { name: "Use template" }).click()
127+
await dialog.getByRole("button", { name: templateName, exact: true }).click()
128+
await dialog.getByRole("button", { name: "Create Diagram" }).click()
129+
130+
await page.waitForURL(/\/local\/[^/]+$/, { timeout: 15_000 })
131+
await waitForCanvasReady(page)
132+
133+
return page.evaluate(() => {
134+
const persisted = localStorage.getItem("persistenceModelStore")
135+
if (!persisted) return null
136+
137+
const state = JSON.parse(persisted).state as {
138+
currentModelId?: string
139+
models?: Record<string, { model?: Record<string, unknown> }>
140+
}
141+
const currentId = state.currentModelId
142+
return currentId ? (state.models?.[currentId]?.model ?? null) : null
143+
})
144+
}
145+
146+
/** Select an edge through the visible middle of its rendered path. */
147+
export async function selectEdgeOnPath(page: Page, edgeId: string) {
148+
const point = await page.evaluate((id) => {
149+
const path = document.querySelector(
150+
`.react-flow__edge[data-id="${id}"] path.react-flow__edge-path`
151+
) as SVGPathElement | null
152+
if (!path) return null
153+
154+
const transform = path.getScreenCTM()
155+
if (!transform) return null
156+
157+
const midpoint = path.getPointAtLength(path.getTotalLength() / 2)
158+
const screenPoint = new DOMPoint(midpoint.x, midpoint.y).matrixTransform(
159+
transform
160+
)
161+
return { x: screenPoint.x, y: screenPoint.y }
162+
}, edgeId)
163+
164+
if (!point) {
165+
throw new Error(`Edge "${edgeId}" path was not rendered`)
166+
}
167+
168+
await page.mouse.click(point.x, point.y)
169+
await page.waitForTimeout(200)
170+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { UMLModel } from "@tumaet/apollon"
2+
import adapter from "../../assets/diagramTemplates/Adapter.json"
3+
import bridge from "../../assets/diagramTemplates/Bridge.json"
4+
import command from "../../assets/diagramTemplates/Command.json"
5+
import factory from "../../assets/diagramTemplates/Factory.json"
6+
import observer from "../../assets/diagramTemplates/Observer.json"
7+
import { describe, expect, it } from "vitest"
8+
import {
9+
getTemplateEdgeRoutingState,
10+
prepareTemplateModel,
11+
} from "../../src/utils/templateModels"
12+
13+
const templates = [
14+
["Adapter", adapter],
15+
["Bridge", bridge],
16+
["Command", command],
17+
["Factory", factory],
18+
["Observer", observer],
19+
] as const
20+
21+
describe("diagram template routing policy", () => {
22+
it.each(templates)(
23+
"%s deliberately leaves every edge under automatic routing",
24+
(_name, source) => {
25+
const model = source as unknown as UMLModel
26+
27+
expect(model.edges).not.toHaveLength(0)
28+
expect(model.edges.map(getTemplateEdgeRoutingState)).toEqual(
29+
model.edges.map(() => "automatic")
30+
)
31+
}
32+
)
33+
34+
it.each(templates)(
35+
"%s creates a clean model without mutating its bundled source",
36+
(name, source) => {
37+
const sourceSnapshot = JSON.stringify(source)
38+
const model = prepareTemplateModel(source as unknown as UMLModel, {
39+
id: `created-${name.toLowerCase()}`,
40+
title: name,
41+
})
42+
43+
expect(model.id).toBe(`created-${name.toLowerCase()}`)
44+
expect(model.title).toBe(name)
45+
expect(model.nodes).not.toHaveLength(0)
46+
expect(model.edges).not.toHaveLength(0)
47+
expect(
48+
model.nodes.some(
49+
(node) =>
50+
"selected" in node || "dragging" in node || "resizing" in node
51+
)
52+
).toBe(false)
53+
expect(model.edges.some((edge) => "selected" in edge)).toBe(false)
54+
expect(model.edges.every((edge) => Array.isArray(edge.data.points))).toBe(
55+
true
56+
)
57+
expect(JSON.stringify(source)).toBe(sourceSnapshot)
58+
}
59+
)
60+
61+
it("preserves intentional endpoint pins and authored bends", () => {
62+
const source = structuredClone(adapter) as unknown as UMLModel
63+
source.edges[0].data = {
64+
points: [],
65+
sourceAnchor: { side: "right", ratio: 0.25 },
66+
}
67+
source.edges[1].data = {
68+
points: [
69+
{ x: 120, y: 330 },
70+
{ x: 120, y: 250 },
71+
{ x: 365, y: 250 },
72+
{ x: 365, y: 190 },
73+
],
74+
}
75+
76+
const model = prepareTemplateModel(source)
77+
78+
expect(getTemplateEdgeRoutingState(model.edges[0])).toBe("pinned")
79+
expect(model.edges[0].data.sourceAnchor).toEqual({
80+
side: "right",
81+
ratio: 0.25,
82+
})
83+
expect(getTemplateEdgeRoutingState(model.edges[1])).toBe("authored")
84+
expect(model.edges[1].data.points).toEqual(source.edges[1].data.points)
85+
})
86+
})

0 commit comments

Comments
 (0)