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:
@@ -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:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
129
apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx
Normal file
129
apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
apps/web/src/modules/diagram/components/editor/EditorHeader.tsx
Normal file
126
apps/web/src/modules/diagram/components/editor/EditorHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
289
apps/web/src/modules/diagram/lib/graph-converter.test.ts
Normal file
289
apps/web/src/modules/diagram/lib/graph-converter.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
76
apps/web/src/modules/diagram/lib/graph-converter.ts
Normal file
76
apps/web/src/modules/diagram/lib/graph-converter.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
124
apps/web/src/modules/diagram/stores/useGraphStore.test.ts
Normal file
124
apps/web/src/modules/diagram/stores/useGraphStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/web/src/modules/diagram/stores/useGraphStore.ts
Normal file
65
apps/web/src/modules/diagram/stores/useGraphStore.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
64
apps/web/src/modules/diagram/types/graph.ts
Normal file
64
apps/web/src/modules/diagram/types/graph.ts
Normal 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
14
apps/web/vitest.config.ts
Normal 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"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user