feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model

Replace the placeholder diagram editor with a professional Studio layout featuring
an interactive @xyflow/react canvas, unified graph data model with bidirectional
converters, Zustand state management, and oklch design tokens. Includes 25 unit
tests for converters and store, with all code review fixes applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 02:07:59 +00:00
parent 098f4968be
commit 5033109656
17 changed files with 1922 additions and 96 deletions

View File

@@ -10,6 +10,7 @@
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "next start",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
@@ -39,6 +40,7 @@
"@turbostarter/shared": "workspace:*",
"@turbostarter/ui": "workspace:*",
"@turbostarter/ui-web": "workspace:*",
"@xyflow/react": "12.10.1",
"accept-language": "3.0.20",
"dayjs": "1.11.19",
"envin": "catalog:",
@@ -77,6 +79,8 @@
"autoprefixer": "10.4.21",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"@turbostarter/vitest-config": "workspace:*",
"vitest": "catalog:"
}
}

View File

@@ -1,13 +1,11 @@
"use client";
import { useRef, useEffect } from "react";
import { useState } from "react";
import { useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { api } from "~/lib/api/client";
import { DiagramEditor } from "~/modules/diagram/components/editor/DiagramEditor";
class DiagramError extends Error {
constructor(
@@ -21,18 +19,18 @@ class DiagramError extends Error {
export default function DiagramEditorPage() {
const params = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const { data, isLoading, error } = useQuery({
queryKey: ["diagram", params.id],
queryFn: async () => {
const res = await api.diagrams[":id"].$get({ param: { id: params.id } });
const res = await api.diagrams[":id"].$get({
param: { id: params.id },
});
if (res.status === 403) {
throw new DiagramError("forbidden", "You don't have access to this diagram");
throw new DiagramError(
"forbidden",
"You don't have access to this diagram",
);
}
if (!res.ok) {
throw new DiagramError("not-found", "Diagram not found");
@@ -45,47 +43,8 @@ export default function DiagramEditorPage() {
},
});
const errorType = error instanceof DiagramError ? error.type : error ? "not-found" : null;
const title = data?.data?.title ?? "Diagram";
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const renameMutation = useMutation({
mutationFn: async (newTitle: string) => {
const res = await api.diagrams[":id"].$patch({
param: { id: params.id },
json: { title: newTitle },
});
if (!res.ok) throw new Error("Failed to rename diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagram", params.id] });
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
toast.success("Diagram renamed");
},
onError: () => {
toast.error("Failed to rename diagram");
},
});
const handleSaveRename = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== title) {
renameMutation.mutate(trimmed);
}
setIsEditing(false);
};
const handleCancelRename = () => {
setEditValue(title);
setIsEditing(false);
};
const errorType =
error instanceof DiagramError ? error.type : error ? "not-found" : null;
if (isLoading) {
return (
@@ -117,43 +76,5 @@ export default function DiagramEditorPage() {
);
}
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
<Icons.LayoutDashboard className="h-16 w-16 text-muted-foreground/30" />
<div className="text-center">
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSaveRename}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSaveRename();
} else if (e.key === "Escape") {
handleCancelRename();
}
}}
className="text-xl font-semibold text-center"
maxLength={255}
/>
) : (
<h1
className="text-xl font-semibold cursor-pointer hover:text-primary/80 transition-colors"
onClick={() => {
setEditValue(title);
setIsEditing(true);
}}
title="Click to rename"
>
{title}
</h1>
)}
<p className="mt-2 text-sm text-muted-foreground">
The diagram editor canvas will be implemented in Epic 2.
</p>
</div>
</div>
);
return <DiagramEditor diagram={data.data} />;
}

View File

