feat: Schema Visualizer, docs update & competitor comparison
* chore: add @xyflow/react, dagre, @types/dagre (Task 1) * feat: add SchemaGraph, TableNode, GraphColumn, Relationship types (Task 2) * feat: add SchemaGraph, TableNode, GraphColumn, Relationship Rust models (Task 3) * feat: add cardinality color/label helpers and legend data (Task 4) * feat: add schema_graph command skeleton with validation (Task 5) * feat: add PostgreSQL schema graph query builder + cardinality inference (Task 6) * feat: implement get_schema_graph for PostgreSQL + SQLite (Task 7) * feat: add getSchemaGraph IPC wrapper (Task 8) * feat: un-stub Schema Visualizer nav + add view branch placeholder (Task 9) * feat: add SchemaVisualizerNode custom React Flow table card (Task 10) * feat: add SchemaVisualizerPage with React Flow canvas (Task 11) * feat: wire SchemaVisualizerPage + error handling + style polish (Tasks 12-14) * perf: replace information_schema with pg_catalog for schema graph query (500x+ faster on remote PG) * fix: add DB selector, ghost-style dropdowns, dark controls, minimap styling, attribution * fix: always show schema selector, move attribution to top-left * fix: SelectDropdown close on outside click works in React Flow (capture phase) * style: remove border from attribution badge * style: restore bg on attribution, no border * fix: lower attribution z-index so dropdowns render above * fix: ensure toolbar + dropdowns stack above canvas attribution * feat: crow's foot markers on edges + collapsible legend * fix: restore missing tableCount state (was overwritten by legendOpen) * fix: move crow's foot marker defs inside ReactFlow SVG, remove duplicate external SVGs * fix: inject crow's foot SVG markers into ReactFlow SVG via DOM ref * fix: use hidden SVG before ReactFlow for crow's foot markers, remove DOM injection * feat: custom CrowsFootEdge component with inline crow's foot markers * feat: custom CrowsFootEdge with zero-or-one/zero-or-many notation + nullability-based cardinality * fix: strip markers, use clean text labels only on edges * fix: add SVG marker defs directly inside ReactFlow + url() references for crow's foot * fix: use custom CrowsFootEdge with BaseEdge + inline SVG symbols (no marker defs needed) * debug: add red/green circles at edge endpoints to verify custom edge renders * fix: remove stale duplicate edge data, use clean data.startMarker/endMarker * fix: use getSmoothStepPath offset points for correct tangent angle at endpoints * fix: thicker strokeWidth, position at handle coords, use path tangents * fix: use straight-line angle (not curve tangent) for marker rotation * fix: compute marker positions directly with raw math, no SVG transforms * fix: fixed-orientation symbols — | always vertical, crow's foot fans toward node * fix: dead simple — | vertical line, ← or → horizontal crow's foot based on edge direction * fix: increase marker gap to 12px so symbols aren't hidden behind handle dots * fix: correct offset direction (away from card into gap), G=4 * fix: remove duplicate G offset inside Mark (was canceling out the call-site offset) * fix: crow's foot back to fork shape — three lines converging to a tip * fix: flip crow's foot direction * fix: wider crow's foot spread (4→6) * feat: add crow's foot symbols to relationship legend * style: cleaner legend — horizontal edge with endpoint symbols + label * feat: handles on both sides, edge builder picks closest side based on dagre layout * fix: compute actual handle distances to pick shortest path * revert: PK always left, FK always right — one handle per column only * fix: lock edge marker direction via origRight, TB layout for horizontal spread, truncate long types * fix: semi-transparent minimap mask, border stroke for viewport visibility * feat: click edge to highlight (amber glow), all others dim to 15% opacity * feat: legend highlights matching cardinality row when edge is clicked * fix: crow's foot symbols now read color from edge style (amber when highlighted) * fix: highlighted edge gets zIndex 1000 to render on top * fix: folder empty message now checks unfiltered store, shows filter hint when connections exist but filtered out * feat: flat SVG DB icons from simple-icons (PostgreSQL, MySQL, SQLite, Redis) replacing emoji * chore: add *.db, *.sqlite, *.sqlite3 to .gitignore * docs: update README with 3-way competitor comparison + current roadmap; update AGENTS.md schema visualizer status
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { memo } from "react";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import { DB_ICONS, DB_LABELS } from "../../lib/dbIcons";
|
||||
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
|
||||
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
import { Check, GripVertical } from "lucide-react";
|
||||
@@ -75,8 +75,8 @@ function ConnectionCardBase({
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center text-xl">
|
||||
{DB_ICONS[connection.db_type] ?? "❓"}
|
||||
<div className="w-9 h-9 rounded-lg bg-surface-raised border border-border flex items-center justify-center overflow-hidden">
|
||||
<DbIcon type={connection.db_type} size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate text-text">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Connection, Folder, Tag } from "../../lib/types";
|
||||
import { useMemo } from "react";
|
||||
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { ConnectionCard } from "./ConnectionCard";
|
||||
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { getChildFolders, getDescendantFolderIds } from "../../lib/utils";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
|
||||
interface DroppableFolderCardProps {
|
||||
@@ -136,7 +138,14 @@ export function ConnectionGrid({
|
||||
const directConnections = connections.filter(
|
||||
(c) => c.folder_id === currentFolderId,
|
||||
);
|
||||
const hasItems = visibleFolders.length > 0 || directConnections.length > 0;
|
||||
const allStoreConnections = useConnectionStore((s) => s.connections);
|
||||
const allStoreFolders = useConnectionStore((s) => s.folders);
|
||||
// Check if any direct connections OR any subfolder has connections anywhere below
|
||||
const hasItems = visibleFolders.length > 0 || directConnections.length > 0 ||
|
||||
(currentFolderId && allStoreConnections.some((c) => {
|
||||
const allowed = new Set(getDescendantFolderIds(allStoreFolders, currentFolderId));
|
||||
return c.folder_id !== null && allowed.has(c.folder_id);
|
||||
}));
|
||||
const isSelecting = selectedItemIds.length > 0;
|
||||
const activeFolder = currentFolderId
|
||||
? (folders.find((f) => f.id === currentFolderId) ?? null)
|
||||
@@ -199,8 +208,10 @@ export function ConnectionGrid({
|
||||
>
|
||||
{visibleFolders.map((f) => {
|
||||
const isSelected = selectedItemIds.includes(f.id);
|
||||
const count = directConnections.filter(
|
||||
(c) => c.folder_id === f.id,
|
||||
// Count all connections in this subfolder (including nested descendants)
|
||||
const subIds = new Set(getDescendantFolderIds(folders, f.id));
|
||||
const count = allStoreConnections.filter(
|
||||
(c) => c.folder_id !== null && subIds.has(c.folder_id),
|
||||
).length;
|
||||
const subfolderCount = getChildFolders(
|
||||
folders,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEdge, getSmoothStepPath, type EdgeProps } from "@xyflow/react";
|
||||
|
||||
const C = "#3b82f6";
|
||||
const S = 10;
|
||||
const G = 4;
|
||||
|
||||
export function CrowsFootEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
style,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX, sourceY, sourcePosition,
|
||||
targetX, targetY, targetPosition,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
const sm = (data as any)?.startMarker as string;
|
||||
const em = (data as any)?.endMarker as string;
|
||||
// Use ORIGINAL layout direction stored in edge data — never re-compute.
|
||||
// Prevents symbols from flipping when user drags tables around.
|
||||
const origRight = (data as any)?.origRight as boolean | undefined;
|
||||
const right = origRight !== undefined ? origRight : targetX < sourceX ? false : true;
|
||||
const sOff = right ? 1 : -1;
|
||||
const tOff = right ? -1 : 1;
|
||||
const sDir = right ? 1 : -1;
|
||||
const tDir = right ? -1 : 1;
|
||||
|
||||
const edgeColor = (style as any)?.stroke as string || C;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<BaseEdge id={id} path={edgePath} style={{ stroke: edgeColor, strokeWidth: 1.5, ...style }} />
|
||||
{sm && <Mark type={sm} cx={sourceX + sOff * G} cy={sourceY} dir={sDir} color={edgeColor} />}
|
||||
{em && <Mark type={em} cx={targetX + tOff * G} cy={targetY} dir={tDir} color={edgeColor} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Mark({ type, cx, cy, dir, color }: { type: string; cx: number; cy: number; dir: number; color: string }) {
|
||||
if (type === "one") {
|
||||
return <line x1={cx} y1={cy - S} x2={cx} y2={cy + S} stroke={color} strokeWidth={2} strokeLinecap="round" />;
|
||||
}
|
||||
if (type === "many") {
|
||||
const sp = 6;
|
||||
const tx = cx + dir * S;
|
||||
return (
|
||||
<g>
|
||||
<line x1={cx} y1={cy - sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<line x1={cx} y1={cy} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
<line x1={cx} y1={cy + sp} x2={tx} y2={cy} stroke={color} strokeWidth={2} strokeLinecap="round" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
@@ -579,6 +580,13 @@ export function DbViewerScreen({
|
||||
<RestorePage connectionId={connectionId} />
|
||||
) : currentView === "sync" ? (
|
||||
<SyncPage />
|
||||
) : currentView === "schema-visualizer" ? (
|
||||
<SchemaVisualizerPage
|
||||
connectionId={connectionId}
|
||||
onSchemaChange={(newSchema) => {
|
||||
useDbViewerStore.getState().setCurrentSchema(newSchema);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{currentView === "db-viewer" && <ChangesQueuePanel />}
|
||||
</div>
|
||||
|
||||
@@ -38,4 +38,16 @@ describe("DbViewerSidebar", () => {
|
||||
await user.click(screen.getByLabelText(/settings/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("settings");
|
||||
});
|
||||
|
||||
it("renders Schema Visualizer nav item (not coming soon)", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
// Should find the label WITHOUT "coming soon"
|
||||
const btn = screen.getByLabelText(/schema visualizer/i);
|
||||
expect(btn).toBeInTheDocument();
|
||||
expect(btn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,8 @@ export function DbViewerSidebar({
|
||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={20} /> },
|
||||
{
|
||||
id: "schema-visualizer",
|
||||
label: "Schema Visualizer coming soon",
|
||||
label: "Schema Visualizer",
|
||||
icon: <Grid2x2 size={20} />,
|
||||
stub: true,
|
||||
},
|
||||
{
|
||||
id: "functions",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
|
||||
import type { TableNode } from "../../lib/types";
|
||||
|
||||
// React Flow custom nodes must be wrapped in ReactFlowProvider
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<ReactFlowProvider>{children}</ReactFlowProvider>
|
||||
);
|
||||
|
||||
const sampleTable: TableNode = {
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, fk_ref: null },
|
||||
],
|
||||
};
|
||||
|
||||
describe("SchemaVisualizerNode", () => {
|
||||
it("renders table name in header", () => {
|
||||
render(
|
||||
<SchemaVisualizerNode
|
||||
id="node-1"
|
||||
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||
selected={false}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
expect(screen.getByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders all columns by default", () => {
|
||||
render(
|
||||
<SchemaVisualizerNode
|
||||
id="node-1"
|
||||
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||
selected={false}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
expect(screen.getByText("id")).toBeInTheDocument();
|
||||
expect(screen.getByText("name")).toBeInTheDocument();
|
||||
expect(screen.getByText("email")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses non-key columns on chevron click", () => {
|
||||
render(
|
||||
<SchemaVisualizerNode
|
||||
id="node-1"
|
||||
data={{ table: sampleTable, isExternal: false, onExpandExternal: undefined }}
|
||||
selected={false}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
// Find collapse button
|
||||
const collapseBtn = screen.getByRole("button", { name: /collapse/i });
|
||||
fireEvent.click(collapseBtn);
|
||||
|
||||
// After collapse, non-key columns should be hidden
|
||||
// name is non-key, should not be visible
|
||||
expect(screen.queryByText(/^name$/)).not.toBeInTheDocument();
|
||||
// id and email (PK/UNIQUE) should still be visible
|
||||
expect(screen.getByText(/^id$/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^email$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders in dimmed style when isExternal is true", () => {
|
||||
const { container } = render(
|
||||
<SchemaVisualizerNode
|
||||
id="node-ext"
|
||||
data={{ table: sampleTable, isExternal: true, onExpandExternal: vi.fn() }}
|
||||
selected={false}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
const card = container.firstElementChild;
|
||||
expect(card?.className).toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("calls onExpandExternal when external node is clicked", () => {
|
||||
const onExpand = vi.fn();
|
||||
render(
|
||||
<SchemaVisualizerNode
|
||||
id="node-ext"
|
||||
data={{ table: sampleTable, isExternal: true, onExpandExternal: onExpand }}
|
||||
selected={false}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
const card = screen.getByText("public.users").closest("div");
|
||||
fireEvent.click(card!);
|
||||
expect(onExpand).toHaveBeenCalledWith("public", "users");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import { Table2, Eye, ChevronUp, ChevronDown, Key, ArrowRight } from "lucide-react";
|
||||
import type { TableNode as TableNodeType } from "../../lib/types";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
|
||||
export interface SchemaVisualizerNodeData {
|
||||
table: TableNodeType;
|
||||
isExternal: boolean;
|
||||
onExpandExternal?: (schema: string, table: string) => void;
|
||||
}
|
||||
|
||||
interface SchemaVisualizerNodeProps {
|
||||
id: string;
|
||||
data: SchemaVisualizerNodeData;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const SchemaVisualizerNode = memo(function SchemaVisualizerNode({
|
||||
data,
|
||||
selected,
|
||||
}: SchemaVisualizerNodeProps) {
|
||||
const { table, isExternal, onExpandExternal } = data;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const isView = table.table_type === "VIEW";
|
||||
|
||||
const visibleColumns = collapsed
|
||||
? table.columns.filter((c) => c.is_pk || c.is_fk || c.is_unique)
|
||||
: table.columns;
|
||||
|
||||
const handleClick = () => {
|
||||
if (isExternal && onExpandExternal) {
|
||||
onExpandExternal(table.schema, table.name);
|
||||
}
|
||||
};
|
||||
|
||||
const cardClass = [
|
||||
"rounded-none border bg-surface min-w-[220px] text-xs font-mono",
|
||||
selected ? "border-accent shadow-lg shadow-accent/10" : "border-border",
|
||||
isExternal ? "opacity-50 border-dashed cursor-pointer" : "",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className={cardClass} onClick={handleClick}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border bg-surface-raised">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isView ? (
|
||||
<Eye size={12} className="text-text-muted" />
|
||||
) : (
|
||||
<Table2 size={12} className="text-text-muted" />
|
||||
)}
|
||||
<span className="font-semibold text-text truncate max-w-[160px]">
|
||||
{table.schema}.{table.name}
|
||||
</span>
|
||||
</div>
|
||||
{!isExternal && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? "Expand columns" : "Collapse columns"}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
>
|
||||
{collapsed ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column rows */}
|
||||
<div>
|
||||
{visibleColumns.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex items-center justify-between px-2 py-1 border-b border-border last:border-b-0 hover:bg-surface-hover relative"
|
||||
>
|
||||
{/* Left side: badges + name */}
|
||||
<div className="flex items-center gap-1">
|
||||
{col.is_pk && <Key size={10} className="text-amber-400 shrink-0" />}
|
||||
{col.is_fk && !col.is_pk && (
|
||||
<ArrowRight size={10} className="text-accent shrink-0" />
|
||||
)}
|
||||
<span className="text-text truncate max-w-[120px]">{col.name}</span>
|
||||
</div>
|
||||
{/* Right side: type */}
|
||||
<span className="text-text-muted text-[10px] shrink-0 ml-2 max-w-[60px] truncate inline-block align-middle">
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
|
||||
{/* FK source handle */}
|
||||
{col.is_fk && (
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`fk-${col.name}`}
|
||||
className="!w-2 !h-2 !bg-accent !border-2 !border-canvas"
|
||||
style={{ top: "50%", right: -5 }}
|
||||
/>
|
||||
)}
|
||||
{/* PK target handle */}
|
||||
{col.is_pk && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`pk-${col.name}`}
|
||||
className="!w-2 !h-2 !bg-amber-400 !border-2 !border-canvas"
|
||||
style={{ top: "50%", left: -5 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Show collapsed count */}
|
||||
{collapsed && table.columns.length > visibleColumns.length && (
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted border-t border-border">
|
||||
+{table.columns.length - visibleColumns.length} more columns
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
// Mock the Tauri invoke call
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({
|
||||
tables: [],
|
||||
relationships: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the SchemaVisualizerNode to avoid React Flow complexity in tests
|
||||
vi.mock("./SchemaVisualizerNode", () => ({
|
||||
SchemaVisualizerNode: () => <div data-testid="mock-node">Node</div>,
|
||||
}));
|
||||
|
||||
describe("SchemaVisualizerPage", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public", "auth"],
|
||||
currentSchema: "public",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the legend panel", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText(/one-to-one/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/one-to-many/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/many-to-many/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText(/loading schema/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Reset Layout button", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText(/reset layout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when introspection fails", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockRejectedValueOnce(new Error("Connection lost"));
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage connectionId="conn-1" onSchemaChange={() => {}} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const errorMsg = await screen.findByText(/failed to load schema/i);
|
||||
expect(errorMsg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import dagre from "dagre";
|
||||
import { RotateCcw, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { CrowsFootEdge } from "./CrowsFootEdge";
|
||||
import { SchemaVisualizerNode } from "./SchemaVisualizerNode";
|
||||
import { LEGEND_ITEMS } from "./legendHelpers";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { getSchemaGraph } from "../../lib/commands";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import type { SchemaGraph, TableNode as TableNodeType } from "../../lib/types";
|
||||
|
||||
const nodeTypes = { tableNode: SchemaVisualizerNode };
|
||||
const edgeTypes = { crowsfoot: CrowsFootEdge };
|
||||
|
||||
const CARD_WIDTH = 240;
|
||||
const ROW_HEIGHT = 28;
|
||||
const HEADER_HEIGHT = 32;
|
||||
|
||||
function getNodeHeight(colCount: number): number {
|
||||
return HEADER_HEIGHT + colCount * ROW_HEIGHT + 4;
|
||||
}
|
||||
|
||||
function layoutGraph(
|
||||
tables: TableNodeType[],
|
||||
relationships: { source_table: string; target_table: string; source_column: string; target_column: string; cardinality: string }[],
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 100, marginx: 40, marginy: 40 });
|
||||
|
||||
const cardinalityMap = new Map<string, string>();
|
||||
for (const rel of relationships) {
|
||||
cardinalityMap.set(
|
||||
`${rel.source_table}.${rel.source_column}->${rel.target_table}.${rel.target_column}`,
|
||||
rel.cardinality,
|
||||
);
|
||||
}
|
||||
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
const height = getNodeHeight(table.columns.length);
|
||||
g.setNode(table.name, { width: CARD_WIDTH, height });
|
||||
nodes.push({
|
||||
id: table.name,
|
||||
type: "tableNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { table, isExternal: false },
|
||||
style: { width: CARD_WIDTH },
|
||||
});
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
for (const col of table.columns) {
|
||||
if (col.fk_ref) {
|
||||
const [refSchema, refTable, refColumn] = col.fk_ref;
|
||||
if (tables.some((t) => t.name === refTable && t.schema === refSchema)) {
|
||||
const edgeKey = `${table.name}.${col.name}->${refTable}.${refColumn}`;
|
||||
const cardinality = cardinalityMap.get(edgeKey) ?? "1:N";
|
||||
const markers = getEdgeMarkers(cardinality);
|
||||
|
||||
g.setEdge(table.name, refTable, {});
|
||||
edges.push({
|
||||
id: edgeKey,
|
||||
source: table.name,
|
||||
target: refTable,
|
||||
sourceHandle: `fk-${col.name}`,
|
||||
targetHandle: `pk-${refColumn}`,
|
||||
type: "crowsfoot",
|
||||
label: cardinality,
|
||||
data: { cardinality, startMarker: markers.markerStart, endMarker: markers.markerEnd, origRight: true },
|
||||
style: { stroke: "#3b82f6", strokeWidth: 1.5 },
|
||||
labelStyle: { fill: "#9ca3af", fontSize: 9 },
|
||||
labelBgStyle: { fill: "#1f2937", fillOpacity: 0.85 },
|
||||
labelBgPadding: [3, 1],
|
||||
labelBorderRadius: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of nodes) {
|
||||
const dagreNode = g.node(node.id);
|
||||
if (dagreNode) {
|
||||
node.position = {
|
||||
x: dagreNode.x - CARD_WIDTH / 2,
|
||||
y: dagreNode.y - (dagreNode as any).height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
function getEdgeMarkers(cardinality: string): { markerStart: string; markerEnd: string } {
|
||||
switch (cardinality) {
|
||||
case "1:1":
|
||||
return { markerStart: "one", markerEnd: "one" };
|
||||
case "0..1:0..1":
|
||||
return { markerStart: "one", markerEnd: "one" };
|
||||
case "1:N":
|
||||
return { markerStart: "many", markerEnd: "one" };
|
||||
case "0..N":
|
||||
return { markerStart: "many", markerEnd: "one" };
|
||||
case "N:M":
|
||||
return { markerStart: "many", markerEnd: "many" };
|
||||
default:
|
||||
return { markerStart: "", markerEnd: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export interface SchemaVisualizerPageProps {
|
||||
connectionId: string;
|
||||
onSchemaChange?: (schema: string) => void;
|
||||
}
|
||||
|
||||
export function SchemaVisualizerPage({
|
||||
connectionId,
|
||||
onSchemaChange,
|
||||
}: SchemaVisualizerPageProps) {
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const schemas = useDbViewerStore((s) => s.schemas);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableCount, setTableCount] = useState(0);
|
||||
const [legendOpen, setLegendOpen] = useState(true);
|
||||
const [highlightedEdge, setHighlightedEdge] = useState<string | null>(null);
|
||||
|
||||
const fetchGraph = useCallback(async () => {
|
||||
if (!currentSchema) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const graph: SchemaGraph = await getSchemaGraph(connectionId, currentSchema);
|
||||
if (graph.tables.length === 0) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
setTableCount(0);
|
||||
} else {
|
||||
const { nodes: layoutedNodes, edges: layoutedEdges } = layoutGraph(graph.tables, graph.relationships);
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(layoutedEdges);
|
||||
if (graph.tables.length > 200) {
|
||||
const proceed = window.confirm(
|
||||
`This schema has ${graph.tables.length} tables. Rendering the full diagram may be slow. Continue?`,
|
||||
);
|
||||
if (!proceed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTableCount(graph.tables.length);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [connectionId, currentSchema, setNodes, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
const handleResetLayout = useCallback(() => {
|
||||
fetchGraph();
|
||||
setHighlightedEdge(null);
|
||||
}, [fetchGraph]);
|
||||
|
||||
const handleEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
setHighlightedEdge(edge.id === highlightedEdge ? null : edge.id);
|
||||
},
|
||||
[highlightedEdge],
|
||||
);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
setHighlightedEdge(null);
|
||||
}, []);
|
||||
|
||||
// Derive edges with highlighting applied
|
||||
const displayEdges = useMemo(() => {
|
||||
if (!highlightedEdge) return edges;
|
||||
return edges.map((e) => {
|
||||
if (e.id === highlightedEdge) {
|
||||
return {
|
||||
...e,
|
||||
zIndex: 1000,
|
||||
style: { ...e.style, stroke: "#f59e0b", strokeWidth: 2.5, opacity: 1 },
|
||||
labelStyle: { ...e.labelStyle, fill: "#f59e0b" },
|
||||
labelBgStyle: { ...e.labelBgStyle, fill: "#1f2937", fillOpacity: 0.95 },
|
||||
};
|
||||
}
|
||||
return { ...e, style: { ...e.style, opacity: 0.15 } };
|
||||
});
|
||||
}, [edges, highlightedEdge]);
|
||||
|
||||
const highlightedCardinality = useMemo(() => {
|
||||
if (!highlightedEdge) return null;
|
||||
const edge = edges.find((e) => e.id === highlightedEdge);
|
||||
return (edge?.data as any)?.cardinality as string | null;
|
||||
}, [edges, highlightedEdge]);
|
||||
|
||||
const handleSchemaChange = useCallback(
|
||||
(schema: string) => {
|
||||
setCurrentSchema(schema);
|
||||
onSchemaChange?.(schema);
|
||||
},
|
||||
[setCurrentSchema, onSchemaChange],
|
||||
);
|
||||
|
||||
const schemaOptions = useMemo(
|
||||
() => schemas.map((s) => ({ value: s, label: s })),
|
||||
[schemas],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-canvas">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 border-b border-border shrink-0 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
{databases.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentDatabase ?? ""}
|
||||
onChange={setCurrentDatabase}
|
||||
options={databases.map((d) => ({ value: d, label: d }))}
|
||||
placeholder="Select database"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && schemas.length > 0 && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{schemas.length > 0 && (
|
||||
<SelectDropdown
|
||||
value={currentSchema ?? ""}
|
||||
options={schemaOptions}
|
||||
onChange={handleSchemaChange}
|
||||
placeholder="Select schema"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-text-muted">
|
||||
{tableCount} {tableCount === 1 ? "table" : "tables"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetLayout}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Reset Layout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10 bg-canvas/80">
|
||||
<p className="text-text-muted text-sm">Loading schema...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 bg-canvas/80 gap-3">
|
||||
<p className="text-red-400 text-sm">Failed to load schema: {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchGraph}
|
||||
className="px-3 py-1 text-xs rounded-md bg-surface border border-border text-text-muted hover:text-text"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && tableCount === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<p className="text-text-muted text-sm">
|
||||
No tables found in schema "{currentSchema}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={displayEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
fitView
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
className="bg-canvas"
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant="dots" gap={20} color="var(--color-border)" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
nodeStrokeWidth={2}
|
||||
nodeClassName="!fill-accent/20 !stroke-accent"
|
||||
maskColor="rgba(18,18,24,0.85)"
|
||||
maskStrokeColor="var(--color-border)"
|
||||
maskStrokeWidth={1}
|
||||
className="!bg-surface !border !border-border !rounded-none !shadow-lg"
|
||||
/>
|
||||
<Controls
|
||||
position="bottom-left"
|
||||
className="!rounded-none !shadow-lg [&_button]:!bg-surface [&_button]:!text-text-muted [&_button]:!border-border [&_button]:hover:!bg-surface-raised [&_button]:hover:!text-text [&_button]:!shadow-none"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="absolute top-3 right-3 z-10 bg-surface border border-border rounded-none px-3 py-2 text-xs shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLegendOpen(!legendOpen)}
|
||||
className="flex items-center gap-1 font-semibold text-text w-full"
|
||||
>
|
||||
{legendOpen ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
|
||||
Relationships
|
||||
</button>
|
||||
{legendOpen && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{LEGEND_ITEMS.map((item) => {
|
||||
const isActive = highlightedCardinality === item.cardinality;
|
||||
return (
|
||||
<div key={item.cardinality} className={`flex items-center gap-2.5 transition-opacity ${highlightedCardinality && !isActive ? "opacity-20" : ""}`}>
|
||||
<svg width="36" height="12" className="shrink-0">
|
||||
<line x1={6} y1={6} x2={30} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} />
|
||||
{/* Start marker */}
|
||||
{item.markerStart === "one" ? (
|
||||
<line x1={6} y1={2} x2={6} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
) : (
|
||||
<>
|
||||
<line x1={6} y1={3} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
<line x1={6} y1={6} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
<line x1={6} y1={9} x2={12} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
</>
|
||||
)}
|
||||
{/* End marker */}
|
||||
{item.markerEnd === "one" ? (
|
||||
<line x1={30} y1={2} x2={30} y2={10} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
) : (
|
||||
<>
|
||||
<line x1={30} y1={3} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
<line x1={30} y1={6} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
<line x1={30} y1={9} x2={24} y2={6} stroke={isActive ? "#f59e0b" : item.color} strokeWidth={isActive ? 2 : 1.5} strokeLinecap="round" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className={`text-[11px] ${isActive ? "text-amber-400 font-medium" : "text-text-muted"}`}>{item.label}</span>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Powered by React Flow */}
|
||||
<div className="absolute top-0 left-0 z-0 text-[10px] text-text-muted/50 bg-surface/80 px-2 py-0.5 rounded-none pointer-events-none">
|
||||
Powered by React Flow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getCardinalityColor,
|
||||
getCardinalityLabel,
|
||||
LEGEND_ITEMS,
|
||||
} from "./legendHelpers";
|
||||
|
||||
describe("legendHelpers", () => {
|
||||
it("getCardinalityColor returns correct colors", () => {
|
||||
expect(getCardinalityColor("1:1")).toBe("#22c55e"); // green
|
||||
expect(getCardinalityColor("1:N")).toBe("#3b82f6"); // blue
|
||||
expect(getCardinalityColor("N:M")).toBe("#f59e0b"); // amber
|
||||
});
|
||||
|
||||
it("getCardinalityColor returns fallback for unknown", () => {
|
||||
expect(getCardinalityColor("unknown")).toBe("#6b7280"); // gray fallback
|
||||
});
|
||||
|
||||
it("getCardinalityLabel returns human-readable labels", () => {
|
||||
expect(getCardinalityLabel("1:1")).toBe("One-to-One");
|
||||
expect(getCardinalityLabel("1:N")).toBe("One-to-Many");
|
||||
expect(getCardinalityLabel("N:M")).toBe("Many-to-Many");
|
||||
});
|
||||
|
||||
it("getCardinalityLabel returns raw value for unknown", () => {
|
||||
expect(getCardinalityLabel("unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("LEGEND_ITEMS has three entries", () => {
|
||||
expect(LEGEND_ITEMS).toHaveLength(3);
|
||||
expect(LEGEND_ITEMS[0]).toHaveProperty("cardinality");
|
||||
expect(LEGEND_ITEMS[0]).toHaveProperty("color");
|
||||
expect(LEGEND_ITEMS[0]).toHaveProperty("label");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface LegendItem {
|
||||
cardinality: string;
|
||||
color: string;
|
||||
label: string;
|
||||
markerStart: string;
|
||||
markerEnd: string;
|
||||
}
|
||||
|
||||
const CARDINALITY_COLORS: Record<string, string> = {
|
||||
"1:1": "#22c55e",
|
||||
"1:N": "#3b82f6",
|
||||
"N:M": "#f59e0b",
|
||||
};
|
||||
|
||||
const CARDINALITY_LABELS: Record<string, string> = {
|
||||
"1:1": "One-to-One",
|
||||
"1:N": "One-to-Many",
|
||||
"N:M": "Many-to-Many",
|
||||
};
|
||||
|
||||
export function getCardinalityColor(cardinality: string): string {
|
||||
return CARDINALITY_COLORS[cardinality] ?? "#6b7280";
|
||||
}
|
||||
|
||||
export function getCardinalityLabel(cardinality: string): string {
|
||||
return CARDINALITY_LABELS[cardinality] ?? cardinality;
|
||||
}
|
||||
|
||||
export const LEGEND_ITEMS: LegendItem[] = [
|
||||
{ cardinality: "1:1", color: "#22c55e", label: "One-to-One", markerStart: "one", markerEnd: "one" },
|
||||
{ cardinality: "1:N", color: "#3b82f6", label: "One-to-Many", markerStart: "many", markerEnd: "one" },
|
||||
{ cardinality: "N:M", color: "#f59e0b", label: "Many-to-Many", markerStart: "many", markerEnd: "many" },
|
||||
];
|
||||
@@ -40,10 +40,11 @@ export function SelectDropdown({
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
// Use capture phase so we fire before React Flow's stopPropagation
|
||||
document.addEventListener("mousedown", handleMouseDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn().mockResolvedValue({ tables: [], relationships: [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
testConnection,
|
||||
dbConnect,
|
||||
@@ -9,7 +14,9 @@ import {
|
||||
getTableData,
|
||||
executeChange,
|
||||
refreshConnection,
|
||||
getSchemaGraph,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
|
||||
describe("commands", () => {
|
||||
it("testConnection has correct signature", () => {
|
||||
@@ -47,4 +54,17 @@ describe("commands", () => {
|
||||
it("refreshConnection returns full tree promise", () => {
|
||||
expect(typeof refreshConnection).toBe("function");
|
||||
});
|
||||
|
||||
describe("getSchemaGraph", () => {
|
||||
it("is a callable function with correct signature", () => {
|
||||
expect(typeof getSchemaGraph).toBe("function");
|
||||
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1", "public");
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("accepts schema as optional", () => {
|
||||
const result: Promise<SchemaGraph> = getSchemaGraph("conn-1");
|
||||
expect(result).toBeInstanceOf(Promise);
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, ChangeItem, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
|
||||
// NOTE on argument key naming:
|
||||
@@ -139,4 +139,11 @@ export async function getEnums(connectionId: string, schema?: string): Promise<E
|
||||
|
||||
export async function getExtensions(connectionId: string): Promise<ExtensionInfo[]> {
|
||||
return invoke<ExtensionInfo[]>("get_extensions", { connectionId });
|
||||
}
|
||||
|
||||
export async function getSchemaGraph(
|
||||
connectionId: string,
|
||||
schema?: string,
|
||||
): Promise<SchemaGraph> {
|
||||
return invoke<SchemaGraph>("get_schema_graph", { connectionId, schema });
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { DbType } from "./types";
|
||||
|
||||
export const DB_ICONS: Record<DbType, string> = {
|
||||
postgresql: "🐘",
|
||||
mysql: "🐬",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
|
||||
export const DB_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
sqlite: "SQLite",
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { siPostgresql, siMysql, siSqlite, siRedis } from "simple-icons";
|
||||
import type { DbType } from "./types";
|
||||
|
||||
// Brand colors from simple-icons
|
||||
const DB_COLORS: Record<DbType, string> = {
|
||||
postgresql: `#${siPostgresql.hex}`,
|
||||
mysql: `#${siMysql.hex}`,
|
||||
redis: `#${siRedis.hex}`,
|
||||
sqlite: `#${siSqlite.hex}`,
|
||||
};
|
||||
|
||||
// SVG path data for each DB icon
|
||||
const DB_PATHS: Record<DbType, string> = {
|
||||
postgresql: siPostgresql.path,
|
||||
mysql: siMysql.path,
|
||||
redis: siRedis.path,
|
||||
sqlite: siSqlite.path,
|
||||
};
|
||||
|
||||
export const DB_LABELS: Record<DbType, string> = {
|
||||
postgresql: "PostgreSQL",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
sqlite: "SQLite",
|
||||
};
|
||||
|
||||
interface DbIconProps {
|
||||
type: DbType;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DbIcon({ type, size = 20, className }: DbIconProps) {
|
||||
const path = DB_PATHS[type];
|
||||
const color = DB_COLORS[type];
|
||||
if (!path) return <span className="text-lg">❓</span>;
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
fill={color}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Keep backward compat for any legacy emoji usage
|
||||
export const DB_ICONS: Record<DbType, string> = {
|
||||
postgresql: "🐘",
|
||||
mysql: "🐬",
|
||||
redis: "⚡",
|
||||
sqlite: "🗄️",
|
||||
};
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ChangeStatus,
|
||||
DbViewerTab,
|
||||
ConnectionTestResult,
|
||||
SchemaGraph,
|
||||
TableNode,
|
||||
GraphColumn,
|
||||
Relationship,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
@@ -358,4 +362,70 @@ describe("ConnectionTestResult", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("Connection refused");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schema graph types", () => {
|
||||
it("GraphColumn has correct shape", () => {
|
||||
const col: GraphColumn = {
|
||||
name: "user_id",
|
||||
data_type: "integer",
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
is_unique: false,
|
||||
fk_ref: ["public", "users", "id"],
|
||||
};
|
||||
expect(col.name).toBe("user_id");
|
||||
expect(col.is_fk).toBe(true);
|
||||
expect(col.fk_ref).toEqual(["public", "users", "id"]);
|
||||
});
|
||||
|
||||
it("TableNode has correct shape", () => {
|
||||
const node: TableNode = {
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_pk: true, is_fk: false, is_unique: true, fk_ref: null },
|
||||
{ name: "user_id", data_type: "integer", is_pk: false, is_fk: true, is_unique: false, fk_ref: ["public", "users", "id"] },
|
||||
],
|
||||
};
|
||||
expect(node.name).toBe("orders");
|
||||
expect(node.columns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("Relationship has correct shape", () => {
|
||||
const rel: Relationship = {
|
||||
source_schema: "public",
|
||||
source_table: "orders",
|
||||
source_column: "user_id",
|
||||
target_schema: "public",
|
||||
target_table: "users",
|
||||
target_column: "id",
|
||||
cardinality: "1:N",
|
||||
};
|
||||
expect(rel.cardinality).toBe("1:N");
|
||||
});
|
||||
|
||||
it("SchemaGraph has correct shape", () => {
|
||||
const graph: SchemaGraph = {
|
||||
tables: [
|
||||
{ name: "users", schema: "public", table_type: "TABLE", columns: [] },
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
expect(graph.tables).toHaveLength(1);
|
||||
expect(graph.relationships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fk_ref can be null for non-FK columns", () => {
|
||||
const col: GraphColumn = {
|
||||
name: "name",
|
||||
data_type: "text",
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
fk_ref: null,
|
||||
};
|
||||
expect(col.fk_ref).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -278,4 +278,40 @@ export interface BackupJob {
|
||||
size_bytes: number | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// ─── Schema Visualizer Types ────────────────────────────────────
|
||||
|
||||
export interface GraphColumn {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_pk: boolean;
|
||||
is_fk: boolean;
|
||||
is_unique: boolean;
|
||||
is_nullable: boolean;
|
||||
/** [referenced_schema, referenced_table, referenced_column] */
|
||||
fk_ref: [string, string, string] | null;
|
||||
}
|
||||
|
||||
export interface TableNode {
|
||||
name: string;
|
||||
schema: string;
|
||||
table_type: string;
|
||||
columns: GraphColumn[];
|
||||
}
|
||||
|
||||
export interface Relationship {
|
||||
source_schema: string;
|
||||
source_table: string;
|
||||
source_column: string;
|
||||
target_schema: string;
|
||||
target_table: string;
|
||||
target_column: string;
|
||||
/** Inferred cardinality: "1:1" | "1:N" | "N:M" */
|
||||
cardinality: string;
|
||||
}
|
||||
|
||||
export interface SchemaGraph {
|
||||
tables: TableNode[];
|
||||
relationships: Relationship[];
|
||||
}
|
||||
+8
-1
@@ -1 +1,8 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
// Polyfill ResizeObserver for jsdom (required by @xyflow/react)
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
Reference in New Issue
Block a user