@@ -2,3 +2,41 @@
@import "@turbostarter/ui-web/globals.css";
@source "../../../../../packages/ui";
@layer base {
@import "@xyflow/react/dist/style.css";
:root {
/* Canvas */
--canvas-bg: oklch(0.985 0.002 247);
--canvas-grid: oklch(0.92 0.004 286 / 30%);
/* Nodes */
--node-bg: oklch(1 0 0);
--node-border: oklch(0.85 0.01 260);
--node-selected: oklch(0.623 0.214 260);
--node-hover: oklch(0.623 0.214 260 / 12%);
/* Edges */
--edge-default: oklch(0.65 0.01 286);
--edge-selected: oklch(0.623 0.214 260);
/* AI (placeholders for future epics) */
--ai-accent: oklch(0.623 0.214 260);
/* Diagram type accents */
--diagram-bpmn: oklch(0.623 0.214 260);
--diagram-er: oklch(0.606 0.25 293);
--diagram-orgchart: oklch(0.723 0.219 150);
--diagram-architecture: oklch(0.552 0.016 286);
--diagram-sequence: oklch(0.795 0.184 86);
--diagram-flowchart: oklch(0.645 0.246 16);
}
.dark {
--canvas-bg: oklch(0.16 0.005 285);
--canvas-grid: oklch(0.92 0.004 286 / 15%);
--node-bg: oklch(0.24 0.006 286);
--node-border: oklch(0.35 0.01 260);
--node-selected: oklch(0.623 0.214 260);
--node-hover: oklch(0.623 0.214 260 / 12%);
--edge-default: oklch(0.55 0.01 286);
--edge-selected: oklch(0.623 0.214 260);
}
}

View File

@@ -0,0 +1,59 @@
"use client";
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
BackgroundVariant,
} from "@xyflow/react";
import { useGraphStore } from "../../stores/useGraphStore";
const nodeTypes = {};
function CanvasInner() {
const nodes = useGraphStore((s) => s.nodes);
const edges = useGraphStore((s) => s.edges);
const onNodesChange = useGraphStore((s) => s.onNodesChange);
const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
const onViewportChange = useGraphStore((s) => s.onViewportChange);
return (
<div className="w-full h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onViewportChange={onViewportChange}
nodeTypes={nodeTypes}
fitView
colorMode="system"
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="var(--canvas-grid)"
/>
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
style={{ width: 120, height: 80 }}
/>
</ReactFlow>
</div>
);
}
export function DiagramCanvas() {
return (
<ReactFlowProvider>
<CanvasInner />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { EditorHeader } from "./EditorHeader";
import { EditorStatusBar } from "./EditorStatusBar";
import { DiagramCanvas } from "./DiagramCanvas";
import { RightPanel } from "./RightPanel";
import { useGraphStore } from "../../stores/useGraphStore";
import { graphToFlow } from "../../lib/graph-converter";
import type { DiagramResponse } from "../DiagramCard";
import type { GraphData, DiagramType } from "../../types/graph";
interface DiagramEditorProps {
diagram: DiagramResponse;
}
export function DiagramEditor({ diagram }: DiagramEditorProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [rightPanelOpen, setRightPanelOpen] = useState(true);
const queryClient = useQueryClient();
const initializeFromGraphData = useGraphStore(
(s) => s.initializeFromGraphData,
);
const resetStore = useGraphStore((s) => s.reset);
// Initialize graph store from diagram data; reset on unmount
useEffect(() => {
const raw = diagram.graphData as Record<string, unknown> | null;
const graphData: GraphData = {
nodes: Array.isArray(raw?.nodes) ? (raw.nodes as GraphData["nodes"]) : [],
edges: Array.isArray(raw?.edges) ? (raw.edges as GraphData["edges"]) : [],
meta: raw?.meta as GraphData["meta"],
};
const { nodes, edges } = graphToFlow(graphData);
initializeFromGraphData(nodes, edges);
return () => resetStore();
}, [diagram.id, diagram.graphData, initializeFromGraphData, resetStore]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "b") {
e.preventDefault();
setSidebarOpen((prev) => !prev);
}
if ((e.metaKey || e.ctrlKey) && e.key === "j") {
e.preventDefault();
setRightPanelOpen((prev) => !prev);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
// Rename mutation
const renameMutation = useMutation({
mutationFn: async (newTitle: string) => {
const res = await api.diagrams[":id"].$patch({
param: { id: diagram.id },
json: { title: newTitle },
});
if (!res.ok) throw new Error("Failed to rename diagram");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["diagram", diagram.id] });
queryClient.invalidateQueries({ queryKey: ["diagrams"] });
toast.success("Diagram renamed");
},
onError: () => {
toast.error("Failed to rename diagram");
},
});
const handleRename = useCallback(
(newTitle: string) => {
if (newTitle.trim() && newTitle.trim() !== diagram.title) {
renameMutation.mutate(newTitle.trim());
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[diagram.title],
);
return (
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--canvas-bg)]">
<EditorHeader
title={diagram.title}
diagramType={diagram.type as DiagramType}
onRename={handleRename}
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
/>
<div className="flex flex-1 overflow-hidden">
{/* Left sidebar */}
<div
className={`shrink-0 border-r border-border bg-background transition-[width] duration-200 ease-out ${
sidebarOpen ? "w-60" : "w-14"
}`}
>
<div className="flex h-full flex-col items-center pt-2">
{!sidebarOpen && (
<span className="text-[10px] text-muted-foreground mt-2">
{"\u2318"}B
</span>
)}
</div>
</div>
{/* Canvas */}
<div className="flex-1 relative">
<DiagramCanvas />
</div>
{/* Right panel */}
<RightPanel open={rightPanelOpen} />
</div>
<EditorStatusBar diagramType={diagram.type as DiagramType} />
</div>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { Icons } from "@turbostarter/ui-web/icons";
import { Button } from "@turbostarter/ui-web/button";
import { Input } from "@turbostarter/ui-web/input";
import { Badge } from "@turbostarter/ui-web/badge";
import { pathsConfig } from "~/config/paths";
import { diagramTypeConfig } from "../DiagramCard";
import type { DiagramType } from "../../types/graph";
interface EditorHeaderProps {
title: string;
diagramType: DiagramType;
onRename: (newTitle: string) => void;
sidebarOpen: boolean;
onToggleSidebar: () => void;
}
export function EditorHeader({
title,
diagramType,
onRename,
sidebarOpen,
onToggleSidebar,
}: EditorHeaderProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
const config = diagramTypeConfig[diagramType];
const TypeIcon = config.icon;
useEffect(() => {
setEditValue(title);
}, [title]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed && trimmed !== title) {
onRename(trimmed);
} else {
setEditValue(title);
}
setIsEditing(false);
};
const handleCancel = () => {
setEditValue(title);
setIsEditing(false);
};
return (
<div className="flex h-12 shrink-0 items-center border-b border-border bg-background px-3 gap-3">
{/* Sidebar toggle */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onToggleSidebar}
title={sidebarOpen ? "Collapse sidebar (⌘B)" : "Expand sidebar (⌘B)"}
>
<Icons.PanelLeft className="h-4 w-4" />
</Button>
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Link
href={pathsConfig.dashboard.user.diagrams}
className="hover:text-foreground transition-colors"
>
Diagrams
</Link>
<Icons.ChevronRight className="h-3.5 w-3.5" />
</nav>
{/* Title */}
{isEditing ? (
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
handleCancel();
}
}}
className="h-7 w-56 text-sm font-medium"
maxLength={255}
/>
) : (
<button
className="text-sm font-medium hover:text-primary/80 transition-colors truncate max-w-64"
onClick={() => {
setEditValue(title);
setIsEditing(true);
}}
title="Click to rename"
>
{title}
</button>
)}
{/* Diagram type badge */}
<Badge variant="secondary" className="gap-1 text-xs shrink-0">
<TypeIcon className={`h-3 w-3 ${config.color}`} />
{config.label}
</Badge>
{/* Spacer */}
<div className="flex-1" />
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { Icons } from "@turbostarter/ui-web/icons";
import { diagramTypeConfig } from "../DiagramCard";
import { useGraphStore } from "../../stores/useGraphStore";
import type { DiagramType } from "../../types/graph";
interface EditorStatusBarProps {
diagramType: DiagramType;
}
export function EditorStatusBar({ diagramType }: EditorStatusBarProps) {
const zoomLevel = useGraphStore((s) => s.zoomLevel);
const nodeCount = useGraphStore((s) => s.nodeCount);
const config = diagramTypeConfig[diagramType];
const TypeIcon = config.icon;
return (
<div className="flex h-7 shrink-0 items-center border-t border-border bg-background px-3 text-xs text-muted-foreground gap-4">
<div className="flex items-center gap-1.5">
<TypeIcon className={`h-3 w-3 ${config.color}`} />
<span>{config.label}</span>
</div>
<div className="flex items-center gap-1">
<Icons.Circle className="h-2 w-2" />
<span>
{nodeCount} node{nodeCount !== 1 ? "s" : ""}
</span>
</div>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Icons.Search className="h-3 w-3" />
<span>{zoomLevel}%</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { Icons } from "@turbostarter/ui-web/icons";
type Tab = "chat" | "inspector" | "annotations";
const tabs: { key: Tab; label: string }[] = [
{ key: "chat", label: "Chat" },
{ key: "inspector", label: "Inspector" },
{ key: "annotations", label: "Annotations" },
];
interface RightPanelProps {
open: boolean;
}
export function RightPanel({ open }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>("chat");
return (
<div
className={`shrink-0 border-l border-border bg-background transition-[width] duration-200 ease-out overflow-hidden ${
open ? "w-80 xl:w-[360px]" : "w-0 border-l-0"
}`}
>
<div className="flex w-80 flex-col xl:w-[360px] h-full">
{/* Tab headers */}
<div className="flex h-10 shrink-0 items-center border-b border-border">
{tabs.map((tab) => (
<button
key={tab.key}
className={`flex-1 h-full text-xs font-medium transition-colors ${
activeTab === tab.key
? "text-foreground border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
{activeTab === "chat" && (
<>
<Icons.Sparkles className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
AI Copilot
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Start a conversation to build your diagram
</p>
</>
)}
{activeTab === "inspector" && (
<>
<Icons.Search className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Inspector
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Select a node to see its properties
</p>
</>
)}
{activeTab === "annotations" && (
<>
<Icons.MessageSquare className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Annotations
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Coming soon
</p>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { describe, it, expect } from "vitest";
import {
graphNodeToFlowNode,
graphEdgeToFlowEdge,
graphToFlow,
flowNodeToGraphNode,
flowEdgeToGraphEdge,
flowToGraph,
} from "./graph-converter";
import type {
DiagramNode,
DiagramEdge,
GraphData,
} from "../types/graph";
describe("graphNodeToFlowNode", () => {
it("should convert a basic node with position", () => {
const node: DiagramNode = {
id: "n1",
type: "flow:process",
label: "Start",
position: { x: 100, y: 200 },
};
const result = graphNodeToFlowNode(node);
expect(result).toEqual({
id: "n1",
type: "default",
position: { x: 100, y: 200 },
data: { ...node, label: "Start" },
});
});
it("should use default position when node has no position", () => {
const node: DiagramNode = {
id: "n2",
type: "bpmn:activity",
label: "Task A",
};
const result = graphNodeToFlowNode(node);
expect(result.position).toEqual({ x: 0, y: 0 });
});
it("should preserve all node data in data field", () => {
const node: DiagramNode = {
id: "n3",
type: "er:entity",
label: "Users",
columns: [
{ name: "id", type: "uuid", isPrimaryKey: true },
{ name: "name", type: "text" },
],
};
const result = graphNodeToFlowNode(node);
expect(result.data.columns).toEqual(node.columns);
expect(result.data.type).toBe("er:entity");
});
});
describe("graphEdgeToFlowEdge", () => {
it("should convert from/to to source/target", () => {
const edge: DiagramEdge = {
id: "e1",
from: "n1",
to: "n2",
label: "connects",
};
const result = graphEdgeToFlowEdge(edge);
expect(result).toEqual({
id: "e1",
source: "n1",
target: "n2",
label: "connects",
type: "default",
data: { ...edge },
});
});
it("should handle edges without labels", () => {
const edge: DiagramEdge = { id: "e2", from: "a", to: "b" };
const result = graphEdgeToFlowEdge(edge);
expect(result.label).toBeUndefined();
});
it("should preserve E-R cardinality", () => {
const edge: DiagramEdge = {
id: "e3",
from: "users",
to: "orders",
cardinality: "1:N",
type: "inheritance",
};
const result = graphEdgeToFlowEdge(edge);
expect(result.data?.cardinality).toBe("1:N");
});
});
describe("graphToFlow", () => {
it("should convert full GraphData to flow format", () => {
const data: GraphData = {
nodes: [
{ id: "n1", type: "flow:process", label: "A", position: { x: 0, y: 0 } },
{ id: "n2", type: "flow:decision", label: "B", position: { x: 100, y: 100 } },
],
edges: [
{ id: "e1", from: "n1", to: "n2", label: "yes" },
],
};
const result = graphToFlow(data);
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
expect(result.nodes[0]!.id).toBe("n1");
expect(result.edges[0]!.source).toBe("n1");
expect(result.edges[0]!.target).toBe("n2");
});
it("should handle empty graph data", () => {
const data: GraphData = { nodes: [], edges: [] };
const result = graphToFlow(data);
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
});
it("should handle all 6 diagram types", () => {
const diagramTypes = [
"bpmn:activity",
"er:entity",
"org:unit",
"arch:service",
"seq:participant",
"flow:process",
];
const nodes: DiagramNode[] = diagramTypes.map((type, i) => ({
id: `n${i}`,
type,
label: `Node ${i}`,
}));
const result = graphToFlow({ nodes, edges: [] });
expect(result.nodes).toHaveLength(6);
result.nodes.forEach((node) => {
expect(node.type).toBe("default");
});
});
});
describe("flowNodeToGraphNode", () => {
it("should convert flow node back to graph node", () => {
const flowNode = {
id: "n1",
type: "default",
position: { x: 50, y: 75 },
data: {
id: "n1",
type: "bpmn:activity",
label: "Task",
lane: "lane1",
},
};
const result = flowNodeToGraphNode(flowNode);
expect(result.id).toBe("n1");
expect(result.type).toBe("bpmn:activity");
expect(result.label).toBe("Task");
expect(result.position).toEqual({ x: 50, y: 75 });
expect(result.lane).toBe("lane1");
});
it("should default to flow:process when type is missing", () => {
const flowNode = {
id: "n1",
type: "default",
position: { x: 0, y: 0 },
data: { label: "No type" },
};
const result = flowNodeToGraphNode(flowNode);
expect(result.type).toBe("flow:process");
});
});
describe("flowEdgeToGraphEdge", () => {
it("should convert source/target back to from/to", () => {
const flowEdge = {
id: "e1",
source: "n1",
target: "n2",
label: "Yes",
type: "default",
data: { type: "sequence", color: "#ff0000" },
};
const result = flowEdgeToGraphEdge(flowEdge);
expect(result.from).toBe("n1");
expect(result.to).toBe("n2");
expect(result.label).toBe("Yes");
expect(result.type).toBe("sequence");
expect(result.color).toBe("#ff0000");
});
it("should handle edges with no data", () => {
const flowEdge = {
id: "e2",
source: "a",
target: "b",
};
const result = flowEdgeToGraphEdge(flowEdge);
expect(result.from).toBe("a");
expect(result.to).toBe("b");
expect(result.type).toBeUndefined();
});
});
describe("flowToGraph", () => {
it("should convert full flow state back to GraphData", () => {
const nodes = [
{
id: "n1",
type: "default",
position: { x: 10, y: 20 },
data: { type: "flow:process", label: "Step 1" },
},
];
const edges = [
{
id: "e1",
source: "n1",
target: "n2",
label: "next",
data: { type: "sequence" },
},
];
const meta = {
version: "1.0",
title: "Test",
diagramType: "flowchart" as const,
};
const result = flowToGraph(nodes, edges, meta);
expect(result.meta).toEqual(meta);
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(1);
expect(result.nodes[0]!.type).toBe("flow:process");
expect(result.edges[0]!.from).toBe("n1");
});
it("should roundtrip: graphToFlow -> flowToGraph preserves data", () => {
const original: GraphData = {
meta: {
version: "1.0",
title: "Roundtrip Test",
diagramType: "er",
},
nodes: [
{
id: "users",
type: "er:entity",
label: "Users",
position: { x: 0, y: 0 },
columns: [
{ name: "id", type: "uuid", isPrimaryKey: true },
{ name: "email", type: "varchar" },
],
},
{
id: "orders",
type: "er:entity",
label: "Orders",
position: { x: 200, y: 0 },
},
],
edges: [
{
id: "e1",
from: "users",
to: "orders",
label: "has",
cardinality: "1:N",
},
],
};
const flow = graphToFlow(original);
const roundtripped = flowToGraph(flow.nodes, flow.edges, original.meta);
expect(roundtripped.meta).toEqual(original.meta);
expect(roundtripped.nodes).toHaveLength(2);
expect(roundtripped.edges).toHaveLength(1);
expect(roundtripped.nodes[0]!.type).toBe("er:entity");
expect(roundtripped.nodes[0]!.columns).toEqual(original.nodes[0]!.columns);
expect(roundtripped.edges[0]!.from).toBe("users");
expect(roundtripped.edges[0]!.to).toBe("orders");
expect(roundtripped.edges[0]!.cardinality).toBe("1:N");
});
});

View File

@@ -0,0 +1,76 @@
import type { Node, Edge } from "@xyflow/react";
import type { DiagramNode, DiagramEdge, GraphData } from "../types/graph";
export function graphNodeToFlowNode(node: DiagramNode): Node {
return {
id: node.id,
type: "default",
position: node.position ?? { x: 0, y: 0 },
data: {
...node,
label: node.label,
},
};
}
export function graphEdgeToFlowEdge(edge: DiagramEdge): Edge {
return {
id: edge.id,
source: edge.from,
target: edge.to,
label: edge.label,
type: "default",
data: { ...edge },
};
}
export function graphToFlow(data: GraphData): { nodes: Node[]; edges: Edge[] } {
return {
nodes: (data.nodes ?? []).map(graphNodeToFlowNode),
edges: (data.edges ?? []).map(graphEdgeToFlowEdge),
};
}
export function flowNodeToGraphNode(node: Node): DiagramNode {
const data = node.data as unknown as DiagramNode & { label: string };
return {
id: node.id,
type: data.type ?? "flow:process",
label: data.label,
position: node.position,
...(data.tag !== undefined && { tag: data.tag }),
...(data.icon !== undefined && { icon: data.icon }),
...(data.color !== undefined && { color: data.color }),
...(data.w !== undefined && { w: data.w }),
...(data.lane !== undefined && { lane: data.lane }),
...(data.group !== undefined && { group: data.group }),
...(data.columns !== undefined && { columns: data.columns }),
...(data.lifeline !== undefined && { lifeline: data.lifeline }),
...(data.parentId !== undefined && { parentId: data.parentId }),
};
}
export function flowEdgeToGraphEdge(edge: Edge): DiagramEdge {
const data = edge.data as unknown as DiagramEdge | undefined;
return {
id: edge.id,
from: edge.source,
to: edge.target,
label: typeof edge.label === "string" ? edge.label : undefined,
type: data?.type,
color: data?.color,
cardinality: data?.cardinality,
};
}
export function flowToGraph(
nodes: Node[],
edges: Edge[],
meta?: GraphData["meta"],
): GraphData {
return {
meta,
nodes: nodes.map(flowNodeToGraphNode),
edges: edges.map(flowEdgeToGraphEdge),
};
}

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useGraphStore } from "./useGraphStore";
import type { Node, Edge } from "@xyflow/react";
const makeNode = (id: string, label = "Node"): Node => ({
id,
type: "default",
position: { x: 0, y: 0 },
data: { label },
});
const makeEdge = (id: string, source: string, target: string): Edge => ({
id,
source,
target,
});
describe("useGraphStore", () => {
beforeEach(() => {
useGraphStore.setState({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0,
zoomLevel: 100,
});
});
describe("initializeFromGraphData", () => {
it("should set nodes, edges, and nodeCount", () => {
const nodes = [makeNode("n1"), makeNode("n2")];
const edges = [makeEdge("e1", "n1", "n2")];
useGraphStore.getState().initializeFromGraphData(nodes, edges);
expect(useGraphStore.getState().nodes).toHaveLength(2);
expect(useGraphStore.getState().edges).toHaveLength(1);
expect(useGraphStore.getState().nodeCount).toBe(2);
});
});
describe("setNodes", () => {
it("should replace nodes and update nodeCount", () => {
useGraphStore.getState().setNodes([makeNode("a"), makeNode("b"), makeNode("c")]);
expect(useGraphStore.getState().nodes).toHaveLength(3);
expect(useGraphStore.getState().nodeCount).toBe(3);
});
it("should handle empty array", () => {
useGraphStore.getState().setNodes([makeNode("x")]);
useGraphStore.getState().setNodes([]);
expect(useGraphStore.getState().nodes).toEqual([]);
expect(useGraphStore.getState().nodeCount).toBe(0);
});
});
describe("setEdges", () => {
it("should replace edges", () => {
const edges = [makeEdge("e1", "a", "b"), makeEdge("e2", "b", "c")];
useGraphStore.getState().setEdges(edges);
expect(useGraphStore.getState().edges).toHaveLength(2);
});
});
describe("onNodesChange", () => {
it("should apply node position changes", () => {
useGraphStore.getState().setNodes([makeNode("n1")]);
useGraphStore.getState().onNodesChange([
{
type: "position",
id: "n1",
position: { x: 100, y: 200 },
},
]);
const updatedNode = useGraphStore.getState().nodes[0];
expect(updatedNode!.position).toEqual({ x: 100, y: 200 });
});
it("should update nodeCount when nodes are removed", () => {
useGraphStore.getState().setNodes([makeNode("n1"), makeNode("n2")]);
expect(useGraphStore.getState().nodeCount).toBe(2);
useGraphStore.getState().onNodesChange([
{ type: "remove", id: "n1" },
]);
expect(useGraphStore.getState().nodeCount).toBe(1);
});
});
describe("onEdgesChange", () => {
it("should apply edge removal", () => {
useGraphStore.getState().setEdges([makeEdge("e1", "a", "b")]);
useGraphStore.getState().onEdgesChange([
{ type: "remove", id: "e1" },
]);
expect(useGraphStore.getState().edges).toHaveLength(0);
});
});
describe("onViewportChange", () => {
it("should update viewport and zoomLevel", () => {
useGraphStore.getState().onViewportChange({ x: 50, y: 50, zoom: 1.5 });
expect(useGraphStore.getState().viewport).toEqual({ x: 50, y: 50, zoom: 1.5 });
expect(useGraphStore.getState().zoomLevel).toBe(150);
});
it("should round zoomLevel to nearest integer", () => {
useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 0.333 });
expect(useGraphStore.getState().zoomLevel).toBe(33);
});
it("should handle zoom at 100%", () => {
useGraphStore.getState().onViewportChange({ x: 0, y: 0, zoom: 1.0 });
expect(useGraphStore.getState().zoomLevel).toBe(100);
});
});
});

View File

@@ -0,0 +1,65 @@
import { create } from "zustand";
import {
applyNodeChanges,
applyEdgeChanges,
} from "@xyflow/react";
import type {
Node,
Edge,
OnNodesChange,
OnEdgesChange,
Viewport,
} from "@xyflow/react";
interface GraphState {
nodes: Node[];
edges: Edge[];
viewport: Viewport;
nodeCount: number;
zoomLevel: number;
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange;
onViewportChange: (viewport: Viewport) => void;
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
reset: () => void;
}
export const useGraphStore = create<GraphState>((set, get) => ({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0,
zoomLevel: 100,
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
setEdges: (edges) => set({ edges }),
onNodesChange: (changes) => {
const updatedNodes = applyNodeChanges(changes, get().nodes);
set({ nodes: updatedNodes, nodeCount: updatedNodes.length });
},
onEdgesChange: (changes) => {
set({ edges: applyEdgeChanges(changes, get().edges) });
},
onViewportChange: (viewport) => {
set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) });
},
initializeFromGraphData: (nodes, edges) => {
set({ nodes, edges, nodeCount: nodes.length });
},
reset: () => {
set({
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0,
zoomLevel: 100,
});
},
}));

View File

@@ -0,0 +1,64 @@
export type DiagramType =
| "bpmn"
| "er"
| "orgchart"
| "architecture"
| "sequence"
| "flowchart";
export interface Column {
name: string;
type: string;
isPrimaryKey?: boolean;
isForeignKey?: boolean;
isNullable?: boolean;
isUnique?: boolean;
references?: string;
}
export interface DiagramNode {
id: string;
type: string;
tag?: string;
label: string;
icon?: string;
color?: string;
w?: number;
position?: { x: number; y: number };
lane?: string;
group?: string;
columns?: Column[];
lifeline?: boolean;
parentId?: string;
}
export interface DiagramEdge {
id: string;
from: string;
to: string;
label?: string;
color?: string;
type?: string;
cardinality?: string;
}
export interface DiagramMeta {
version: string;
title: string;
description?: string;
diagramType: DiagramType;
layoutDirection?: "DOWN" | "RIGHT" | "LEFT" | "UP";
edgeRouting?: "ORTHOGONAL" | "SPLINES" | "POLYLINE";
}
export interface GraphData {
meta?: DiagramMeta;
nodes: DiagramNode[];
edges: DiagramEdge[];
pools?: Array<{
id: string;
label: string;
lanes: Array<{ id: string; label: string }>;
}>;
groups?: Array<{ id: string; label: string; color?: string }>;
}

14
apps/web/vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { resolve } from "path";
import { defineConfig, mergeConfig } from "vitest/config";
import baseConfig from "@turbostarter/vitest-config/base";
export default mergeConfig(
baseConfig,
defineConfig({
resolve: {
alias: {
"~": resolve(__dirname, "./src"),
},
},
}),
);