refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- Move the Tauri app (React frontend + Rust backend) into desktop/ via git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts) - Root package.json becomes a private bun workspace container with orchestration scripts; desktop package renamed gridline-desktop - Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin, static output, ready for Dokploy) - Update release.yml for the new layout: projectPath: desktop, desktop/src-tauri resource paths, rust-cache workspace path - Update README + AGENTS.md structure trees and dev commands - Verified: vitest (1074), cargo test (354), desktop build, tauri dev (window launches), tauri build (dmg + app bundles), Astro build
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Select } from "../ui/Select";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, pgDump } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
|
||||
interface BackupDialogProps {
|
||||
open: boolean;
|
||||
connectionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type BackupFormat = "plain" | "custom" | "tar" | "directory";
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "plain", label: "Plain SQL" },
|
||||
{ value: "custom", label: "Custom Archive" },
|
||||
{ value: "tar", label: "Tarball" },
|
||||
{ value: "directory", label: "Directory" },
|
||||
];
|
||||
|
||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(): string {
|
||||
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
|
||||
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
}
|
||||
|
||||
export function BackupDialog({ open, connectionId, onClose }: BackupDialogProps) {
|
||||
const [format, setFormat] = useState<BackupFormat>("custom");
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [noOwner, setNoOwner] = useState(true);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
try {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const extensions: Record<BackupFormat, string[]> = {
|
||||
plain: ["sql"],
|
||||
custom: ["dump", "custom"],
|
||||
tar: ["tar"],
|
||||
directory: [],
|
||||
};
|
||||
const picked = await save({
|
||||
defaultPath: `backup.${format === "custom" ? "dump" : format === "plain" ? "sql" : "tar"}`,
|
||||
filters: [{ name: "Backup", extensions: extensions[format] }],
|
||||
});
|
||||
if (picked) setFilePath(picked);
|
||||
} catch {
|
||||
// dialog not available (non-Tauri env), use manual path input
|
||||
}
|
||||
}, [format]);
|
||||
|
||||
const handleStartBackup = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
const jobId = `dump-${Date.now()}`;
|
||||
startJob(jobId, "dump");
|
||||
try {
|
||||
await pgDump(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
noOwner,
|
||||
});
|
||||
notify("Backup completed successfully", "success");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
notify(`Backup failed: ${parseError(msg)}`, "error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [filePath, format, schema, noOwner, connectionId, startJob, notify, onClose]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_dump_found;
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Backup Database</h3>
|
||||
|
||||
{checkingTools && (
|
||||
<p className="text-sm text-text-muted mb-4">Checking for pg_dump...</p>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-medium">pg_dump not found</p>
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<div className="space-y-4">
|
||||
{/* Format selector */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Format</label>
|
||||
<Select
|
||||
value={format}
|
||||
onChange={(v) => setFormat(v as BackupFormat)}
|
||||
options={FORMAT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Output File</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
|
||||
/>
|
||||
<Button variant="secondary" onClick={handlePickFile}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema filter */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Schema (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
placeholder="public"
|
||||
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* No owner toggle */}
|
||||
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) => setNoOwner(e.target.checked)}
|
||||
className="rounded bg-surface border-border accent-accent"
|
||||
/>
|
||||
No Owner (--no-owner flag)
|
||||
</label>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob?.status === "running" && (
|
||||
<BackupProgress
|
||||
progress={50}
|
||||
jobType="dump"
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={running}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartBackup} disabled={running || !filePath}>
|
||||
{running ? "Backing up..." : "Start Backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
|
||||
function parseError(msg: string): string {
|
||||
if (msg.includes("pg_dump:")) {
|
||||
const [, ...rest] = msg.split("pg_dump:");
|
||||
return rest.join(":").trim() || msg;
|
||||
}
|
||||
if (msg.includes("No such file or directory")) {
|
||||
return `File not found. Check the output path and try again.`;
|
||||
}
|
||||
if (msg.includes("Permission denied")) {
|
||||
return `Permission denied. Check file permissions for the output path.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ save: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
describe("BackupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
});
|
||||
|
||||
it("hides install instructions when tools are bundled", async () => {
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: "bundled",
|
||||
pg_restore_source: "bundled",
|
||||
});
|
||||
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
|
||||
render(<BackupPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText(/brew install|apt install/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupPage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
mockConnections.length = 0;
|
||||
});
|
||||
|
||||
it("shows a single SQL format for MySQL (no custom/tar/directory)", async () => {
|
||||
mockConnections.push({ id: "c1", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue({
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
});
|
||||
render(<BackupPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText("Tarball")).toBeNull();
|
||||
expect(screen.queryByText("Directory")).toBeNull();
|
||||
expect(screen.getByText("Plain SQL")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a plain file-picker backup for SQLite (no format selector, no tool card)", () => {
|
||||
mockConnections.push({ id: "c2", db_type: "sqlite", name: "s" });
|
||||
render(<BackupPage connectionId="c2" />);
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/pg_dump/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("still lists Custom Archive for PostgreSQL (regression guard)", async () => {
|
||||
mockConnections.push({ id: "c3", db_type: "postgresql", name: "p" });
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue({
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
});
|
||||
render(<BackupPage connectionId="c3" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_dump/i)).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Custom Archive")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Download, FolderOpen, HardDrive } from "lucide-react";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import {
|
||||
detectPgTools,
|
||||
pgDump,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlDump,
|
||||
sqliteDump,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface BackupPageProps {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
type BackupFormat = "plain" | "custom" | "tar" | "directory";
|
||||
|
||||
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_dump is in your PATH.",
|
||||
};
|
||||
|
||||
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install mysql-client",
|
||||
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
|
||||
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysqldump is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(map: Record<string, string>): string {
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform.toLowerCase()
|
||||
: "";
|
||||
if (platform.includes("mac") || platform.includes("darwin"))
|
||||
return map.darwin;
|
||||
if (platform.includes("linux")) return map.linux;
|
||||
if (platform.includes("win")) return map.win32;
|
||||
return map.linux;
|
||||
}
|
||||
|
||||
export function BackupPage({ connectionId }: BackupPageProps) {
|
||||
const connection = useConnectionStore((s) =>
|
||||
s.connections.find((c) => c.id === connectionId),
|
||||
);
|
||||
const dbType = connection?.db_type ?? "postgresql";
|
||||
const database = connection?.database ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
const isSqlite = dbType === "sqlite";
|
||||
|
||||
const [format, setFormat] = useState<BackupFormat>("custom");
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [noOwner, setNoOwner] = useState(true);
|
||||
const [singleTransaction, setSingleTransaction] = useState(true);
|
||||
const [noData, setNoData] = useState(false);
|
||||
const [routines, setRoutines] = useState(true);
|
||||
const [triggers, setTriggers] = useState(true);
|
||||
const [events, setEvents] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
const isRunning = activeJob?.status === "running";
|
||||
|
||||
// Track our job ID so we only react to jobs we started
|
||||
const pendingJobRef = useRef<string | null>(null);
|
||||
|
||||
// React to job completion/failure via store events
|
||||
useEffect(() => {
|
||||
if (!pendingJobRef.current || !activeJob) return;
|
||||
if (activeJob.id !== pendingJobRef.current) return;
|
||||
|
||||
if (activeJob.status === "completed") {
|
||||
notify("Backup completed successfully", "success");
|
||||
pendingJobRef.current = null;
|
||||
} else if (activeJob.status === "failed") {
|
||||
notify(
|
||||
`Backup failed: ${activeJob.error_message || "Unknown error"}`,
|
||||
"error",
|
||||
);
|
||||
pendingJobRef.current = null;
|
||||
}
|
||||
}, [activeJob, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [connectionId, isPg, isMysql]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
let defaultPath = "backup";
|
||||
let extensions: string[] = [];
|
||||
|
||||
if (isPg) {
|
||||
const pgExtensions: Record<BackupFormat, string[]> = {
|
||||
plain: ["sql"],
|
||||
custom: ["dump", "custom"],
|
||||
tar: ["tar"],
|
||||
directory: [],
|
||||
};
|
||||
extensions = pgExtensions[format];
|
||||
defaultPath = `backup.${
|
||||
format === "custom"
|
||||
? "dump"
|
||||
: format === "plain"
|
||||
? "sql"
|
||||
: "tar"
|
||||
}`;
|
||||
} else if (isMysql) {
|
||||
extensions = ["sql"];
|
||||
defaultPath = "backup.sql";
|
||||
} else {
|
||||
extensions = ["db", "sqlite", "sql"];
|
||||
defaultPath = "backup.db";
|
||||
}
|
||||
|
||||
const picked = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: "Backup", extensions }],
|
||||
});
|
||||
if (picked) setFilePath(picked);
|
||||
}, [format, isPg, isMysql, isSqlite]);
|
||||
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
// Command returns the job ID immediately — completion
|
||||
// comes via Tauri events handled by the backupStore
|
||||
await action();
|
||||
} catch (e) {
|
||||
// If the command itself fails (e.g. connection not found),
|
||||
// the event won't fire — handle here
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartBackup = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithProgress("dump", () => {
|
||||
if (isPg) {
|
||||
return pgDump(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
noOwner,
|
||||
});
|
||||
}
|
||||
if (isMysql) {
|
||||
return mysqlDump(connectionId, {
|
||||
database: database!,
|
||||
filePath,
|
||||
singleTransaction,
|
||||
noData,
|
||||
routines,
|
||||
triggers,
|
||||
events,
|
||||
});
|
||||
}
|
||||
return sqliteDump(connectionId, { filePath });
|
||||
});
|
||||
}, [
|
||||
filePath,
|
||||
database,
|
||||
isPg,
|
||||
isMysql,
|
||||
isSqlite,
|
||||
format,
|
||||
schema,
|
||||
noOwner,
|
||||
singleTransaction,
|
||||
noData,
|
||||
routines,
|
||||
triggers,
|
||||
events,
|
||||
connectionId,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus && !pgToolStatus.pg_dump_found
|
||||
: isMysql
|
||||
? mysqlToolStatus && !mysqlToolStatus.mysqldumpFound
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_dump_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqldumpSource === "bundled"
|
||||
: false;
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_dump..."
|
||||
: isMysql
|
||||
? "Checking for mysqldump..."
|
||||
: null;
|
||||
|
||||
const headerDescription = isPg
|
||||
? "Create a database backup via pg_dump"
|
||||
: isMysql
|
||||
? "Create a database backup via mysqldump"
|
||||
: "Create a database backup";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<HardDrive size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Backup</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_dump not found" : "mysqldump not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format */}
|
||||
{isPg ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(
|
||||
e.target.value as BackupFormat,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
) : isMysql ? (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<div className="text-sm text-text py-2">
|
||||
Plain SQL
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Output file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Output File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
setFilePath(e.target.value)
|
||||
}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
>
|
||||
<FolderOpen size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PostgreSQL: no-owner toggle */}
|
||||
{isPg && (
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noOwner}
|
||||
onChange={(e) =>
|
||||
setNoOwner(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Owner{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-owner
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* MySQL: option toggles */}
|
||||
{isMysql && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={singleTransaction}
|
||||
onChange={(e) =>
|
||||
setSingleTransaction(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Single Transaction{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--single-transaction
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noData}
|
||||
onChange={(e) =>
|
||||
setNoData(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
No Data{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--no-data
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={routines}
|
||||
onChange={(e) =>
|
||||
setRoutines(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Routines{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--routines
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={triggers}
|
||||
onChange={(e) =>
|
||||
setTriggers(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Triggers{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--triggers
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={events}
|
||||
onChange={(e) =>
|
||||
setEvents(e.target.checked)
|
||||
}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Events{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
--events
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="dump"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartBackup}
|
||||
disabled={isRunning || !filePath}
|
||||
>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{isRunning ? "Backing up..." : "Start Backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Button } from "../ui/Button";
|
||||
|
||||
interface BackupProgressProps {
|
||||
progress: number;
|
||||
jobType: string;
|
||||
status: "running" | "completed" | "failed" | "cancelled";
|
||||
errorMessage?: string | null;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function BackupProgress({
|
||||
progress,
|
||||
jobType,
|
||||
status,
|
||||
errorMessage,
|
||||
onCancel,
|
||||
}: BackupProgressProps) {
|
||||
const isRunning = status === "running";
|
||||
|
||||
return (
|
||||
<div data-testid="backup-progress" className="w-full space-y-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text capitalize">
|
||||
{jobType}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{status === "running" && `In progress...`}
|
||||
{status === "completed" && "Completed"}
|
||||
{status === "failed" && "Failed"}
|
||||
{status === "cancelled" && "Cancelled"}
|
||||
</span>
|
||||
</div>
|
||||
{isRunning && onCancel && (
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative w-full h-2 bg-surface-raised rounded-full overflow-hidden">
|
||||
<div
|
||||
data-testid="progress-bar-fill"
|
||||
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-300 ${
|
||||
status === "failed" ? "bg-red-500" : "bg-accent"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, progress))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{errorMessage && status === "failed" && (
|
||||
<p className="text-sm text-red-400">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
describe("ChangesQueuePanel", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useUiStore.setState({ activeConnectionId: "c1" });
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("shows nothing when queue is empty", () => {
|
||||
const { container } = render(<ChangesQueuePanel />);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows the pending-changes header and a change card", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
description: "Update row in users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the old → new value diff on update cards", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
description: "Update row in users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
// the diff renders old (struck) → new (accent) as separate spans
|
||||
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("revert removes the change from the queue", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
description: "Update row in users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
await user.click(screen.getByRole("button", { name: /revert/i }));
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("labels bulk_insert / empty_table / drop_table cards", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "bulk_insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
columns: ["a"],
|
||||
rows: [[1]],
|
||||
description: "Import 2 rows into public.t",
|
||||
} as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "empty_table",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
description: "Empty Table: public.t",
|
||||
} as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "drop_table",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
description: "Drop Table: public.t",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql: "DROP INDEX public.i",
|
||||
description: "Drop index i",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText("DDL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Drop index i")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /sql/i }));
|
||||
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("commit calls executeChange with buildChangePayload output for insert", async () => {
|
||||
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
newData: { id: 1, name: "Alice" },
|
||||
description: "Insert row into t",
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(exec).toHaveBeenCalled());
|
||||
expect(exec.mock.calls[0][0]).toBe("c1");
|
||||
expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
data: expect.any(String),
|
||||
}));
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after committing a drop_table change", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "drop_table",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
description: "Drop Table: public.t",
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after committing a schema-modifying ddl change", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
schema: "public",
|
||||
table: "products",
|
||||
sql: 'CREATE TABLE "public"."products" ("id" integer NOT NULL)',
|
||||
description: "Create Table",
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("SQL toggle shows the generated SQL", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
newData: { name: "Alice" },
|
||||
description: "Insert row into users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
await user.click(screen.getByRole("button", { name: /sql/i }));
|
||||
expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Cmd+S commits all pending changes", async () => {
|
||||
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
newData: { a: 1 },
|
||||
description: "Insert row into t",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.keyDown(document, { key: "s", metaKey: true });
|
||||
await waitFor(() => expect(exec).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("Clear All empties the queue", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
newData: { a: 1 },
|
||||
description: "Insert row into t",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
await user.click(screen.getByRole("button", { name: /clear all/i }));
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calls onCommitted after a successful commit cycle", async () => {
|
||||
const onCommitted = vi.fn();
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
newData: { a: 1 },
|
||||
description: "Insert row into t",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel onCommitted={onCommitted} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("shows a green check on committed changes after Commit All", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("auto-closes tabs for a table dropped via Commit All", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => {
|
||||
const tabs = useDbViewerStore.getState().tabs;
|
||||
expect(tabs.some((t) => t.table === "users")).toBe(false);
|
||||
expect(tabs.some((t) => t.table === "posts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a rebuild_table change with an amber REBUILD badge and SQL preview", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "rebuild_table",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
sql: "BEGIN; ALTER TABLE \"public\".\"users\" ...; COMMIT;",
|
||||
description: "Rebuild public.users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText("REBUILD")).toBeInTheDocument();
|
||||
expect(screen.getByText(/rebuild public\.users/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/BEGIN;/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Check, X, RotateCcw } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
||||
import { isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||
|
||||
const statusBg: Record<QueueStatus, string> = {
|
||||
pending: "bg-accent/5",
|
||||
committed: "bg-green-500/5",
|
||||
failed: "bg-red-500/5",
|
||||
cancelled: "bg-surface-raised/50",
|
||||
};
|
||||
|
||||
function formatChangeLabel(change: QueueItem): string {
|
||||
const schema = change.schema ?? "";
|
||||
const table = change.table ?? "";
|
||||
const fullName = schema ? `${schema}.${table}` : table;
|
||||
switch (change.type) {
|
||||
case "bulk_insert":
|
||||
return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`;
|
||||
case "empty_table":
|
||||
return `Empty Table: ${fullName}`;
|
||||
case "drop_table":
|
||||
return `Drop Table: ${fullName}`;
|
||||
case "rebuild_table":
|
||||
return change.description ?? `Rebuild ${fullName}`;
|
||||
case "ddl":
|
||||
return change.description ?? "DDL";
|
||||
default:
|
||||
return change.table ?? "-";
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the old → new value change for update queue items. */
|
||||
function formatValueDiff(change: QueueItem): string | null {
|
||||
if (change.type !== "update" || !change.newData) return null;
|
||||
const colName = Object.keys(change.newData)[0];
|
||||
if (!colName) return null;
|
||||
const oldVal =
|
||||
change.oldData && change.oldData[colName] !== undefined
|
||||
? String(change.oldData[colName])
|
||||
: "NULL";
|
||||
const newVal =
|
||||
change.newData[colName] === null || change.newData[colName] === undefined
|
||||
? "NULL"
|
||||
: String(change.newData[colName]);
|
||||
return `${colName}: ${oldVal} → ${newVal}`;
|
||||
}
|
||||
|
||||
function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
|
||||
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
||||
function badgeLabel(type: string): string {
|
||||
if (type === "ddl") return "DDL";
|
||||
if (type === "rebuild_table") return "REBUILD";
|
||||
return capitalizeType(type);
|
||||
}
|
||||
|
||||
function tableRef(change: QueueItem): string {
|
||||
if (change.schema && change.table) return `${change.schema}.${change.table}`;
|
||||
return change.table ?? "-";
|
||||
}
|
||||
|
||||
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const removeChange = useDbViewerStore((state) => state.removeChange);
|
||||
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
||||
const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted);
|
||||
const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed);
|
||||
const notify = useNotificationStore((state) => state.notify);
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
|
||||
const handleCommitAll = useCallback(async () => {
|
||||
const connectionId = useUiStore.getState().activeConnectionId;
|
||||
if (!connectionId) {
|
||||
notify("No active connection", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = useDbViewerStore.getState().changesQueue.filter(
|
||||
(c) => c.status === "pending",
|
||||
);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let committedCount = 0;
|
||||
let treeDirty = false;
|
||||
|
||||
for (const change of pending) {
|
||||
try {
|
||||
const payload = buildChangePayload(change);
|
||||
await cmd.executeChange(connectionId, payload);
|
||||
markChangeCommitted(change.id);
|
||||
committedCount++;
|
||||
if (change.type === "drop_table") {
|
||||
treeDirty = true;
|
||||
const st = useDbViewerStore.getState();
|
||||
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
||||
} else if (
|
||||
change.type === "rebuild_table" ||
|
||||
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
|
||||
) {
|
||||
treeDirty = true;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
markChangeFailed(change.id, msg);
|
||||
notify(`Change failed: ${msg}`, "error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (committedCount > 0) {
|
||||
onCommitted?.();
|
||||
notify(`${committedCount} change(s) committed`, "success");
|
||||
}
|
||||
|
||||
if (treeDirty) {
|
||||
const st = useDbViewerStore.getState();
|
||||
void st.refreshTree(connectionId, st.currentSchema ?? undefined);
|
||||
}
|
||||
}, [markChangeCommitted, markChangeFailed, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
void handleCommitAll();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleCommitAll]);
|
||||
|
||||
if (changesQueue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingCount = changesQueue.filter((c) => c.status === "pending").length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="font-medium text-sm text-text">Pending Changes</span>
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Visual"
|
||||
onClick={() => setView("visual")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors",
|
||||
view === "visual"
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="SQL"
|
||||
onClick={() => setView("sql")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors",
|
||||
view === "sql"
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
SQL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-2 py-2 space-y-2">
|
||||
{view === "visual" ? (
|
||||
changesQueue.map((change) => (
|
||||
<div
|
||||
key={change.id}
|
||||
className={`rounded-lg border border-border bg-surface-raised/40 px-3 py-2 ${statusBg[change.status]}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={[
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
change.type === "rebuild_table"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-surface-raised text-text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{badgeLabel(change.type)}
|
||||
</span>
|
||||
<span className="text-sm text-text truncate">
|
||||
{tableRef(change)}
|
||||
</span>
|
||||
</div>
|
||||
{change.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Revert change"
|
||||
title="Revert change"
|
||||
onClick={() => removeChange(change.id)}
|
||||
className="rounded p-1 text-text-muted hover:bg-red-500/10 hover:text-red-500 cursor-pointer shrink-0"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
) : change.status === "committed" ? (
|
||||
<span title="Committed" className="shrink-0 text-green-500">
|
||||
<Check className="h-4 w-4" />
|
||||
</span>
|
||||
) : change.status === "failed" ? (
|
||||
<span title="Failed" className="shrink-0 text-red-500">
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{change.type === "rebuild_table" ? (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
|
||||
{buildChangeSql(change)}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
changesQueue.map((change) => (
|
||||
<pre
|
||||
key={change.id}
|
||||
className="text-xs text-text-muted whitespace-pre-wrap rounded-md bg-canvas px-3 py-2 font-mono border border-border"
|
||||
>
|
||||
{buildChangeSql(change)}
|
||||
</pre>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearChanges}
|
||||
className="text-xs text-text-muted hover:text-text hover:bg-surface-raised rounded-md px-2 py-1 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingCount === 0}
|
||||
onClick={handleCommitAll}
|
||||
className="inline-flex items-center rounded-md bg-accent px-3 py-1 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Commit All ({pendingCount})
|
||||
<kbd className="ml-1.5 rounded bg-surface-raised px-1 text-[10px]">⌘S</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionDropBanner } from "./ConnectionDropBanner";
|
||||
|
||||
describe("ConnectionDropBanner", () => {
|
||||
it("shows error message", () => {
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Connection lost")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reconnect button", () => {
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /reconnect/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when reconnect clicked", async () => {
|
||||
const onRetry = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={onRetry}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /reconnect/i }));
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDismiss when close button clicked", async () => {
|
||||
const onDismiss = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConnectionDropBanner
|
||||
error="Connection lost"
|
||||
onRetry={() => {}}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConnectionDropBannerProps {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionDropBanner({ error, onRetry, onDismiss }: ConnectionDropBannerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 bg-red-500/10 border border-red-500/20 rounded-md px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<AlertTriangle size={18} className="text-red-400 shrink-0" />
|
||||
<span className="text-red-300 text-sm truncate">{error}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="text-sm px-3 py-1.5 rounded-md bg-red-500/20 text-red-200 hover:bg-red-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss error"
|
||||
onClick={onDismiss}
|
||||
className="p-1.5 rounded-md text-red-300 hover:bg-red-500/20 transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DbViewerSidebar } from "./DbViewerSidebar";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { DB_CAPABILITIES } from "../../lib/dbCapabilities";
|
||||
|
||||
describe("DbViewerSidebar", () => {
|
||||
it("renders all navigation icons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onNavigate when home is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await user.click(screen.getByLabelText(/home/i));
|
||||
expect(onNavigate).toHaveBeenCalledWith("home");
|
||||
});
|
||||
|
||||
it("calls onNavigate when settings is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNavigate = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={onNavigate} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
it("renders Queries nav item", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Tools nav item", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText("Tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows all 5 tools for PostgreSQL (default capabilities)", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/objects/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Tools but hides Objects for SQLite", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.sqlite} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/schema visualizer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Tools but hides Objects and Visualizer for MySQL", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.mysql} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.getByLabelText(/explorer/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Queries")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/tools/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/objects/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no top nav items for Redis (unsupported browsing)", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerSidebar currentView="db-viewer" onNavigate={() => {}} capabilities={DB_CAPABILITIES.redis} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
expect(screen.queryByLabelText(/explorer/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Queries")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/schema visualizer/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/settings/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Boxes,
|
||||
Clock,
|
||||
Database,
|
||||
DatabaseBackup,
|
||||
Home,
|
||||
Settings,
|
||||
Share2,
|
||||
} from "lucide-react";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { DB_CAPABILITIES, type DbCapabilities } from "../../lib/dbCapabilities";
|
||||
|
||||
export interface DbViewerSidebarProps {
|
||||
currentView: string;
|
||||
onNavigate: (view: string) => void;
|
||||
capabilities?: DbCapabilities;
|
||||
}
|
||||
|
||||
export const NAV_CAPABILITY_KEY: Record<string, keyof DbCapabilities> = {
|
||||
"db-viewer": "explorer",
|
||||
queries: "queries",
|
||||
"schema-visualizer": "visualizer",
|
||||
objects: "objects",
|
||||
tools: "tools",
|
||||
};
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
stub?: boolean;
|
||||
}
|
||||
|
||||
export function DbViewerSidebar({
|
||||
currentView,
|
||||
onNavigate,
|
||||
capabilities = DB_CAPABILITIES.postgresql,
|
||||
}: DbViewerSidebarProps) {
|
||||
const topItems: NavItem[] = [
|
||||
{ id: "db-viewer", label: "Explorer", icon: <Database size={16} /> },
|
||||
{
|
||||
id: "queries",
|
||||
label: "Queries",
|
||||
icon: <Clock size={16} />,
|
||||
},
|
||||
{
|
||||
id: "schema-visualizer",
|
||||
label: "Schema Visualizer",
|
||||
icon: <Share2 size={16} />,
|
||||
},
|
||||
{ id: "objects", label: "Objects", icon: <Boxes size={16} /> },
|
||||
{ id: "tools", label: "Tools", icon: <DatabaseBackup size={16} /> },
|
||||
];
|
||||
|
||||
const visibleTopItems = topItems.filter(
|
||||
(item) => capabilities[NAV_CAPABILITY_KEY[item.id]]
|
||||
);
|
||||
|
||||
const bottomItems: NavItem[] = [
|
||||
{ id: "home", label: "Home", icon: <Home size={16} /> },
|
||||
{ id: "settings", label: "Settings", icon: <Settings size={16} /> },
|
||||
];
|
||||
|
||||
function renderItem(item: NavItem) {
|
||||
const isActive = currentView === item.id;
|
||||
const baseClass =
|
||||
"w-8 h-8 flex items-center justify-center rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50";
|
||||
const activeClass = "text-accent";
|
||||
const inactiveClass =
|
||||
"text-text-muted hover:text-text hover:bg-surface-raised";
|
||||
const stubClass = "opacity-40 cursor-not-allowed";
|
||||
|
||||
return (
|
||||
<Tooltip key={item.id} content={item.label} side="right">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={item.label}
|
||||
disabled={item.stub}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={`${baseClass} ${isActive ? activeClass : inactiveClass} ${item.stub ? stubClass : ""}`}
|
||||
>
|
||||
{item.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-14 h-full bg-canvas border-r border-border flex flex-col items-center py-3 gap-2 shrink-0">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
{visibleTopItems.map(renderItem)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{bottomItems.map(renderItem)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
|
||||
const defaultProps = {
|
||||
databases: [] as string[],
|
||||
currentDatabase: null as string | null,
|
||||
setCurrentDatabase: () => {},
|
||||
schemas: [] as string[],
|
||||
currentSchema: null as string | null,
|
||||
setCurrentSchema: () => {},
|
||||
searchQuery: "",
|
||||
onSearchChange: () => {},
|
||||
};
|
||||
|
||||
describe("DbViewerToolbar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders Tables label", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("Tables")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders database dropdown when multiple databases", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
databases={["mydb", "otherdb"]}
|
||||
currentDatabase="mydb"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("mydb")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits bottom padding when nothing is rendered below the title row", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container.firstElementChild!.className).not.toContain("pb-3");
|
||||
});
|
||||
|
||||
it("keeps bottom padding when selectors are rendered below", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
databases={["mydb", "otherdb"]}
|
||||
currentDatabase="mydb"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container.firstElementChild!.className).toContain("pb-3");
|
||||
});
|
||||
|
||||
it("keeps bottom padding while the search input is open", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/search tables/i));
|
||||
expect(container.firstElementChild!.className).toContain("pb-3");
|
||||
});
|
||||
|
||||
it("renders refresh and create table buttons", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByLabelText(/refresh/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a create table form tab when Create Table is clicked", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} currentSchema="public" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/create table/i));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "create" }));
|
||||
});
|
||||
|
||||
it("shows a disabled schema loading indicator while schema tree is loading", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByLabelText(/select schema/i)).toBeDisabled();
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders schema options when not loading and multiple schemas exist", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: false });
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar
|
||||
{...defaultProps}
|
||||
schemas={["public", "app"]}
|
||||
currentSchema="public"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByText("public")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Search,
|
||||
Pencil,
|
||||
Check,
|
||||
AlertCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { SchemaMenu } from "./SchemaMenu";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
currentDatabase,
|
||||
setCurrentDatabase,
|
||||
schemas,
|
||||
currentSchema,
|
||||
setCurrentSchema,
|
||||
onEdit,
|
||||
connectionId,
|
||||
dbType,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: {
|
||||
databases: string[];
|
||||
currentDatabase: string | null;
|
||||
setCurrentDatabase: (db: string | null) => void;
|
||||
schemas: string[];
|
||||
currentSchema: string | null;
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
connectionId?: string;
|
||||
dbType?: DbType;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
}) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [result, setResult] = useState<"idle" | "success" | "error">("idle");
|
||||
const resultTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
const populate = useDbViewerStore((s) => s.populate);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
|
||||
// Focus input when search opens
|
||||
useEffect(() => {
|
||||
if (searchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
|
||||
// Auto-hide on blur when empty
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
// Small delay to allow clicks on clear button / search icon
|
||||
setTimeout(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
}, [searchQuery]);
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) onSearchChange(""); // clear when closing
|
||||
return next;
|
||||
});
|
||||
}, [onSearchChange]);
|
||||
|
||||
// Cleanup result timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (resultTimer.current) clearTimeout(resultTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateTable = useCallback(() => {
|
||||
const schema = currentSchema ?? "public";
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: "",
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
params: { schema, name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
}, [currentSchema]);
|
||||
|
||||
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!connectionId || refreshing) return;
|
||||
setRefreshing(true);
|
||||
setResult("idle");
|
||||
try {
|
||||
const dbs = await cmd.getDatabases(connectionId);
|
||||
const scs = await cmd.getSchemas(connectionId);
|
||||
const tbls = await cmd.getTables(connectionId);
|
||||
populate(dbs, scs, tbls);
|
||||
setResult("success");
|
||||
} catch {
|
||||
setResult("error");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
resultTimer.current = setTimeout(() => setResult("idle"), 1500);
|
||||
}
|
||||
}, [connectionId, refreshing, populate]);
|
||||
|
||||
const hasBelow =
|
||||
searchOpen || databases.length > 1 || schemas.length > 1 || schemaTreeLoading;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 pt-3 border-b border-border space-y-2 ${hasBelow ? "pb-3" : ""}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-normal text-text-muted">Tables</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{onEdit && (
|
||||
<Tooltip content="Edit Connection" side="bottom">
|
||||
<button
|
||||
aria-label="Edit Connection"
|
||||
onClick={onEdit}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
result === "success"
|
||||
? "Refreshed"
|
||||
: result === "error"
|
||||
? "Refresh failed"
|
||||
: "Refresh Database"
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? (
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
) : result === "success" ? (
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
) : result === "error" ? (
|
||||
<AlertCircle
|
||||
size={14}
|
||||
className="text-red-400"
|
||||
/>
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showCreateTable && (
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
onClick={handleCreateTable}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="Search Tables" side="bottom">
|
||||
<button
|
||||
aria-label="Search Tables"
|
||||
onClick={toggleSearch}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Search input */}
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2.5 text-text-muted pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
placeholder="Filter tables…"
|
||||
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(databases.length > 1 || schemas.length > 1 || schemaTreeLoading) && (
|
||||
<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"
|
||||
aria-label="Select database"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && (schemas.length > 1 || schemaTreeLoading) && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{(schemas.length > 1 || schemaTreeLoading) && (
|
||||
<SelectDropdown
|
||||
value={schemaTreeLoading ? "" : (currentSchema ?? "")}
|
||||
onChange={schemaTreeLoading ? () => {} : setCurrentSchema}
|
||||
options={
|
||||
schemaTreeLoading
|
||||
? [{ value: "", label: "Loading…" }]
|
||||
: schemas.map((s) => ({ value: s, label: s }))
|
||||
}
|
||||
placeholder={schemaTreeLoading ? "Loading…" : "Select schema"}
|
||||
aria-label="Select schema"
|
||||
variant="ghost"
|
||||
disabled={schemaTreeLoading}
|
||||
/>
|
||||
)}
|
||||
<SchemaMenu
|
||||
connectionId={connectionId ?? ""}
|
||||
schema={currentSchema ?? undefined}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
|
||||
describe("DependencyDialog", () => {
|
||||
it("empty list shows no-dependencies message", () => {
|
||||
render(<DependencyDialog open deps={[]} onProceed={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText(/no dependencies/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("non-empty lists rows and requires checkbox to proceed", () => {
|
||||
const deps = [{ deptype: "n", class: "pg_class", name: "v_orders" }];
|
||||
render(<DependencyDialog open deps={deps} onProceed={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText("v_orders")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /proceed/i })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
expect(screen.getByRole("button", { name: /proceed/i })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { DependencyInfo } from "../../lib/types";
|
||||
|
||||
interface DependencyDialogProps {
|
||||
open: boolean;
|
||||
deps: DependencyInfo[];
|
||||
onProceed: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function DependencyDialog({ open, deps, onProceed, onCancel }: DependencyDialogProps) {
|
||||
const [ack, setAck] = useState(false);
|
||||
const hasDeps = deps.length > 0;
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onCancel}>
|
||||
<div className="w-[420px]">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Dependencies</h3>
|
||||
{hasDeps ? (
|
||||
<>
|
||||
<p className="text-sm text-red-400 mb-2">
|
||||
The following depend on this object and will be removed with CASCADE:
|
||||
</p>
|
||||
<ul className="max-h-48 overflow-auto my-2 space-y-1 pr-1">
|
||||
{deps.map((d, i) => (
|
||||
<li key={i} className="text-sm text-text">
|
||||
{d.name}{" "}
|
||||
<span className="text-text-subtle">({d.class})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="flex items-center gap-2 text-sm text-text-muted mt-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ack}
|
||||
onChange={(e) => setAck(e.target.checked)}
|
||||
className="accent-accent h-4 w-4"
|
||||
/>
|
||||
I understand these will be dropped.
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">No dependencies — safe to drop.</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onProceed}
|
||||
disabled={hasDeps && !ack}
|
||||
>
|
||||
Proceed
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import * as commands from "../../lib/commands";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
const { updateConnection, loadAll } = vi.hoisted(() => ({
|
||||
updateConnection: vi.fn().mockResolvedValue({}),
|
||||
loadAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (sel: (s: any) => any) =>
|
||||
sel({ updateConnection, loadAll, folders: [], tags: [] }),
|
||||
}));
|
||||
vi.mock("../../lib/commands", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/commands")>();
|
||||
return {
|
||||
...actual,
|
||||
updateConnection: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
saveConnectionPassword: vi.fn(),
|
||||
saveConnectionSshPassword: vi.fn(),
|
||||
saveConnectionSshPassphrase: vi.fn(),
|
||||
deleteConnectionPassword: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("../../stores/notificationStore", () => ({
|
||||
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
|
||||
}));
|
||||
|
||||
const baseConn: Connection = {
|
||||
id: "c1",
|
||||
name: "Prod",
|
||||
db_type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
database: "db",
|
||||
};
|
||||
|
||||
describe("EditConnectionModal", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("renders the Connection Label field and General/SSH tabs", () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={baseConn}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByLabelText(/connection label/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^general$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /ssh \/ ssl/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the Supabase SSL hint when editing a Supabase-host connection", () => {
|
||||
const supa = { ...baseConn, host: "db.abcdefghijklmnopqrst.supabase.co" };
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={supa}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{ ...baseConn, use_keychain: true }}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
|
||||
expect(cb.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("saves to keychain when use_keychain=true", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{ ...baseConn, use_keychain: true }}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
|
||||
);
|
||||
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purges keychain when use_keychain=false", async () => {
|
||||
render(
|
||||
<EditConnectionModal
|
||||
connection={{ ...baseConn, use_keychain: false }}
|
||||
open={true}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
|
||||
);
|
||||
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
|
||||
import { persistDbPassword } from "../../lib/keychain";
|
||||
import { detectProviderFromHost } from "../../lib/connectionString";
|
||||
import type { Connection, ConnectionInput } from "../../lib/types";
|
||||
import type { ConnectionFormData } from "../connections/connectionFormData";
|
||||
|
||||
interface EditConnectionModalProps {
|
||||
connection: Connection;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (updated: Connection) => void;
|
||||
}
|
||||
|
||||
export function EditConnectionModal({
|
||||
connection,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditConnectionModalProps) {
|
||||
const managedPreset = detectProviderFromHost(connection.host);
|
||||
|
||||
const [form, setForm] = useState<ConnectionFormData>(() => ({
|
||||
name: connection.name,
|
||||
environment: (connection.environment as ConnectionFormData["environment"]) ?? null,
|
||||
folder_id: connection.folder_id,
|
||||
tag_ids: [...connection.tag_ids],
|
||||
connection_string: "",
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
password: null,
|
||||
database: connection.database ?? null,
|
||||
use_keychain: connection.use_keychain ?? true,
|
||||
ssh_host: connection.ssh_host ?? null,
|
||||
ssh_port: connection.ssh_port ?? null,
|
||||
ssh_user: connection.ssh_user ?? null,
|
||||
ssh_auth_method:
|
||||
(connection.ssh_auth_method as "password" | "key" | null | undefined) ??
|
||||
null,
|
||||
ssh_private_key: connection.ssh_private_key_path ?? null,
|
||||
ssh_password: null,
|
||||
ssh_passphrase: null,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const input: ConnectionInput = {
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
ssh_host: form.ssh_host ?? null,
|
||||
ssh_port: form.ssh_port ?? null,
|
||||
ssh_user: form.ssh_user ?? null,
|
||||
ssh_auth_method: form.ssh_auth_method ?? null,
|
||||
ssh_private_key_path: form.ssh_private_key ?? null,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
ssh_passphrase: form.ssh_passphrase ?? null,
|
||||
};
|
||||
const updated = await updateConnection(connection.id, input);
|
||||
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
|
||||
// Persist SSH secrets to the OS keychain (not SQLite)
|
||||
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
|
||||
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
|
||||
}
|
||||
if (form.ssh_host && form.ssh_passphrase) {
|
||||
await saveConnectionSshPassphrase(connection.id, form.ssh_passphrase).catch(() => {});
|
||||
}
|
||||
notify("Connection updated", "success");
|
||||
onSaved(updated);
|
||||
onClose();
|
||||
loadAll();
|
||||
} catch (e) {
|
||||
notify(`Failed to update: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, connection.id, notify, onSaved, onClose, loadAll]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
// Fetch password from keychain if not provided in form
|
||||
let password = form.password;
|
||||
if (!password) {
|
||||
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
|
||||
}
|
||||
|
||||
const result = await testConnection({
|
||||
name: form.name,
|
||||
db_type: form.db_type,
|
||||
host: form.host,
|
||||
port: form.port,
|
||||
username: form.username,
|
||||
password,
|
||||
database: form.database,
|
||||
folder_id: form.folder_id,
|
||||
environment: form.environment,
|
||||
tag_ids: form.tag_ids,
|
||||
ssh_password: form.ssh_password ?? null,
|
||||
});
|
||||
if (result.ok) {
|
||||
notify("Connection successful", "success");
|
||||
} else {
|
||||
notify(result.error ?? "Connection failed", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
notify(`Test failed: ${e instanceof Error ? e.message : e}`, "error");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [form, notify, connection.id]);
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Edit Connection</h3>
|
||||
<DetailedConnectionForm form={form} onChange={(updates) => setForm((prev) => ({ ...prev, ...updates }))} managedPreset={managedPreset} />
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={handleTest} disabled={testing}>
|
||||
{testing ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("FilterBuilder", () => {
|
||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("name"));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
||||
]);
|
||||
});
|
||||
it("removing a rule calls onChange without it", () => {
|
||||
const onChange = vi.fn();
|
||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { X } from "lucide-react";
|
||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
}
|
||||
|
||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: `col-${col.name}`,
|
||||
data: { column: col },
|
||||
});
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onAdd}
|
||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const OPERATORS: FilterOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"starts",
|
||||
"ends",
|
||||
"gt",
|
||||
"lt",
|
||||
"null",
|
||||
"notnull",
|
||||
];
|
||||
|
||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
||||
const [val, setVal] = useState<Record<string, string>>({});
|
||||
|
||||
const addRule = (col: ColumnInfo) => {
|
||||
const op = defaultFilterOperator(col.data_type);
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `f-${Date.now()}-${col.name}`,
|
||||
column: col.name,
|
||||
operator: op,
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
||||
|
||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragEnd={(e) => {
|
||||
const id = e.active.id as string;
|
||||
const colName = id.replace(/^col-/, "");
|
||||
const col = columns.find((c) => c.name === colName);
|
||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{columns.map((c) => (
|
||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
||||
isOver ? "border-accent bg-surface" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{rules.length === 0 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Drop columns here to add filters
|
||||
</span>
|
||||
)}
|
||||
{rules.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text font-semibold">{r.column}</span>
|
||||
<select
|
||||
value={r.operator}
|
||||
onChange={(e) =>
|
||||
update(r.id, { operator: e.target.value as FilterOperator })
|
||||
}
|
||||
className="bg-surface border border-border rounded px-1"
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!["null", "notnull"].includes(r.operator) && (
|
||||
<input
|
||||
value={val[r.id] ?? r.value}
|
||||
onChange={(e) => {
|
||||
setVal({ ...val, [r.id]: e.target.value });
|
||||
update(r.id, { value: e.target.value });
|
||||
}}
|
||||
className="bg-surface border border-border rounded px-1 flex-1"
|
||||
placeholder="value"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove filter ${r.column}`}
|
||||
onClick={() => remove(r.id)}
|
||||
className="text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Key, X, ExternalLink, Loader2 } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { QueryResult } from "../../lib/types";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { DataTypeIcon } from "../ui/DataTypeIcon";
|
||||
|
||||
interface FkPreviewPopoverProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FkPreviewPopover({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
value,
|
||||
anchorRect,
|
||||
onClose,
|
||||
}: FkPreviewPopoverProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const setColumnFilter = useDbViewerStore((s) => s.setColumnFilter);
|
||||
|
||||
const handleOpen = () => {
|
||||
openTab(schema, table);
|
||||
// Find the newly created tab and apply the column filter
|
||||
const newTab = useDbViewerStore.getState().tabs.find(
|
||||
(t) => t.schema === schema && t.table === table,
|
||||
);
|
||||
if (newTab) {
|
||||
setColumnFilter(newTab.id, column, value);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Fetch the referenced row
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getFkPreview(connectionId, schema, table, column, value)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
if (!cancelled) {
|
||||
setError(String(e));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, schema, table, column, value]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
// Delay to avoid closing immediately from the same click that opened it
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
// Compute position to keep popover within viewport
|
||||
const popoverWidth = 360;
|
||||
const popoverMaxHeight = 320;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
// Flip horizontally if off-screen
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
// Flip vertically if not enough space below
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Key size={12} className="text-amber-400 shrink-0" />
|
||||
<span className="text-xs font-heading text-text truncate">
|
||||
{schema}.{table}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-[11px] rounded hover:bg-accent/10 text-accent transition-colors cursor-pointer"
|
||||
title="Open table in new tab"
|
||||
>
|
||||
<ExternalLink size={11} />
|
||||
<span>Open</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto" style={{ maxHeight: popoverMaxHeight - 41 }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-text-muted">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-red-500 px-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length === 0 && !loading && (
|
||||
<div className="flex items-center justify-center py-4 text-xs text-text-muted">
|
||||
No matching row found
|
||||
</div>
|
||||
)}
|
||||
{data && data.rows.length > 0 && (
|
||||
<table className="w-full text-xs" style={{ tableLayout: "fixed" }}>
|
||||
<tbody>
|
||||
{data.columns.map((col, ci) => {
|
||||
const cell = data.rows[0][ci];
|
||||
const isNull = cell === null || cell === undefined;
|
||||
return (
|
||||
<tr
|
||||
key={col.name}
|
||||
className="border-b border-border last:border-0 hover:bg-surface/30"
|
||||
>
|
||||
<td
|
||||
className="px-3 py-1.5 text-text-muted font-heading whitespace-nowrap overflow-hidden align-top"
|
||||
style={{ width: 100, maxWidth: 100 }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{col.is_pk && <Key size={9} className="text-accent shrink-0" />}
|
||||
{col.is_fk && <Key size={9} className="text-amber-400 shrink-0" />}
|
||||
<span className="truncate">{col.name}</span>
|
||||
<DataTypeIcon
|
||||
dataType={col.data_type}
|
||||
size={9}
|
||||
className="text-text-muted/60 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-text align-top break-all">
|
||||
{isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : (
|
||||
<span className="break-all">{String(cell)}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/f.csv") }));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn().mockResolvedValue("a,b\n1,2\n3,4") }));
|
||||
|
||||
describe("ImportDialog", () => {
|
||||
it("parses CSV and stages a bulk_insert change", async () => {
|
||||
const addChange = vi.fn();
|
||||
render(<ImportDialog open schema="public" table="t" columns={["a", "b"]} onStage={addChange} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await waitFor(() => expect(screen.getByText(/preview/i)).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /stage import/i }));
|
||||
await waitFor(() => {
|
||||
expect(addChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: "bulk_insert", schema: "public", table: "t",
|
||||
columns: ["a", "b"],
|
||||
}));
|
||||
expect(addChange.mock.calls[0][0].rows.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects files over the row cap", async () => {
|
||||
vi.mocked(readTextFile).mockResolvedValue("a\n" + "1\n".repeat(100_001));
|
||||
const onStage = vi.fn();
|
||||
render(<ImportDialog open schema="public" table="t" columns={["a"]} onStage={onStage} onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /choose file/i }));
|
||||
await waitFor(() => expect(screen.getByText(/limit/i)).toBeInTheDocument());
|
||||
expect(onStage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { Select } from "../ui/Select";
|
||||
import { normalizeImport, coerceRow } from "../../lib/importNormalize";
|
||||
|
||||
const MAX_ROWS = 100_000;
|
||||
const MAX_BYTES = 100 * 1024 * 1024;
|
||||
const SKIP = "<skip>";
|
||||
|
||||
export interface ImportDialogProps {
|
||||
open: boolean;
|
||||
schema: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
onStage: (change: {
|
||||
type: "bulk_insert";
|
||||
schema: string;
|
||||
table: string;
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
description: string;
|
||||
}) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportDialog({
|
||||
open: isOpen,
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
onStage,
|
||||
onClose,
|
||||
}: ImportDialogProps) {
|
||||
const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null);
|
||||
const [mapping, setMapping] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const chooseFile = async () => {
|
||||
try {
|
||||
const path = await open({
|
||||
filters: [{ name: "Data", extensions: ["csv", "json"] }],
|
||||
});
|
||||
if (!path || Array.isArray(path)) return;
|
||||
|
||||
const text = await readTextFile(path as string);
|
||||
if (text.length > MAX_BYTES) {
|
||||
setError("File exceeds 100 MB limit");
|
||||
return;
|
||||
}
|
||||
|
||||
const { headers, rows } = normalizeImport(text);
|
||||
|
||||
if (rows.length > MAX_ROWS) {
|
||||
setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setParsed({ headers, rows });
|
||||
setMapping(
|
||||
Object.fromEntries(
|
||||
headers.map((h, i) => [h, columns[i] ?? SKIP]),
|
||||
),
|
||||
);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const stage = () => {
|
||||
if (!parsed) return;
|
||||
|
||||
const selected = parsed.headers
|
||||
.map((header) => ({ header, col: mapping[header] }))
|
||||
.filter(({ col }) => col && col !== SKIP);
|
||||
|
||||
const targetColumns = selected.map(({ col }) => col);
|
||||
const dataRows = parsed.rows.map((row) =>
|
||||
selected.map(({ header }) => {
|
||||
const idx = parsed.headers.indexOf(header);
|
||||
return coerceRow(row[idx]);
|
||||
}),
|
||||
);
|
||||
|
||||
onStage({
|
||||
type: "bulk_insert",
|
||||
schema,
|
||||
table,
|
||||
columns: targetColumns,
|
||||
rows: dataRows,
|
||||
description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mappingOptions = [
|
||||
{ value: SKIP, label: "<skip>" },
|
||||
...columns.map((c) => ({ value: c, label: c })),
|
||||
];
|
||||
|
||||
const previewRows = parsed ? parsed.rows.slice(0, 100) : [];
|
||||
|
||||
return (
|
||||
<AnimatedModal open={isOpen} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">
|
||||
Import into {schema}.{table}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={chooseFile}>Choose file</Button>
|
||||
<span className="text-xs text-text-muted">CSV or JSON, up to 100 MB / 100,000 rows</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-md px-4 py-3">
|
||||
<span className="text-red-300 text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
Preview ({parsed.rows.length.toLocaleString()} rows × {parsed.headers.length} columns)
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{parsed.headers.map((header) => (
|
||||
<div key={header} className="flex items-center gap-3">
|
||||
<span className="text-sm text-text w-24 truncate" title={header}>{header}</span>
|
||||
<span className="text-xs text-text-muted">→</span>
|
||||
<Select
|
||||
value={mapping[header] ?? SKIP}
|
||||
onChange={(v) =>
|
||||
setMapping((prev) => ({ ...prev, [header]: v }))
|
||||
}
|
||||
options={mappingOptions}
|
||||
label={`Map ${header}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto max-h-64 rounded-lg border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-surface sticky top-0">
|
||||
<tr className="border-b border-border">
|
||||
{parsed.headers.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left text-text-muted font-heading whitespace-nowrap">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{previewRows.map((row, ri) => (
|
||||
<tr key={ri} className="border-b border-border last:border-0 hover:bg-surface/30">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-3 py-1.5 text-text whitespace-nowrap">
|
||||
{cell === "" ? (
|
||||
<span className="italic text-text-muted/50">null</span>
|
||||
) : (
|
||||
String(cell)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={stage} disabled={!parsed}>Stage import</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Braces, Copy, Check, X } from "lucide-react";
|
||||
|
||||
interface JsonCellPopoverProps {
|
||||
value: unknown;
|
||||
anchorRect: DOMRect | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function safeJsonParse(value: unknown): object | null {
|
||||
if (typeof value === "object" && value !== null) return value as object;
|
||||
if (typeof value !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(obj: object): string {
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return String(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export function JsonCellPopover({ value, anchorRect, onClose }: JsonCellPopoverProps) {
|
||||
const [tab, setTab] = useState<"formatted" | "raw">("formatted");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const parsed = safeJsonParse(value);
|
||||
const rawText = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const formattedText = parsed ? formatJson(parsed) : rawText;
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const id = setTimeout(() => document.addEventListener("mousedown", onClick), 0);
|
||||
return () => {
|
||||
clearTimeout(id);
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!anchorRect) return null;
|
||||
|
||||
const popoverWidth = 420;
|
||||
const popoverMaxHeight = 360;
|
||||
const gap = 8;
|
||||
let left = anchorRect.left;
|
||||
let top = anchorRect.bottom + gap;
|
||||
|
||||
if (left + popoverWidth > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - popoverWidth - 16);
|
||||
}
|
||||
if (top + popoverMaxHeight > window.innerHeight - 16) {
|
||||
top = anchorRect.top - popoverMaxHeight - gap;
|
||||
if (top < 16) top = 16;
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = tab === "formatted" ? formattedText : rawText;
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: popoverWidth,
|
||||
maxHeight: popoverMaxHeight,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-surface/80">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Braces size={12} className="text-accent shrink-0" />
|
||||
<span className="text-xs font-heading text-text">JSON</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Tabs */}
|
||||
<div className="flex rounded bg-surface-raised border border-border overflow-hidden mr-1">
|
||||
<button
|
||||
onClick={() => setTab("formatted")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "formatted" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Formatted
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("raw")}
|
||||
className={`px-2 py-0.5 text-[11px] transition-colors cursor-pointer ${
|
||||
tab === "raw" ? "bg-accent text-white" : "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Raw
|
||||
</button>
|
||||
</div>
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
||||
</button>
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-0.5 rounded hover:bg-surface-hover text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
className="overflow-auto p-3"
|
||||
style={{ maxHeight: popoverMaxHeight - 41 }}
|
||||
>
|
||||
<pre className="text-[11px] text-text font-mono whitespace-pre-wrap break-all leading-relaxed select-text">
|
||||
{tab === "formatted" ? formattedText : rawText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract a brief label for the collapsed JSON preview shown in the cell. */
|
||||
export function jsonPreview(value: unknown): { label: string; isJson: boolean } {
|
||||
const parsed = safeJsonParse(value);
|
||||
if (!parsed) return { label: "", isJson: false };
|
||||
if (Array.isArray(parsed)) {
|
||||
return { label: `[ ${parsed.length} item${parsed.length !== 1 ? "s" : ""} ]`, isJson: true };
|
||||
}
|
||||
const keys = Object.keys(parsed);
|
||||
return { label: `{ ${keys.length} key${keys.length !== 1 ? "s" : ""} }`, isJson: true };
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
describe("ObjectExplorerPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
});
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("renders functions by default", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "add_one",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: ["int"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "sql",
|
||||
source: "SELECT $1 + 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("functions type filters to kind === 'f'", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "do_thing",
|
||||
schema: "public",
|
||||
return_type: "void",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN END",
|
||||
kind: "p",
|
||||
},
|
||||
{
|
||||
name: "calc",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "sql",
|
||||
source: "SELECT 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
|
||||
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
|
||||
{
|
||||
name: "idx_users_email",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
|
||||
is_unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
size_bytes: 8192,
|
||||
tablespace: null,
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
|
||||
);
|
||||
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
|
||||
await user.click(screen.getByText("idx_users_email"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Index")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("8192")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
|
||||
{
|
||||
name: "chk_users_positive",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
contype: "CHECK",
|
||||
definition: "CHECK (age > 0)",
|
||||
deferrable: false,
|
||||
validated: true,
|
||||
columns: ["age"],
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Constraints"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
|
||||
);
|
||||
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
|
||||
await user.click(screen.getByText("chk_users_positive"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Constraint")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Deferrable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No indexes found' when getIndexes returns []", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error message when getIndexes rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("boom")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("procedures type filters getFunctions to kind === 'p'", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "do_thing",
|
||||
schema: "public",
|
||||
return_type: "void",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN END",
|
||||
kind: "p",
|
||||
},
|
||||
{
|
||||
name: "calc",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "sql",
|
||||
source: "SELECT 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Procedures"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("do_thing")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText("calc")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Copy DDL calls getObjectDdl and writes to clipboard", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["a"] },
|
||||
]);
|
||||
vi.spyOn(commands, "getObjectDdl").mockResolvedValue("CREATE TYPE ...");
|
||||
const writeText = vi.fn();
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() => screen.getByText("role"));
|
||||
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||
fireEvent.click(screen.getByText(/copy ddl/i));
|
||||
await waitFor(() =>
|
||||
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
|
||||
);
|
||||
expect(writeText).toHaveBeenCalledWith("CREATE TYPE ...");
|
||||
});
|
||||
|
||||
it("View dependencies opens DependencyDialog", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "add_one",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: ["int"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "sql",
|
||||
source: "SELECT $1 + 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([
|
||||
{ deptype: "n", class: "pg_class", name: "v" },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => screen.getByText("add_one(int)"));
|
||||
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||
fireEvent.click(screen.getByText(/dependencies/i));
|
||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
|
||||
});
|
||||
|
||||
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["admin"] },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() => screen.getByText("role"));
|
||||
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
|
||||
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Drop…")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Edit…"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||
expect(st.tabs[0].form?.mode).toBe("edit");
|
||||
});
|
||||
|
||||
it("right-click on a list row opens the context menu", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["admin"] },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
const row = await screen.findByText("role");
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Create…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("header + create button opens an objectForm create tab for the current type", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["admin"] },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() => screen.getByText("role"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||
expect(st.tabs[0].form?.mode).toBe("create");
|
||||
expect(st.tabs[0].form?.kind).toBe("enum");
|
||||
expect(st.tabs[0].form?.params?.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("refetches the current object list after a ddl commit succeeds", async () => {
|
||||
const getFunctions = vi
|
||||
.spyOn(commands, "getFunctions")
|
||||
.mockResolvedValue([]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql: "DROP INDEX public.i",
|
||||
description: "Drop index i",
|
||||
} as any);
|
||||
useDbViewerStore.getState().markChangeCommitted("ch-1");
|
||||
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "add",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN RETURN 1; END",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("add")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("add"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(
|
||||
st.tabs.some(
|
||||
(t) => t.tabType === "object" && t.table === "add",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("sidebarMode: does not render the inline detail pane", async () => {
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([
|
||||
{ name: "role", schema: "public", labels: ["admin"] },
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
|
||||
fireEvent.click(screen.getByLabelText("Object type"));
|
||||
fireEvent.click(screen.getByText("Enums"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("role")).toBeInTheDocument(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("role"));
|
||||
// clicking opened an object tab (no inline detail selected)
|
||||
expect(
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.tabs.some((t) => t.tabType === "object"),
|
||||
).toBe(true);
|
||||
// detail pane is gone; only the list remains
|
||||
expect(
|
||||
screen.queryByText("Select an enum to view details"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roles fetch ignores the schema filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRoles = vi.spyOn(commands, "getRoles").mockResolvedValue([
|
||||
{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Roles"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("app")).toBeInTheDocument(),
|
||||
);
|
||||
// Roles are cluster-scoped: no schema argument is ever passed, even when
|
||||
// the schema changes (no schema-change refetch for roles).
|
||||
expect(getRoles).toHaveBeenCalledTimes(1);
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
expect(getRoles).not.toHaveBeenCalledWith("c1", "public");
|
||||
useDbViewerStore.setState({ currentSchema: "analytics" });
|
||||
await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(1));
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("preselects type from store on mount", () => {
|
||||
useDbViewerStore.setState({ selectedObjectType: "sequences" });
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
expect(screen.getByText("Sequences")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,631 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
||||
import { ChevronRight, Plus, Search, X, RefreshCw } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import { ObjectContextMenu } from "./objects/ObjectContextMenu";
|
||||
import {
|
||||
ObjectDetail,
|
||||
OBJECT_ICONS,
|
||||
TYPE_LABELS,
|
||||
SINGULAR_LABELS,
|
||||
type AnyObject,
|
||||
} from "./objects/ObjectDetail";
|
||||
import { initialCrudParams, type ObjectKind } from "../../lib/objectCrud";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ObjectType, DependencyInfo } from "../../lib/types";
|
||||
|
||||
|
||||
|
||||
interface ObjectExplorerPageProps {
|
||||
connectionId: string;
|
||||
sidebarMode?: boolean;
|
||||
}
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
(t) => ({ value: t, label: TYPE_LABELS[t] }),
|
||||
);
|
||||
|
||||
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
||||
function emptyPlural(type: ObjectType): string {
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
const irregulars: Record<string, string> = {
|
||||
index: "indexes",
|
||||
constraint: "constraints",
|
||||
};
|
||||
return irregulars[singular] ?? `${singular}s`;
|
||||
}
|
||||
|
||||
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
||||
function itemKey(item: AnyObject): string {
|
||||
const name = (item as any).name as string;
|
||||
if ("argument_types" in item && Array.isArray(item.argument_types)) {
|
||||
return `${name}(${item.argument_types.join(",")})`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Display name for the tree list. Functions show their argument signature. */
|
||||
function itemLabel(item: AnyObject): string {
|
||||
const name = (item as any).name as string;
|
||||
if (
|
||||
"argument_types" in item &&
|
||||
Array.isArray(item.argument_types) &&
|
||||
item.argument_types.length > 0
|
||||
) {
|
||||
return `${name}(${item.argument_types.join(", ")})`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function schemaOf(item: AnyObject): string {
|
||||
// Roles are cluster-scoped and carry no schema — default to "".
|
||||
return (item as { schema?: string }).schema ?? "";
|
||||
}
|
||||
|
||||
function objectName(item: AnyObject): string {
|
||||
return item.name;
|
||||
}
|
||||
|
||||
function typeToDdlType(type: ObjectType): string {
|
||||
const map: Record<ObjectType, string> = {
|
||||
functions: "function",
|
||||
procedures: "procedure",
|
||||
triggers: "trigger",
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
roles: "role",
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
|
||||
export function ObjectExplorerPage({
|
||||
connectionId,
|
||||
sidebarMode = false,
|
||||
}: ObjectExplorerPageProps) {
|
||||
const selectedObjectType = useDbViewerStore((s) => s.selectedObjectType);
|
||||
const [type, setType] = useState<ObjectType>(selectedObjectType ?? "functions");
|
||||
const [panelWidth, setPanelWidth] = useState(280);
|
||||
const panelResizeRef = useRef<{ startX: number; startW: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onPanelResizeStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
panelResizeRef.current = { startX: e.clientX, startW: panelWidth };
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!panelResizeRef.current) return;
|
||||
const delta = ev.clientX - panelResizeRef.current.startX;
|
||||
const next = Math.max(
|
||||
180,
|
||||
Math.min(500, panelResizeRef.current.startW + delta),
|
||||
);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
panelResizeRef.current = null;
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
const databases = useDbViewerStore((s) => s.databases);
|
||||
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
|
||||
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
|
||||
const schemas = useDbViewerStore((s) => s.schemas);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const openObjectTab = useDbViewerStore((s) => s.openObjectTab);
|
||||
const refreshTree = useDbViewerStore((s) => s.refreshTree);
|
||||
|
||||
// Use store for persistence, but allow re-fetching when schema changes
|
||||
const [items, setItems] = useState<AnyObject[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<AnyObject | null>(null);
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
const [depOpen, setDepOpen] = useState(false);
|
||||
const [depDeps, setDepDeps] = useState<DependencyInfo[]>([]);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track last-fetched-schema so we know when to re-fetch
|
||||
const lastSchemaRef = useRef<string | undefined>(undefined);
|
||||
// Track the last committed ddl change so a commit triggers exactly one
|
||||
// refetch (no duplicate refetches across re-renders).
|
||||
const lastCommittedDdlRef = useRef<string>("");
|
||||
|
||||
// Fetch on mount and when schema changes
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let result: AnyObject[];
|
||||
if (type === "roles") {
|
||||
// Roles are cluster-scoped — no schema filter.
|
||||
result = await cmd.getRoles(connectionId);
|
||||
} else if (type === "extensions") {
|
||||
result = await cmd.getExtensions(connectionId);
|
||||
} else if (type === "functions") {
|
||||
result = (await cmd.getFunctions(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
)).filter((f) => f.kind === "f");
|
||||
} else if (type === "procedures") {
|
||||
result = (await cmd.getFunctions(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
)).filter((f) => f.kind === "p");
|
||||
} else if (type === "triggers") {
|
||||
result = await cmd.getTriggers(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "sequences") {
|
||||
result = await cmd.getSequences(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "enums") {
|
||||
result = await cmd.getEnums(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "indexes") {
|
||||
result = await cmd.getIndexes(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "constraints") {
|
||||
result = await cmd.getConstraints(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
setItems(result);
|
||||
setSelectedItem(null);
|
||||
lastSchemaRef.current = currentSchema ?? undefined;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setItems(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [type, connectionId, currentSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only re-fetch if schema actually changed (or first load). Roles are
|
||||
// cluster-scoped — skip the refetch when the schema changes (the mount
|
||||
// fetch still runs via lastSchemaRef === undefined).
|
||||
const schema = currentSchema ?? undefined;
|
||||
const schemaChanged = lastSchemaRef.current !== schema;
|
||||
const skipForRoles =
|
||||
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
|
||||
if (schemaChanged && !skipForRoles) {
|
||||
fetch();
|
||||
}
|
||||
}, [currentSchema, fetch]);
|
||||
|
||||
// After a commit containing ddl items succeeds, refetch the current
|
||||
// object-type list + refresh the schema tree so newly created/dropped
|
||||
// objects show up without a manual refresh.
|
||||
useEffect(() => {
|
||||
const committedDdl = changesQueue.filter(
|
||||
(c) => c.type === "ddl" && c.status === "committed",
|
||||
);
|
||||
const lastCommitted = committedDdl[committedDdl.length - 1];
|
||||
if (lastCommitted && lastCommitted.id !== lastCommittedDdlRef.current) {
|
||||
lastCommittedDdlRef.current = lastCommitted.id;
|
||||
fetch();
|
||||
void refreshTree(connectionId, currentSchema ?? undefined);
|
||||
}
|
||||
}, [changesQueue, connectionId, currentSchema, fetch, refreshTree]);
|
||||
|
||||
// Search toggle handling
|
||||
useEffect(() => {
|
||||
if (searchOpen && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [searchOpen]);
|
||||
|
||||
const handleSearchBlur = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}, 150);
|
||||
}, [searchQuery]);
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
setSearchOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) setSearchQuery("");
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const label = itemLabel(item).toLowerCase();
|
||||
return label.includes(q);
|
||||
});
|
||||
}, [items, q]);
|
||||
|
||||
const icon = OBJECT_ICONS[type];
|
||||
const label = TYPE_LABELS[type];
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
|
||||
// Header create button: open an objectForm tab prefilled with create state for the current type.
|
||||
const openCreate = useCallback(() => {
|
||||
const kind = typeToDdlType(type) as ObjectKind;
|
||||
const schema = currentSchema ?? "public";
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind,
|
||||
schema,
|
||||
name: "",
|
||||
title: `Create ${singular}`,
|
||||
description: `Create ${singular}`,
|
||||
mode: "create",
|
||||
params: initialCrudParams(kind, { schema, name: "" }, "create"),
|
||||
});
|
||||
}, [type, currentSchema]);
|
||||
|
||||
// Switching object type: reset selection/search, clear the stale list so
|
||||
// the loading state renders (no flash of the previous type's objects), and
|
||||
// reset the last-fetched-schema marker so the fetch effect re-runs.
|
||||
const handleTypeChange = useCallback((next: ObjectType) => {
|
||||
setType(next);
|
||||
setSearchQuery("");
|
||||
setSelectedItem(null);
|
||||
setOpenKey(null);
|
||||
setItems(null);
|
||||
setLoading(true);
|
||||
lastSchemaRef.current = undefined;
|
||||
}, []);
|
||||
|
||||
// Consume any object-type preselection from the Cmd+K palette.
|
||||
useEffect(() => {
|
||||
if (selectedObjectType && selectedObjectType !== type) {
|
||||
handleTypeChange(selectedObjectType);
|
||||
}
|
||||
if (selectedObjectType) {
|
||||
useDbViewerStore.getState().setSelectedObjectType(null);
|
||||
}
|
||||
}, [selectedObjectType, type, handleTypeChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Left panel: toolbar + object list */}
|
||||
<div
|
||||
className={
|
||||
sidebarMode
|
||||
? "flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
: "border-r border-border flex flex-col shrink-0"
|
||||
}
|
||||
style={sidebarMode ? undefined : { width: panelWidth }}
|
||||
>
|
||||
<div className="p-3 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<SelectDropdown
|
||||
value={type}
|
||||
onChange={(v) => handleTypeChange(v as ObjectType)}
|
||||
options={OBJECT_TYPE_OPTIONS}
|
||||
variant="ghost"
|
||||
aria-label="Object type"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
aria-label={`Create ${singular}`}
|
||||
onClick={openCreate}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Refresh"
|
||||
onClick={fetch}
|
||||
disabled={loading}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={loading ? "animate-spin" : ""}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Search ${label}`}
|
||||
onClick={toggleSearch}
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center cursor-pointer ${searchOpen ? "text-accent bg-accent/10" : "text-text-muted hover:text-text hover:bg-surface-raised"}`}
|
||||
>
|
||||
<Search size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div
|
||||
ref={searchContainerRef}
|
||||
className={`overflow-hidden transition-all duration-200 ease-out ${searchOpen ? "max-h-10 opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2.5 text-text-muted pointer-events-none"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
placeholder={`Filter ${label.toLowerCase()}…`}
|
||||
className="w-full bg-transparent border-0 border-b border-border pl-8 pr-7 py-1.5 text-xs text-text placeholder:text-text-muted/60 outline-none focus:border-accent/50 transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 flex items-center justify-center w-5 h-5 rounded text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database/Schema dropdowns */}
|
||||
{(databases.length > 1 || schemas.length > 1) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{databases.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentDatabase ?? ""}
|
||||
onChange={(v) =>
|
||||
setCurrentDatabase(v || null)
|
||||
}
|
||||
options={databases.map((d) => ({
|
||||
value: d,
|
||||
label: d,
|
||||
}))}
|
||||
placeholder="Select database"
|
||||
aria-label="Select database"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{databases.length > 1 && schemas.length > 1 && (
|
||||
<span className="text-border">|</span>
|
||||
)}
|
||||
{schemas.length > 1 && (
|
||||
<SelectDropdown
|
||||
value={currentSchema ?? ""}
|
||||
onChange={(v) =>
|
||||
setCurrentSchema(v || null)
|
||||
}
|
||||
options={schemas.map((s) => ({
|
||||
value: s,
|
||||
label: s,
|
||||
}))}
|
||||
placeholder="Select schema"
|
||||
aria-label="Select schema"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Object list */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className="animate-spin mr-2"
|
||||
/>
|
||||
Loading {label.toLowerCase()}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
{searchQuery
|
||||
? `No ${emptyPlural(type)} matching "${searchQuery}"`
|
||||
: `No ${emptyPlural(type)} found`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
filtered.map((item) => {
|
||||
const name = itemLabel(item);
|
||||
const key = itemKey(item);
|
||||
const isSelected =
|
||||
selectedItem !== null &&
|
||||
itemKey(selectedItem) === itemKey(item);
|
||||
|
||||
const handleCopyDdl = async () => {
|
||||
setOpenKey(null);
|
||||
const ddl = await cmd.getObjectDdl(
|
||||
connectionId,
|
||||
schemaOf(item),
|
||||
typeToDdlType(type),
|
||||
objectName(item),
|
||||
);
|
||||
try {
|
||||
await navigator.clipboard?.writeText(ddl);
|
||||
} catch {
|
||||
// Ignore clipboard errors.
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDependencies = async () => {
|
||||
setOpenKey(null);
|
||||
const deps = await cmd.getObjectDependencies(
|
||||
connectionId,
|
||||
schemaOf(item),
|
||||
typeToDdlType(type),
|
||||
objectName(item),
|
||||
);
|
||||
setDepDeps(deps);
|
||||
setDepOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() =>
|
||||
sidebarMode
|
||||
? openObjectTab(
|
||||
type,
|
||||
schemaOf(item),
|
||||
item.name,
|
||||
item,
|
||||
)
|
||||
: setSelectedItem(item)
|
||||
}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenKey(key);
|
||||
}}
|
||||
className={`group flex items-center gap-1 px-3 py-1 cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? "bg-accent/10 text-accent"
|
||||
: "text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="flex-1 text-sm truncate">
|
||||
{name}
|
||||
</span>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative shrink-0"
|
||||
>
|
||||
<ObjectContextMenu
|
||||
connectionId={connectionId}
|
||||
objectType={
|
||||
typeToDdlType(type) as ObjectKind
|
||||
}
|
||||
item={{
|
||||
...item,
|
||||
schema: schemaOf(item),
|
||||
}}
|
||||
onRefresh={fetch}
|
||||
open={openKey === key}
|
||||
onOpenChange={(o) =>
|
||||
setOpenKey(o ? key : null)
|
||||
}
|
||||
extraItems={[
|
||||
{
|
||||
id: "copy-ddl",
|
||||
label: "Copy DDL",
|
||||
onClick: handleCopyDdl,
|
||||
},
|
||||
{
|
||||
id: "dependencies",
|
||||
label: "Dependencies",
|
||||
onClick:
|
||||
handleViewDependencies,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-text-muted shrink-0 transition-transform ${
|
||||
isSelected ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sidebarMode && (
|
||||
<>
|
||||
{/* Panel resize handle */}
|
||||
<div
|
||||
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
|
||||
onMouseDown={onPanelResizeStart}
|
||||
onDoubleClick={() => setPanelWidth(280)}
|
||||
/>
|
||||
|
||||
{/* Right panel: detail view */}
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-y-auto overflow-x-hidden">
|
||||
{selectedItem ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-2 flex flex-row items-center justify-between border-b border-border">
|
||||
<h2 className="text-lg font-semibold text-text font-mono">
|
||||
{itemLabel(selectedItem)}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted capitalize">
|
||||
{singular}
|
||||
{"schema" in selectedItem
|
||||
? ` · ${(selectedItem as any).schema}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Detail content */}
|
||||
<div style={{ overflowX: "auto", width: "100%" }}>
|
||||
<ObjectDetail
|
||||
connectionId={connectionId}
|
||||
type={type}
|
||||
item={selectedItem}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-text-muted">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="flex justify-center">
|
||||
{cloneElement(icon as React.ReactElement<{ size?: number }>, { size: 20 })}
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
Select a {singular} to view details
|
||||
</p>
|
||||
<p className="text-xs text-text-subtle">
|
||||
{filtered.length} {label.toLowerCase()}{" "}
|
||||
available
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DependencyDialog
|
||||
open={depOpen}
|
||||
deps={depDeps}
|
||||
onProceed={() => setDepOpen(false)}
|
||||
onCancel={() => setDepOpen(false)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectSearchPalette } from "./ObjectSearchPalette";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ObjectSearchHit } from "../../lib/types";
|
||||
|
||||
vi.mock("../../lib/commands");
|
||||
|
||||
const mockSetObjectSearchOpen = vi.fn();
|
||||
const mockSetCurrentSchema = vi.fn();
|
||||
const mockSetSelectedObjectType = vi.fn();
|
||||
const mockSetRequestedView = vi.fn();
|
||||
const mockOpenTab = vi.fn();
|
||||
|
||||
const baseMockState = {
|
||||
objectSearchOpen: true,
|
||||
setObjectSearchOpen: mockSetObjectSearchOpen,
|
||||
currentSchema: "public" as string | null,
|
||||
setCurrentSchema: mockSetCurrentSchema,
|
||||
setSelectedObjectType: mockSetSelectedObjectType,
|
||||
setRequestedView: mockSetRequestedView,
|
||||
openTab: mockOpenTab,
|
||||
};
|
||||
|
||||
let mockState: typeof baseMockState = { ...baseMockState };
|
||||
|
||||
vi.mock("../../stores/dbViewerStore", () => ({
|
||||
useDbViewerStore: (selector: unknown) => {
|
||||
return typeof selector === "function"
|
||||
? (selector as (s: typeof mockState) => unknown)(mockState)
|
||||
: mockState[selector as keyof typeof mockState];
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ObjectSearchPalette", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = { ...baseMockState };
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||
const { container } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("debounces and groups results by type", async () => {
|
||||
const hits: ObjectSearchHit[] = [
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
{ name: "get_user", schema: "public", object_type: "FUNCTION" },
|
||||
];
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue(hits);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "user" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "user"),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("TABLE")).toBeInTheDocument();
|
||||
expect(screen.getByText("FUNCTION")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses currentSchema fallback when store value is null", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||
mockState = { ...baseMockState, currentSchema: null };
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(cmd.searchObjects).toHaveBeenCalledWith("c1", "public", "x"),
|
||||
);
|
||||
});
|
||||
|
||||
it("Esc closes the palette", () => {
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("backdrop click closes the palette", () => {
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
const backdrop = screen.getByLabelText(/search objects/i).closest(
|
||||
"div[class*='fixed inset-0']",
|
||||
) as HTMLElement;
|
||||
fireEvent.click(backdrop);
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("selecting a table opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "users" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("users"));
|
||||
fireEvent.click(screen.getByText("users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
|
||||
expect(mockSetCurrentSchema).not.toHaveBeenCalled();
|
||||
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selecting a view opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "active_users", schema: "public", object_type: "VIEW" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "active" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("active_users"));
|
||||
fireEvent.click(screen.getByText("active_users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "active_users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockSetRequestedView).toHaveBeenCalledWith("db-viewer");
|
||||
});
|
||||
|
||||
it("selecting a matview opens a tab and closes", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "mv_users", schema: "public", object_type: "MATERIALIZED VIEW" },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "mv" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("mv_users"));
|
||||
fireEvent.click(screen.getByText("mv_users"));
|
||||
|
||||
expect(mockOpenTab).toHaveBeenCalledWith("public", "mv_users");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["FUNCTION", "functions"],
|
||||
["PROCEDURE", "procedures"],
|
||||
["TRIGGER", "triggers"],
|
||||
["SEQUENCE", "sequences"],
|
||||
["ENUM", "enums"],
|
||||
["EXTENSION", "extensions"],
|
||||
["INDEX", "indexes"],
|
||||
["CONSTRAINT", "constraints"],
|
||||
] as const)(
|
||||
"selecting a %s switches the objects view and closes",
|
||||
async (objectType, mappedType) => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "item", schema: "app", object_type: objectType },
|
||||
]);
|
||||
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "item" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("item"));
|
||||
fireEvent.click(screen.getByText("item"));
|
||||
|
||||
expect(mockSetCurrentSchema).toHaveBeenCalledWith("app");
|
||||
expect(mockSetSelectedObjectType).toHaveBeenCalledWith(mappedType);
|
||||
expect(mockSetRequestedView).toHaveBeenCalledWith("objects");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
expect(mockOpenTab).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("shows an empty state when no results match", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "nomatch" },
|
||||
});
|
||||
await waitFor(() => expect(cmd.searchObjects).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/no matches/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears query and results when reopened", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "users", schema: "public", object_type: "TABLE" },
|
||||
]);
|
||||
const { rerender } = render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "users" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("users"));
|
||||
|
||||
mockState = { ...baseMockState, objectSearchOpen: false };
|
||||
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||
|
||||
mockState = { ...baseMockState, objectSearchOpen: true };
|
||||
rerender(<ObjectSearchPalette connectionId="c1" />);
|
||||
|
||||
expect(screen.queryByText("users")).not.toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/search objects/i)).toHaveValue("");
|
||||
});
|
||||
|
||||
it("ArrowDown then Enter selects the second hit", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "first", schema: "public", object_type: "FUNCTION" },
|
||||
{ name: "second", schema: "public", object_type: "ENUM" },
|
||||
]);
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "s" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("second"));
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
|
||||
// highlight started on the first hit; one ArrowDown moved to the second
|
||||
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("enums");
|
||||
expect(mockSetCurrentSchema).toHaveBeenCalledWith("public");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("ArrowUp wraps from the first hit to the last", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([
|
||||
{ name: "first", schema: "public", object_type: "FUNCTION" },
|
||||
{ name: "last", schema: "public", object_type: "SEQUENCE" },
|
||||
]);
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "s" },
|
||||
});
|
||||
await waitFor(() => screen.getByText("last"));
|
||||
|
||||
fireEvent.keyDown(window, { key: "ArrowUp" });
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
|
||||
expect(mockSetSelectedObjectType).toHaveBeenCalledWith("sequences");
|
||||
expect(mockSetObjectSearchOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Enter with no results does nothing", async () => {
|
||||
vi.mocked(cmd.searchObjects).mockResolvedValue([]);
|
||||
render(<ObjectSearchPalette connectionId="c1" />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search objects/i), {
|
||||
target: { value: "nope" },
|
||||
});
|
||||
await waitFor(() => screen.getByText(/no matches/i));
|
||||
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
|
||||
expect(mockOpenTab).not.toHaveBeenCalled();
|
||||
expect(mockSetSelectedObjectType).not.toHaveBeenCalled();
|
||||
expect(mockSetObjectSearchOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { ObjectSearchHit, ObjectType } from "../../lib/types";
|
||||
|
||||
const TYPE_TO_OBJECTS: Record<string, ObjectType> = {
|
||||
FUNCTION: "functions",
|
||||
PROCEDURE: "procedures",
|
||||
TRIGGER: "triggers",
|
||||
SEQUENCE: "sequences",
|
||||
ENUM: "enums",
|
||||
EXTENSION: "extensions",
|
||||
INDEX: "indexes",
|
||||
CONSTRAINT: "constraints",
|
||||
};
|
||||
|
||||
interface ObjectSearchPaletteProps {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function ObjectSearchPalette({ connectionId }: ObjectSearchPaletteProps) {
|
||||
const open = useDbViewerStore((s) => s.objectSearchOpen);
|
||||
const setOpen = useDbViewerStore((s) => s.setObjectSearchOpen);
|
||||
const storeSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const currentSchema = storeSchema ?? "public";
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const setSelectedObjectType = useDbViewerStore((s) => s.setSelectedObjectType);
|
||||
const setRequestedView = useDbViewerStore((s) => s.setRequestedView);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [hits, setHits] = useState<ObjectSearchHit[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Clear the transient state whenever the palette is closed so it reopens
|
||||
// with an empty search, no stale results, and the highlight reset.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setHits([]);
|
||||
setHighlightedIndex(0);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Debounced search against the current schema.
|
||||
useEffect(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (!query.trim()) {
|
||||
setHits([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await cmd.searchObjects(
|
||||
connectionId,
|
||||
currentSchema,
|
||||
query,
|
||||
);
|
||||
setHits(results);
|
||||
setHighlightedIndex(0);
|
||||
} catch {
|
||||
setHits([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [query, connectionId, currentSchema]);
|
||||
|
||||
// Select: open a table/view tab (switching to the DB viewer view so the
|
||||
// tab is actually visible), or jump to the Objects view with the type
|
||||
// preselected. The view switch goes through the store's requestedView
|
||||
// mechanism — currentView is local state in DbViewerScreen, which watches
|
||||
// requestedView and clears it after navigating.
|
||||
const handleSelect = (hit: ObjectSearchHit) => {
|
||||
if (
|
||||
hit.object_type === "TABLE" ||
|
||||
hit.object_type === "VIEW" ||
|
||||
hit.object_type === "MATERIALIZED VIEW"
|
||||
) {
|
||||
setRequestedView("db-viewer");
|
||||
openTab(hit.schema, hit.name);
|
||||
} else {
|
||||
setCurrentSchema(hit.schema);
|
||||
setSelectedObjectType(TYPE_TO_OBJECTS[hit.object_type] ?? "functions");
|
||||
setRequestedView("objects");
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// Keyboard navigation: ↓/↑ move the highlight (wrapping), Enter picks,
|
||||
// Esc closes. Registered on window so it works even after the input blurs.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!open) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (hits.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i + 1) % hits.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((i) => (i - 1 + hits.length) % hits.length);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const hit = hits[highlightedIndex];
|
||||
if (hit) handleSelect(hit);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, hits, highlightedIndex]);
|
||||
|
||||
// Scroll the highlighted row into view inside the results list.
|
||||
// `?.()` guards environments without scrollIntoView (jsdom) so the ref
|
||||
// callback never throws during commit.
|
||||
const onHighlightedRef = (el: HTMLButtonElement | null) => {
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const grouped = hits.reduce<Record<string, ObjectSearchHit[]>>((acc, hit) => {
|
||||
const list = acc[hit.object_type] ?? [];
|
||||
list.push(hit);
|
||||
acc[hit.object_type] = list;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Flattened index → hit, so keyboard navigation matches the visible order.
|
||||
// (Highlight index is tracked against the flat result list; grouped output
|
||||
// below increments it in render order.)
|
||||
let flatIndex = -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24"
|
||||
onClick={() => setOpen(false)}
|
||||
role="dialog"
|
||||
aria-label="Search objects"
|
||||
>
|
||||
<div
|
||||
className="w-[520px] max-h-[60vh] overflow-auto rounded-xl border border-border bg-surface shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Search size={14} className="text-text-muted" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search objects in current schema…"
|
||||
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
{loading && (
|
||||
<span className="text-xs text-text-muted">loading</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.entries(grouped).map(([type, list]) => (
|
||||
<div key={type}>
|
||||
<div className="px-3 py-1 text-[10px] uppercase text-text-subtle">
|
||||
{type}
|
||||
</div>
|
||||
{list.map((hit) => {
|
||||
flatIndex += 1;
|
||||
const index = flatIndex;
|
||||
const highlighted = index === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={`${hit.object_type}:${hit.schema}:${hit.name}`}
|
||||
type="button"
|
||||
ref={highlighted ? onHighlightedRef : undefined}
|
||||
onClick={() => handleSelect(hit)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
|
||||
highlighted
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{hit.name}</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{hit.schema}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && query.trim() && hits.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm text-text-muted">No matches</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { ChevronRight, ChevronDown, FunctionSquare, GitBranch, ListOrdered, Tag, Puzzle, Search } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../../lib/types";
|
||||
|
||||
type ObjectType = "functions" | "triggers" | "sequences" | "enums" | "extensions";
|
||||
|
||||
interface ObjectTreeProps {
|
||||
type: ObjectType;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "functions",
|
||||
triggers: "triggers",
|
||||
sequences: "sequences",
|
||||
enums: "enums",
|
||||
extensions: "extensions",
|
||||
};
|
||||
|
||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
functions: <FunctionSquare size={14} className="text-text-muted shrink-0" />,
|
||||
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
function SourceCode({ source }: { source: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLen = 500;
|
||||
const truncated = source.length > maxLen && !expanded;
|
||||
const display = truncated ? source.slice(0, maxLen) : source;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<pre className="text-xs text-text-muted bg-surface-raised rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{display}
|
||||
{truncated && <span className="text-text-subtle">...</span>}
|
||||
</pre>
|
||||
{source.length > maxLen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
|
||||
className="text-xs text-accent hover:underline mt-1"
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObjectTree({ type, connectionId }: ObjectTreeProps) {
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const functions = useDbViewerStore((s) => s.functions);
|
||||
const triggers = useDbViewerStore((s) => s.triggers);
|
||||
const sequences = useDbViewerStore((s) => s.sequences);
|
||||
const enums = useDbViewerStore((s) => s.enums);
|
||||
const extensions = useDbViewerStore((s) => s.extensions);
|
||||
const setFunctions = useDbViewerStore((s) => s.setFunctions);
|
||||
const setTriggers = useDbViewerStore((s) => s.setTriggers);
|
||||
const setSequences = useDbViewerStore((s) => s.setSequences);
|
||||
const setEnums = useDbViewerStore((s) => s.setEnums);
|
||||
const setExtensions = useDbViewerStore((s) => s.setExtensions);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Determine which store accessors to use
|
||||
const data = useMemo(() => {
|
||||
switch (type) {
|
||||
case "functions": return functions;
|
||||
case "triggers": return triggers;
|
||||
case "sequences": return sequences;
|
||||
case "enums": return enums;
|
||||
case "extensions": return extensions;
|
||||
}
|
||||
}, [type, functions, triggers, sequences, enums, extensions]);
|
||||
|
||||
const setter = useMemo(() => {
|
||||
switch (type) {
|
||||
case "functions": return setFunctions;
|
||||
case "triggers": return setTriggers;
|
||||
case "sequences": return setSequences;
|
||||
case "enums": return setEnums;
|
||||
case "extensions": return setExtensions;
|
||||
}
|
||||
}, [type, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
|
||||
// Fetch on mount if not in store
|
||||
useEffect(() => {
|
||||
if (data !== null) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (type === "extensions") {
|
||||
const result = await cmd.getExtensions(connectionId);
|
||||
if (!cancelled) (setExtensions as (v: ExtensionInfo[]) => void)(result);
|
||||
} else if (type === "functions") {
|
||||
const result = await cmd.getFunctions(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setFunctions as (v: FunctionInfo[]) => void)(result);
|
||||
} else if (type === "triggers") {
|
||||
const result = await cmd.getTriggers(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setTriggers as (v: TriggerInfo[]) => void)(result);
|
||||
} else if (type === "sequences") {
|
||||
const result = await cmd.getSequences(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setSequences as (v: SequenceInfo[]) => void)(result);
|
||||
} else if (type === "enums") {
|
||||
const result = await cmd.getEnums(connectionId, currentSchema ?? undefined);
|
||||
if (!cancelled) (setEnums as (v: EnumInfo[]) => void)(result);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — store remains null, we show the empty state
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, [type, connectionId, currentSchema, data, setter, setFunctions, setTriggers, setSequences, setEnums, setExtensions]);
|
||||
|
||||
const q = search.toLowerCase().trim();
|
||||
|
||||
const list = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (!q) return data;
|
||||
return data.filter((item) => {
|
||||
const name = "name" in item ? (item as { name: string }).name : "";
|
||||
return name.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, q]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setExpandedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const icon = ICONS[type];
|
||||
const pluralLabel = TYPE_LABELS[type];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Search bar */}
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Search ${pluralLabel}...`}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-7 pr-2 py-1 text-xs bg-surface-raised border border-border rounded text-text placeholder:text-text-subtle focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto py-2" style={{ overscrollBehavior: "none" }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-text-muted">
|
||||
<div className="w-4 h-4 border-2 border-text-muted border-t-accent rounded-full animate-spin mr-2" />
|
||||
Loading {pluralLabel}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && list.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
{data === null ? `No ${pluralLabel} found` : `No ${pluralLabel} found`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && list.map((item) => {
|
||||
const name = "name" in item ? (item as { name: string }).name : "";
|
||||
const key = name;
|
||||
const isExpanded = expandedKeys.has(key);
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
|
||||
onClick={() => toggle(key)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
{icon}
|
||||
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pl-10 pr-3 py-1 space-y-1">
|
||||
{type === "functions" && (() => {
|
||||
const f = item as FunctionInfo;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Returns:</span> {f.return_type}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Language:</span> {f.language}
|
||||
</div>
|
||||
{f.argument_names.length > 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Args:</span>{" "}
|
||||
{f.argument_names.map((a, i) => (
|
||||
<span key={i}>
|
||||
{f.argument_modes?.[i] && f.argument_modes[i] !== "IN" && (
|
||||
<span className="text-amber-400">{f.argument_modes[i]} </span>
|
||||
)}
|
||||
{a} <span className="text-text-subtle">({f.argument_types?.[i] || "unknown"})</span>
|
||||
{i < f.argument_names.length - 1 && ", "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{f.source && <SourceCode source={f.source} />}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "triggers" && (() => {
|
||||
const t = item as TriggerInfo;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Table:</span> {t.table_schema}.{t.table_name}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Event:</span> {t.event_manipulation}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Timing:</span> {t.action_timing}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Orientation:</span> {t.action_orientation}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Enabled:</span> {t.enabled}
|
||||
</div>
|
||||
{t.action_statement && <SourceCode source={t.action_statement} />}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "sequences" && (() => {
|
||||
const s = item as SequenceInfo;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Current:</span> {s.current_value}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Increment:</span> {s.increment}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Min:</span> {s.min_value}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Max:</span> {s.max_value}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Start:</span> {s.start_value}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Cycle:</span> {s.cycle ? "Yes" : "No"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "enums" && (() => {
|
||||
const e = item as EnumInfo;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{e.labels.map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="inline-block px-1.5 py-0.5 text-[10px] rounded bg-surface-raised text-text-muted border border-border"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{type === "extensions" && (() => {
|
||||
const e = item as ExtensionInfo;
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Version:</span> {e.version}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Schema:</span> {e.schema}
|
||||
</div>
|
||||
{e.comment && (
|
||||
<div className="text-xs text-text-muted">
|
||||
<span className="text-text-subtle">Comment:</span> {e.comment}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||
|
||||
describe("PasswordPromptDialog", () => {
|
||||
it("renders nothing when closed", () => {
|
||||
const { container } = render(
|
||||
<PasswordPromptDialog
|
||||
open={false}
|
||||
connectionName="n"
|
||||
onConnect={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows the connection name and a password field when open", () => {
|
||||
render(
|
||||
<PasswordPromptDialog
|
||||
open={true}
|
||||
connectionName="Prod DB"
|
||||
onConnect={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onConnect with the typed value", () => {
|
||||
const onConnect = vi.fn();
|
||||
render(
|
||||
<PasswordPromptDialog
|
||||
open={true}
|
||||
connectionName="X"
|
||||
onConnect={onConnect}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText(/password/i), {
|
||||
target: { value: "p@ss" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
|
||||
expect(onConnect).toHaveBeenCalledWith("p@ss");
|
||||
});
|
||||
|
||||
it("calls onCancel on cancel", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<PasswordPromptDialog
|
||||
open={true}
|
||||
connectionName="X"
|
||||
onConnect={() => {}}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
connectionName: string;
|
||||
onConnect: (password: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PasswordPromptDialog({
|
||||
open,
|
||||
connectionName,
|
||||
onConnect,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [pw, setPw] = useState("");
|
||||
if (!open) return null;
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onCancel}>
|
||||
<div className="p-5 w-80">
|
||||
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
|
||||
<p className="text-xs text-text-muted mb-3">
|
||||
“{connectionName}” has keychain disabled. Enter the password for this
|
||||
session (it will not be saved).
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
aria-label="Password"
|
||||
placeholder="Password"
|
||||
value={pw}
|
||||
onChange={(e) => setPw(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && pw) onConnect(pw);
|
||||
}}
|
||||
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pw && onConnect(pw)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, pgRestore } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
|
||||
interface RestoreDialogProps {
|
||||
open: boolean;
|
||||
connectionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PLATFORM_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(): string {
|
||||
const platform = typeof navigator !== "undefined" ? navigator.platform.toLowerCase() : "";
|
||||
if (platform.includes("mac") || platform.includes("darwin")) return PLATFORM_INSTALL_INSTRUCTIONS.darwin;
|
||||
if (platform.includes("linux")) return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
if (platform.includes("win")) return PLATFORM_INSTALL_INSTRUCTIONS.win32;
|
||||
return PLATFORM_INSTALL_INSTRUCTIONS.linux;
|
||||
}
|
||||
|
||||
export function RestoreDialog({ open, connectionId, onClose }: RestoreDialogProps) {
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [format, setFormat] = useState("custom");
|
||||
const [clean, setClean] = useState(true);
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
try {
|
||||
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "Backup Files", extensions: ["dump", "sql", "tar", "custom", "gz"] }],
|
||||
});
|
||||
if (picked && typeof picked === "string") setFilePath(picked);
|
||||
} catch {
|
||||
// dialog not available (non-Tauri env), use manual path input
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStartRestore = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
const jobId = `restore-${Date.now()}`;
|
||||
startJob(jobId, "restore");
|
||||
try {
|
||||
await pgRestore(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
clean,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
notify("Restore completed successfully", "success");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
notify(`Restore failed: ${parseError(msg)}`, "error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [filePath, format, clean, schema, connectionId, startJob, notify, onClose]);
|
||||
|
||||
const toolsMissing = toolStatus && !toolStatus.pg_restore_found;
|
||||
const canStart = filePath && confirmed && !running;
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Restore Database</h3>
|
||||
|
||||
{checkingTools && (
|
||||
<p className="text-sm text-text-muted mb-4">Checking for pg_restore...</p>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-medium">pg_restore not found</p>
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
The PostgreSQL client tools are required for backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded p-2 whitespace-pre-wrap">
|
||||
{getPlatformInstructions()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<div className="space-y-4">
|
||||
{/* File path */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Backup File</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) => setFilePath(e.target.value)}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
|
||||
/>
|
||||
<Button variant="secondary" onClick={handlePickFile}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Format</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className="rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="custom">Custom Archive</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">Directory</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schema filter */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Schema (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
placeholder="public"
|
||||
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clean toggle */}
|
||||
<label className="flex items-center gap-2 text-sm text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) => setClean(e.target.checked)}
|
||||
className="rounded bg-surface border-border accent-accent"
|
||||
/>
|
||||
Clean (DROP before CREATE)
|
||||
</label>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500"
|
||||
data-testid="restore-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300">
|
||||
I understand this will overwrite data on the target database. This action cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob?.status === "running" && (
|
||||
<BackupProgress
|
||||
progress={50}
|
||||
jobType="restore"
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={running}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartRestore} disabled={!canStart}>
|
||||
{running ? "Restoring..." : "Start Restore"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
|
||||
function parseError(msg: string): string {
|
||||
if (msg.includes("pg_restore:")) {
|
||||
const [, ...rest] = msg.split("pg_restore:");
|
||||
return rest.join(":").trim() || msg;
|
||||
}
|
||||
if (msg.includes("No such file or directory")) {
|
||||
return `File not found. Check the path and try again.`;
|
||||
}
|
||||
if (msg.includes("Permission denied")) {
|
||||
return `Permission denied. Check file permissions.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/backup.dump") }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
const pgToolsOk = {
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
};
|
||||
|
||||
const mysqlToolsOk = {
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
};
|
||||
|
||||
describe("RestorePage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
});
|
||||
|
||||
it("shows format selector for PostgreSQL", async () => {
|
||||
mockConnections.push({ id: "c1", db_type: "postgresql", name: "p" });
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
render(<RestorePage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for pg_restore/i)).not.toBeInTheDocument());
|
||||
expect(screen.getByText("Custom Archive")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a plain MySQL restore (no format selector)", async () => {
|
||||
mockConnections.push({ id: "c2", db_type: "mysql", name: "m", database: "db1" });
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<RestorePage connectionId="c2" />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for mysqldump/i)).not.toBeInTheDocument());
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/Plain SQL restores run via psql/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a plain SQLite restore (no format selector, no tool card)", () => {
|
||||
mockConnections.push({ id: "c3", db_type: "sqlite", name: "s" });
|
||||
render(<RestorePage connectionId="c3" />);
|
||||
expect(screen.queryByText("Custom Archive")).toBeNull();
|
||||
expect(screen.queryByText(/pg_restore/i)).toBeNull();
|
||||
expect(screen.queryByText(/mysqldump/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { FileSearch, Upload } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import {
|
||||
detectPgTools,
|
||||
pgRestore,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlRestore,
|
||||
sqliteRestore,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob } from "../../lib/types";
|
||||
|
||||
interface RestorePageProps {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
const PG_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install libpq",
|
||||
linux: "sudo apt install postgresql-client # Debian/Ubuntu\nsudo dnf install postgresql # Fedora\nsudo pacman -S postgresql # Arch",
|
||||
win32: "Download PostgreSQL installer from https://www.postgresql.org/download/windows/ and ensure pg_restore is in your PATH.",
|
||||
};
|
||||
|
||||
const MYSQL_INSTALL_INSTRUCTIONS: Record<string, string> = {
|
||||
darwin: "brew install mysql-client",
|
||||
linux: "sudo apt install mysql-client # Debian/Ubuntu\nsudo dnf install mysql # Fedora\nsudo pacman -S mariadb # Arch",
|
||||
win32: "Download MySQL installer from https://dev.mysql.com/downloads/installer/ and ensure mysql is in your PATH.",
|
||||
};
|
||||
|
||||
function getPlatformInstructions(map: Record<string, string>): string {
|
||||
const platform =
|
||||
typeof navigator !== "undefined"
|
||||
? navigator.platform.toLowerCase()
|
||||
: "";
|
||||
if (platform.includes("mac") || platform.includes("darwin"))
|
||||
return map.darwin;
|
||||
if (platform.includes("linux")) return map.linux;
|
||||
if (platform.includes("win")) return map.win32;
|
||||
return map.linux;
|
||||
}
|
||||
|
||||
export function RestorePage({ connectionId }: RestorePageProps) {
|
||||
const connection = useConnectionStore((s) =>
|
||||
s.connections.find((c) => c.id === connectionId),
|
||||
);
|
||||
const dbType = connection?.db_type ?? "postgresql";
|
||||
const database = connection?.database ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
const isSqlite = dbType === "sqlite";
|
||||
|
||||
const [filePath, setFilePath] = useState("");
|
||||
const [format, setFormat] = useState("custom");
|
||||
const [clean, setClean] = useState(true);
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(true);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
const isRunning = activeJob?.status === "running";
|
||||
const pendingJobRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingJobRef.current || !activeJob) return;
|
||||
if (activeJob.id !== pendingJobRef.current) return;
|
||||
|
||||
if (activeJob.status === "completed") {
|
||||
notify("Restore completed successfully", "success");
|
||||
pendingJobRef.current = null;
|
||||
} else if (activeJob.status === "failed") {
|
||||
notify(
|
||||
`Restore failed: ${activeJob.error_message || "Unknown error"}`,
|
||||
"error",
|
||||
);
|
||||
pendingJobRef.current = null;
|
||||
}
|
||||
}, [activeJob, notify]);
|
||||
|
||||
useEffect(() => {
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setAvailableSchemas([]);
|
||||
|
||||
if (isPg) {
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else if (isMysql) {
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
|
||||
getSchemas(connectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
} else {
|
||||
setCheckingTools(false);
|
||||
}
|
||||
}, [connectionId, isPg, isMysql]);
|
||||
|
||||
const handlePickFile = useCallback(async () => {
|
||||
const extensions = isPg
|
||||
? ["dump", "sql", "tar", "custom", "gz"]
|
||||
: isMysql
|
||||
? ["sql"]
|
||||
: ["db", "sqlite", "sql"];
|
||||
|
||||
const picked = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "Backup Files",
|
||||
extensions,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (picked && typeof picked === "string") setFilePath(picked);
|
||||
}, [isPg, isMysql, isSqlite]);
|
||||
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartRestore = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
notify("Please select a file path", "error");
|
||||
return;
|
||||
}
|
||||
if (isMysql && !database) {
|
||||
notify("MySQL connection has no database selected", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await runWithProgress("restore", () => {
|
||||
if (isPg) {
|
||||
return pgRestore(connectionId, {
|
||||
format,
|
||||
filePath,
|
||||
clean,
|
||||
schema: schema || undefined,
|
||||
});
|
||||
}
|
||||
if (isMysql) {
|
||||
return mysqlRestore(connectionId, {
|
||||
database: database!,
|
||||
filePath,
|
||||
clean,
|
||||
});
|
||||
}
|
||||
return sqliteRestore(connectionId, { filePath, clean });
|
||||
});
|
||||
}, [
|
||||
filePath,
|
||||
database,
|
||||
isPg,
|
||||
isMysql,
|
||||
isSqlite,
|
||||
format,
|
||||
clean,
|
||||
schema,
|
||||
connectionId,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus && !pgToolStatus.pg_restore_found
|
||||
: isMysql
|
||||
? mysqlToolStatus && !mysqlToolStatus.mysqlFound
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_restore_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqlSource === "bundled"
|
||||
: false;
|
||||
const canStart = filePath && confirmed && !isRunning;
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_restore..."
|
||||
: isMysql
|
||||
? "Checking for mysql..."
|
||||
: null;
|
||||
|
||||
const headerDescription = isPg
|
||||
? "Restore a database from a backup file"
|
||||
: isMysql
|
||||
? "Restore a database from a SQL dump"
|
||||
: "Restore a database from a backup file";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<Upload size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">Restore</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{headerDescription}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg ? "pg_restore not found" : "mysql client not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
The {isPg ? "PostgreSQL" : "MySQL"} client tools are required for
|
||||
backup/restore operations. Install them using:
|
||||
</p>
|
||||
<pre className="text-xs text-amber-100 bg-amber-500/10 rounded-lg p-3 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{getPlatformInstructions(
|
||||
isPg
|
||||
? PG_INSTALL_INSTRUCTIONS
|
||||
: MYSQL_INSTALL_INSTRUCTIONS,
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Format (PostgreSQL only) */}
|
||||
{isPg && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) =>
|
||||
setFormat(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="custom">
|
||||
Custom Archive
|
||||
</option>
|
||||
<option value="plain">Plain SQL</option>
|
||||
<option value="tar">Tarball</option>
|
||||
<option value="directory">
|
||||
Directory
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup file */}
|
||||
<div className="space-y-1 w-full">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Backup File
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={filePath}
|
||||
onChange={(e) =>
|
||||
setFilePath(e.target.value)
|
||||
}
|
||||
placeholder="/path/to/backup.dump"
|
||||
className="flex-1 px-4 py-2 text-sm text-text placeholder-text-muted/50 border-b border-border focus:border-accent focus:outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickFile}
|
||||
className="flex items-center justify-center w-9 h-9 rounded-lg border border-border bg-surface text-text-muted hover:text-text hover:bg-surface-raised hover:border-border-hover transition-colors cursor-pointer shrink-0"
|
||||
aria-label="Browse for file"
|
||||
>
|
||||
<FileSearch size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">All schemas</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clean toggle */}
|
||||
{(isPg || isMysql || isSqlite) && (
|
||||
<label
|
||||
className={`flex items-center gap-2.5 cursor-pointer group ${
|
||||
isPg && format === "plain"
|
||||
? "opacity-40 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clean}
|
||||
onChange={(e) =>
|
||||
setClean(e.target.checked)
|
||||
}
|
||||
disabled={isPg && format === "plain"}
|
||||
className="rounded bg-surface border-border accent-accent w-4 h-4 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-text-muted group-hover:text-text transition-colors">
|
||||
Clean{" "}
|
||||
<code className="text-[11px] text-text-muted/60 bg-surface-raised rounded px-1.5 py-0.5">
|
||||
DROP before CREATE
|
||||
</code>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
{isPg && format === "plain" && (
|
||||
<p className="text-[11px] text-text-muted/70 -mt-3">
|
||||
Plain SQL restores run via psql and don't
|
||||
support DROP-before-CREATE. Use Custom
|
||||
Archive for clean restores.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="restore-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="restore"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartRestore}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" />
|
||||
{isRunning
|
||||
? "Restoring..."
|
||||
: "Start Restore"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { SchemaMenu } from "./SchemaMenu";
|
||||
|
||||
vi.mock("../../lib/commands");
|
||||
|
||||
describe("SchemaMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(cmd.createSchema).mockReset();
|
||||
vi.mocked(cmd.renameSchema).mockReset();
|
||||
vi.mocked(cmd.dropSchema).mockReset();
|
||||
vi.mocked(cmd.getObjectDependencies).mockReset();
|
||||
});
|
||||
|
||||
it("create happy path calls createSchema then refreshTree", async () => {
|
||||
vi.mocked(cmd.createSchema).mockResolvedValue(undefined);
|
||||
const refresh = vi.fn();
|
||||
render(<SchemaMenu connectionId="c1" onRefresh={refresh} />);
|
||||
fireEvent.click(screen.getByLabelText(/new schema/i));
|
||||
fireEvent.change(screen.getByPlaceholderText(/schema name/i), { target: { value: "myschema" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => expect(cmd.createSchema).toHaveBeenCalledWith("c1", "myschema"));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("drop non-empty requires cascade checkbox + typed confirm", async () => {
|
||||
vi.mocked(cmd.getObjectDependencies).mockResolvedValue([{ deptype: "n", class: "pg_class", name: "users" }]);
|
||||
vi.mocked(cmd.dropSchema).mockResolvedValue(undefined);
|
||||
const refresh = vi.fn();
|
||||
render(<SchemaMenu connectionId="c1" schema="s" onRefresh={refresh} />);
|
||||
fireEvent.click(screen.getByLabelText(/schema menu/i));
|
||||
fireEvent.click(screen.getByText(/drop/i));
|
||||
await waitFor(() => expect(cmd.getObjectDependencies).toHaveBeenCalledWith("c1", "s", "schema", "s"));
|
||||
await waitFor(() => expect(screen.getByText("users")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("checkbox"));
|
||||
fireEvent.change(screen.getByPlaceholderText(/type the schema name/i), { target: { value: "s" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /drop schema/i }));
|
||||
await waitFor(() => expect(cmd.dropSchema).toHaveBeenCalledWith("c1", "s", true));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Plus, MoreVertical } from "lucide-react";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { DependencyInfo } from "../../lib/types";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
|
||||
interface SchemaMenuProps {
|
||||
connectionId: string;
|
||||
schema?: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function SchemaMenu({ connectionId, schema, onRefresh }: SchemaMenuProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const [dropOpen, setDropOpen] = useState(false);
|
||||
const [deps, setDeps] = useState<DependencyInfo[]>([]);
|
||||
const [typed, setTyped] = useState("");
|
||||
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuOpen]);
|
||||
|
||||
const resetErrors = () => setErr(null);
|
||||
|
||||
const create = async () => {
|
||||
resetErrors();
|
||||
try {
|
||||
await cmd.createSchema(connectionId, name);
|
||||
setCreating(false);
|
||||
setName("");
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
resetErrors();
|
||||
if (!schema) return;
|
||||
try {
|
||||
await cmd.renameSchema(connectionId, schema, newName);
|
||||
setRenaming(false);
|
||||
setNewName("");
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const startDrop = async () => {
|
||||
setMenuOpen(false);
|
||||
if (!schema) return;
|
||||
try {
|
||||
const d = await cmd.getObjectDependencies(connectionId, schema, "schema", schema);
|
||||
setDeps(d);
|
||||
setDropOpen(true);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDrop = async (cascade: boolean) => {
|
||||
if (!schema) return;
|
||||
try {
|
||||
await cmd.dropSchema(connectionId, schema, cascade);
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setDeps([]);
|
||||
onRefresh();
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const hasDeps = deps.length > 0;
|
||||
|
||||
const inputClass =
|
||||
"w-full bg-surface border border-border rounded-md px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:border-accent/50 transition-colors";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1" ref={menuRef}>
|
||||
<button
|
||||
aria-label="New schema"
|
||||
onClick={() => {
|
||||
setCreating(true);
|
||||
setName("");
|
||||
setErr(null);
|
||||
}}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
|
||||
{schema && (
|
||||
<button
|
||||
aria-label="Schema menu"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-8 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[160px] shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setRenaming(true);
|
||||
setNewName("");
|
||||
setErr(null);
|
||||
}}
|
||||
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startDrop}
|
||||
className="flex items-center px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
>
|
||||
Drop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Schema */}
|
||||
<AnimatedModal open={creating} onClose={() => setCreating(false)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">New Schema</h3>
|
||||
<p className="text-sm text-text-muted mb-3">Create a new schema in the current database.</p>
|
||||
<input
|
||||
placeholder="Schema name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={() => setCreating(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={create}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
|
||||
{/* Rename Schema */}
|
||||
<AnimatedModal open={renaming} onClose={() => setRenaming(false)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Rename {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">Enter the new name for this schema.</p>
|
||||
<input
|
||||
placeholder="New name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={() => setRenaming(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={rename}>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
|
||||
{/* Dependency warning */}
|
||||
{dropOpen && (
|
||||
<DependencyDialog
|
||||
open={dropOpen}
|
||||
deps={deps}
|
||||
onCancel={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setDeps([]);
|
||||
setErr(null);
|
||||
}}
|
||||
onProceed={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Typed-name confirmation for CASCADE drop */}
|
||||
{dropOpen && hasDeps && (
|
||||
<AnimatedModal
|
||||
open={dropOpen}
|
||||
onClose={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Type the schema name to confirm the CASCADE drop.
|
||||
</p>
|
||||
<input
|
||||
placeholder="Type the schema name"
|
||||
value={typed}
|
||||
onChange={(e) => {
|
||||
setTyped(e.target.value);
|
||||
if (err) setErr(null);
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropOpen(false);
|
||||
setTyped("");
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (typed !== schema) {
|
||||
setErr("Name does not match");
|
||||
return;
|
||||
}
|
||||
void confirmDrop(true);
|
||||
}}
|
||||
>
|
||||
Drop Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
|
||||
{/* Empty-schema confirmation (no deps) */}
|
||||
{dropOpen && !hasDeps && (
|
||||
<AnimatedModal
|
||||
open={dropOpen}
|
||||
onClose={() => {
|
||||
setDropOpen(false);
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">Drop Schema: {schema}</h3>
|
||||
<p className="text-sm text-text-muted mb-3">No dependencies — drop this empty schema?</p>
|
||||
{err && <p className="text-red-400 text-xs mt-2">{err}</p>}
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setDropOpen(false);
|
||||
setErr(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void confirmDrop(false)}>
|
||||
Drop Schema
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, is_nullable: false, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: true, is_nullable: false, 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,446 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { Mock } from "vitest";
|
||||
import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { toPng } from "html-to-image";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||
|
||||
// Mock html-to-image so exports don't hit real DOM capture in jsdom
|
||||
vi.mock("html-to-image", () => ({
|
||||
toPng: vi.fn().mockResolvedValue("data:image/png;base64,AAAA"),
|
||||
toJpeg: vi.fn().mockResolvedValue("data:image/jpeg;base64,AAAA"),
|
||||
toSvg: vi.fn().mockResolvedValue("data:image/svg+xml;base64,AAAA"),
|
||||
}));
|
||||
|
||||
// Mock the Tauri save dialog and fs write
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({
|
||||
save: vi.fn().mockResolvedValue("/tmp/export.png"),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// 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(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
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();
|
||||
});
|
||||
|
||||
it("renders the Export menu with a scope selector and format options", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
expect(screen.getByLabelText("Export scope")).toBeInTheDocument();
|
||||
expect(screen.getByText("PNG")).toBeInTheDocument();
|
||||
expect(screen.getByText("JPEG")).toBeInTheDocument();
|
||||
expect(screen.getByText("SVG")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exports the viewport as PNG when Viewport scope is selected", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
// pick Viewport scope, then PNG
|
||||
fireEvent.click(screen.getByLabelText("Export scope"));
|
||||
fireEvent.click(screen.getByText("Viewport"));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
expect(toPng).toHaveBeenCalledTimes(1);
|
||||
const [, options] = (toPng as Mock).mock.calls[0];
|
||||
// Viewport export keeps the current view — no transform override
|
||||
expect(options.style).toBeUndefined();
|
||||
expect(options.width).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("exports the entire schema as PNG with a computed transform", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByText("PNG")); // scope defaults to Entire Schema
|
||||
|
||||
expect(toPng).toHaveBeenCalledTimes(1);
|
||||
const [, options] = (toPng as Mock).mock.calls[0];
|
||||
expect(options.style.transform).toContain("scale(");
|
||||
});
|
||||
|
||||
it("shows an export error when the schema has no nodes", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({ tables: [], relationships: [] });
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
// default mock resolves an empty graph
|
||||
await screen.findByText(/no tables found/i);
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
expect(await screen.findByText(/export failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves through the dialog and notifies with the file path", async () => {
|
||||
(save as Mock).mockResolvedValue("/Users/me/Pictures/public-export.png");
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
await waitFor(() => expect(writeFile).toHaveBeenCalledTimes(1));
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
"/Users/me/Pictures/public-export.png",
|
||||
expect.any(Uint8Array),
|
||||
);
|
||||
const notification = useNotificationStore
|
||||
.getState()
|
||||
.notifications.find((n) => n.message.includes("exported to"));
|
||||
expect(notification).toBeTruthy();
|
||||
expect(notification!.message).toContain(
|
||||
"/Users/me/Pictures/public-export.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the filename to the db name plus a locale timestamp", async () => {
|
||||
(save as Mock).mockResolvedValue(null);
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
const { defaultPath } = (save as Mock).mock.calls[0][0];
|
||||
// schema is "public" here; timestamp is locale-formatted then sanitized
|
||||
expect(defaultPath).toMatch(/^public-.*\.png$/);
|
||||
});
|
||||
|
||||
it("exports a transparent PNG when a transparent background is selected", async () => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByLabelText("Export background"));
|
||||
fireEvent.click(screen.getByText("Transparent"));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
await waitFor(() => expect(toPng).toHaveBeenCalled());
|
||||
const [, options] = (toPng as Mock).mock.calls[0];
|
||||
expect(options.backgroundColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides the JPEG option when a transparent background is selected", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
// JPEG is available with an opaque background by default
|
||||
expect(screen.getByText("JPEG")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Export background"));
|
||||
fireEvent.click(screen.getByText("Transparent"));
|
||||
|
||||
expect(screen.queryByText("JPEG")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("PNG")).toBeInTheDocument();
|
||||
expect(screen.getByText("SVG")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not write the file when the save dialog is cancelled", async () => {
|
||||
(save as Mock).mockResolvedValue(null);
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
(invoke as any).mockReset();
|
||||
(invoke as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<SchemaVisualizerPage
|
||||
connectionId="conn-1"
|
||||
onSchemaChange={() => {}}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await screen.findByText("1 table");
|
||||
|
||||
fireEvent.click(screen.getByText(/export/i));
|
||||
fireEvent.click(screen.getByText("PNG"));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useNotificationStore
|
||||
.getState()
|
||||
.notifications.some((n) => n.message.includes("exported to")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
getNodesBounds,
|
||||
getViewportForBounds,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import dagre from "dagre";
|
||||
import { RotateCcw, ChevronUp, ChevronDown, Download, Loader2 } from "lucide-react";
|
||||
import { toPng, toJpeg, toSvg } from "html-to-image";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeFile } from "@tauri-apps/plugin-fs";
|
||||
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 { useNotificationStore } from "../../stores/notificationStore";
|
||||
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;
|
||||
|
||||
// Export size for "Entire Schema" renders
|
||||
const EXPORT_WIDTH = 1600;
|
||||
const EXPORT_HEIGHT = 1000;
|
||||
|
||||
/**
|
||||
* Decode an html-to-image data URL (base64 or URL-encoded) into bytes so it
|
||||
* can be written to disk via the Tauri fs plugin.
|
||||
*/
|
||||
function dataUrlToBytes(dataUrl: string): Uint8Array {
|
||||
const [meta, payload] = dataUrl.split(",");
|
||||
const raw = /;base64/i.test(meta) ? atob(payload) : decodeURIComponent(payload);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
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 },
|
||||
width: CARD_WIDTH,
|
||||
height,
|
||||
});
|
||||
}
|
||||
|
||||
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],
|
||||
labelBgBorderRadius: 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<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
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);
|
||||
|
||||
// Export state
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
const exportButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportScope, setExportScope] = useState<"schema" | "viewport">(
|
||||
"schema",
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [exportBackground, setExportBackground] = useState<
|
||||
"opaque" | "transparent"
|
||||
>("opaque");
|
||||
const transparent = exportBackground === "transparent";
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
// Close the export menu on outside click (ignoring the trigger button)
|
||||
useEffect(() => {
|
||||
if (!exportOpen) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
const target = e.target as Element | null;
|
||||
if (exportButtonRef.current?.contains(target)) return;
|
||||
if (exportMenuRef.current?.contains(target)) return;
|
||||
setExportOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [exportOpen]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (scope: "schema" | "viewport", format: "png" | "jpeg" | "svg") => {
|
||||
const element = document.querySelector<HTMLElement>(
|
||||
".react-flow__viewport",
|
||||
);
|
||||
if (!element) return;
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
try {
|
||||
let width: number;
|
||||
let height: number;
|
||||
let style: Partial<CSSStyleDeclaration> | undefined;
|
||||
if (scope === "viewport") {
|
||||
const container = containerRef.current;
|
||||
width = container?.clientWidth || 1024;
|
||||
height = container?.clientHeight || 768;
|
||||
} else {
|
||||
width = EXPORT_WIDTH;
|
||||
height = EXPORT_HEIGHT;
|
||||
const bounds = getNodesBounds(nodes);
|
||||
if (bounds.width === 0 && bounds.height === 0) {
|
||||
throw new Error("Nothing to export");
|
||||
}
|
||||
const viewport = getViewportForBounds(
|
||||
bounds,
|
||||
width,
|
||||
height,
|
||||
0.5,
|
||||
2,
|
||||
0.05,
|
||||
);
|
||||
style = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
// JPEG has no alpha channel; transparency only applies to PNG/SVG
|
||||
...(transparent && format !== "jpeg"
|
||||
? {}
|
||||
: { backgroundColor: "#0a0a0b" }),
|
||||
width,
|
||||
height,
|
||||
style,
|
||||
pixelRatio: 2,
|
||||
};
|
||||
const dataUrl =
|
||||
format === "png"
|
||||
? await toPng(element, options)
|
||||
: format === "jpeg"
|
||||
? await toJpeg(element, { ...options, quality: 0.95 })
|
||||
: await toSvg(element, options);
|
||||
|
||||
// Filename: <db name>-<locale timestamp>.<ext>
|
||||
const dbName = currentDatabase ?? currentSchema ?? "schema";
|
||||
const timestamp = new Date()
|
||||
.toLocaleString()
|
||||
.replace(/[\\/:*?"<>|]/g, "-")
|
||||
.replace(/\s+/g, "-");
|
||||
const ext = format === "jpeg" ? "jpg" : format;
|
||||
const filename = `${dbName}-${timestamp}.${ext}`;
|
||||
|
||||
const bytes = dataUrlToBytes(dataUrl);
|
||||
let savedPath: string | null = null;
|
||||
try {
|
||||
const path = await save({
|
||||
defaultPath: filename,
|
||||
filters: [
|
||||
{ name: format.toUpperCase(), extensions: [ext] },
|
||||
],
|
||||
});
|
||||
if (path) {
|
||||
await writeFile(path, bytes);
|
||||
savedPath = path;
|
||||
}
|
||||
} catch {
|
||||
// Not running in Tauri (e.g. plain browser dev): fall back to the
|
||||
// webview's default download handler.
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
if (savedPath) {
|
||||
notify(`Schema exported to ${savedPath}`, "success");
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setExportError(`Export failed: ${msg}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setExportOpen(false);
|
||||
}
|
||||
},
|
||||
[nodes, currentSchema, currentDatabase, exportBackground, notify],
|
||||
);
|
||||
|
||||
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-20">
|
||||
<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>
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex items-center gap-2">
|
||||
{exportError && (
|
||||
<span className="text-[11px] text-red-400 max-w-56 truncate">
|
||||
{exportError}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
ref={exportButtonRef}
|
||||
onClick={() => setExportOpen((v) => !v)}
|
||||
disabled={exporting}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={12} />
|
||||
)}
|
||||
{exporting ? "Exporting…" : "Export"}
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
{exportOpen && !exporting && (
|
||||
<div
|
||||
ref={exportMenuRef}
|
||||
className="absolute right-0 top-full mt-1 z-30 w-52 rounded-lg bg-surface border border-border shadow-lg py-2 px-2"
|
||||
>
|
||||
<div className="px-1 pb-1.5 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Scope
|
||||
</div>
|
||||
<SelectDropdown
|
||||
value={exportScope}
|
||||
onChange={(v) =>
|
||||
setExportScope(v as "schema" | "viewport")
|
||||
}
|
||||
options={[
|
||||
{ value: "schema", label: "Entire Schema" },
|
||||
{ value: "viewport", label: "Viewport" },
|
||||
]}
|
||||
aria-label="Export scope"
|
||||
variant="pill"
|
||||
/>
|
||||
<div className="px-1 pb-1.5 pt-1.5 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Background
|
||||
</div>
|
||||
<SelectDropdown
|
||||
value={exportBackground}
|
||||
onChange={(v) =>
|
||||
setExportBackground(v as "opaque" | "transparent")
|
||||
}
|
||||
options={[
|
||||
{ value: "opaque", label: "Opaque" },
|
||||
{ value: "transparent", label: "Transparent" },
|
||||
]}
|
||||
aria-label="Export background"
|
||||
variant="pill"
|
||||
/>
|
||||
<div className="border-t border-border my-1.5" />
|
||||
{[
|
||||
{ format: "png" as const, label: "PNG" },
|
||||
{ format: "jpeg" as const, label: "JPEG" },
|
||||
{ format: "svg" as const, label: "SVG" },
|
||||
]
|
||||
.filter((f) => !(transparent && f.format === "jpeg"))
|
||||
.map(({ format, label }) => (
|
||||
<button
|
||||
key={format}
|
||||
type="button"
|
||||
onClick={() => handleExport(exportScope, format)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 min-h-0 relative" ref={containerRef}>
|
||||
{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={BackgroundVariant.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,203 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { detectPgTools, dbSync } from "../../lib/commands";
|
||||
import type { PgToolStatus } from "../../lib/types";
|
||||
|
||||
interface SyncDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SyncDialog({ open, onClose }: SyncDialogProps) {
|
||||
const [sourceConnectionId, setSourceConnectionId] = useState("");
|
||||
const [targetConnectionId, setTargetConnectionId] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [toolStatus, setToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCheckingTools(true);
|
||||
setConfirmed(false);
|
||||
detectPgTools()
|
||||
.then((status) => setToolStatus(status))
|
||||
.catch(() => setToolStatus({ pg_dump_found: false, pg_restore_found: false, pg_dump_version: null, pg_restore_version: null, pg_dump_source: null, pg_restore_source: null }))
|
||||
.finally(() => setCheckingTools(false));
|
||||
}, [open]);
|
||||
|
||||
const handleStartSync = useCallback(async () => {
|
||||
if (!sourceConnectionId || !targetConnectionId) {
|
||||
notify("Please select both source and target connections", "error");
|
||||
return;
|
||||
}
|
||||
if (sourceConnectionId === targetConnectionId) {
|
||||
notify("Source and target must be different", "error");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
const jobId = `sync-${Date.now()}`;
|
||||
startJob(jobId, "sync");
|
||||
try {
|
||||
await dbSync({
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
dbType: "postgresql",
|
||||
});
|
||||
notify("Sync completed successfully", "success");
|
||||
onClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
notify(`Sync failed: ${parseError(msg)}`, "error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [sourceConnectionId, targetConnectionId, schema, startJob, notify, onClose]);
|
||||
|
||||
const toolsMissing = toolStatus && (!toolStatus.pg_dump_found || !toolStatus.pg_restore_found);
|
||||
const canStart = sourceConnectionId && targetConnectionId && confirmed && !running;
|
||||
|
||||
const postgresqlConnections = connections.filter((c) => c.db_type === "postgresql");
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-full min-w-md max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
<h3 className="font-heading text-text text-lg mb-4">Sync Databases</h3>
|
||||
|
||||
{checkingTools && (
|
||||
<p className="text-sm text-text-muted mb-4">Checking for pg_dump/pg_restore...</p>
|
||||
)}
|
||||
|
||||
{toolsMissing && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-md px-4 py-3 mb-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-medium">PostgreSQL tools not found</p>
|
||||
<p className="text-amber-200/80 text-xs">
|
||||
Both pg_dump and pg_restore are required for database sync.
|
||||
</p>
|
||||
{!toolStatus?.pg_dump_found && (
|
||||
<p className="text-amber-200/80 text-xs">pg_dump is missing.</p>
|
||||
)}
|
||||
{!toolStatus?.pg_restore_found && (
|
||||
<p className="text-amber-200/80 text-xs">pg_restore is missing.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<div className="space-y-4">
|
||||
{/* Source connection */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Source Connection</label>
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) => setSourceConnectionId(e.target.value)}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">Select source...</option>
|
||||
{postgresqlConnections.map((c) => (
|
||||
<option key={c.id} value={c.id} disabled={c.id === targetConnectionId}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target connection */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Target Connection</label>
|
||||
<select
|
||||
value={targetConnectionId}
|
||||
onChange={(e) => setTargetConnectionId(e.target.value)}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">Select target...</option>
|
||||
{postgresqlConnections.map((c) => (
|
||||
<option key={c.id} value={c.id} disabled={c.id === sourceConnectionId}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schema filter */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-text-muted">Schema (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
placeholder="public"
|
||||
className="w-full rounded-full bg-surface border border-border px-4 py-2 text-sm text-text placeholder-text-muted/60 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-lg px-4 py-3">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500"
|
||||
data-testid="sync-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300">
|
||||
I understand this will overwrite data on the target database. This action cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob?.status === "running" && (
|
||||
<BackupProgress
|
||||
progress={50}
|
||||
jobType="sync"
|
||||
status="running"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onClose} disabled={running}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartSync} disabled={!canStart}>
|
||||
{running ? "Syncing..." : "Start Sync"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
|
||||
function parseError(msg: string): string {
|
||||
if (msg.includes("pg_dump:") || msg.includes("pg_restore:")) {
|
||||
const parts = msg.split(/pg_(dump|restore):/);
|
||||
return parts[parts.length - 1]?.trim() || msg;
|
||||
}
|
||||
if (msg.includes("No such file or directory")) {
|
||||
return `File not found. Check the output path and try again.`;
|
||||
}
|
||||
if (msg.includes("Permission denied")) {
|
||||
return `Permission denied. Check file permissions.`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn().mockReturnValue(Promise.resolve(() => {})) }));
|
||||
|
||||
const mockConnections: any[] = [];
|
||||
vi.mock("../../stores/connectionStore", () => ({
|
||||
useConnectionStore: (selector: any) => selector({ connections: mockConnections }),
|
||||
}));
|
||||
|
||||
const pgToolsOk = {
|
||||
pg_dump_found: true,
|
||||
pg_restore_found: true,
|
||||
pg_dump_version: "16",
|
||||
pg_restore_version: "16",
|
||||
pg_dump_source: "system",
|
||||
pg_restore_source: "system",
|
||||
};
|
||||
|
||||
const mysqlToolsOk = {
|
||||
mysqldumpFound: true,
|
||||
mysqlFound: true,
|
||||
mysqldumpVersion: "8.0",
|
||||
mysqlVersion: "8.0",
|
||||
mysqldumpSource: "system",
|
||||
mysqlSource: "system",
|
||||
};
|
||||
|
||||
describe("SyncPage DB-aware", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockConnections.length = 0;
|
||||
useBackupStore.setState({ jobs: [], activeJobId: null, progress: 0 });
|
||||
useNotificationStore.setState({ notifications: [] });
|
||||
});
|
||||
|
||||
it("filters target connections to the same db_type as source (mysql)", async () => {
|
||||
mockConnections.push(
|
||||
{ id: "pg1", db_type: "postgresql", name: "Postgres 1" },
|
||||
{ id: "my1", db_type: "mysql", name: "MySQL 1" },
|
||||
{ id: "my2", db_type: "mysql", name: "MySQL 2" },
|
||||
{ id: "sq1", db_type: "sqlite", name: "SQLite 1" },
|
||||
);
|
||||
vi.spyOn(commands, "detectPgTools").mockResolvedValue(pgToolsOk);
|
||||
vi.spyOn(commands, "detectMysqlTools").mockResolvedValue(mysqlToolsOk);
|
||||
render(<SyncPage />);
|
||||
await waitFor(() => expect(screen.queryByText(/checking for/i)).not.toBeInTheDocument());
|
||||
|
||||
const [sourceSelect, targetSelect] = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||
fireEvent.change(sourceSelect, { target: { value: "my1" } });
|
||||
|
||||
const options = Array.from(targetSelect.options).map((o) => o.value);
|
||||
expect(options).toContain("my1");
|
||||
expect(options).toContain("my2");
|
||||
expect(options).not.toContain("pg1");
|
||||
expect(options).not.toContain("sq1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { ArrowLeftRight, Database } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { BackupProgress } from "./BackupProgress";
|
||||
import { useBackupStore } from "../../stores/backupStore";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import {
|
||||
detectPgTools,
|
||||
dbSync,
|
||||
getSchemas,
|
||||
detectMysqlTools,
|
||||
mysqlSync,
|
||||
sqliteSync,
|
||||
} from "../../lib/commands";
|
||||
import type { PgToolStatus, MySqlToolStatus, BackupJob, DbType } from "../../lib/types";
|
||||
|
||||
export function SyncPage() {
|
||||
const [sourceConnectionId, setSourceConnectionId] = useState("");
|
||||
const [targetConnectionId, setTargetConnectionId] = useState("");
|
||||
const [schema, setSchema] = useState("");
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [pgToolStatus, setPgToolStatus] = useState<PgToolStatus | null>(null);
|
||||
const [mysqlToolStatus, setMysqlToolStatus] = useState<MySqlToolStatus | null>(null);
|
||||
const [checkingTools, setCheckingTools] = useState(false);
|
||||
const [availableSchemas, setAvailableSchemas] = useState<string[]>([]);
|
||||
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
const activeJobId = useBackupStore((s) => s.activeJobId);
|
||||
const jobs = useBackupStore((s) => s.jobs);
|
||||
const startJob = useBackupStore((s) => s.startJob);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
|
||||
const activeJob = jobs.find((j) => j.id === activeJobId);
|
||||
const isRunning = activeJob?.status === "running";
|
||||
const pendingJobRef = useRef<string | null>(null);
|
||||
|
||||
const sourceConnection = connections.find(
|
||||
(c) => c.id === sourceConnectionId,
|
||||
);
|
||||
const dbType: DbType | null = sourceConnection?.db_type ?? null;
|
||||
const isPg = dbType === "postgresql";
|
||||
const isMysql = dbType === "mysql";
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingJobRef.current || !activeJob) return;
|
||||
if (activeJob.id !== pendingJobRef.current) return;
|
||||
|
||||
if (activeJob.status === "completed") {
|
||||
notify("Sync completed successfully", "success");
|
||||
pendingJobRef.current = null;
|
||||
} else if (activeJob.status === "failed") {
|
||||
notify(
|
||||
`Sync failed: ${activeJob.error_message || "Unknown error"}`,
|
||||
"error",
|
||||
);
|
||||
pendingJobRef.current = null;
|
||||
}
|
||||
}, [activeJob, notify]);
|
||||
|
||||
// Tool detection: depends on the selected source connection's DB type
|
||||
useEffect(() => {
|
||||
setPgToolStatus(null);
|
||||
setMysqlToolStatus(null);
|
||||
setConfirmed(false);
|
||||
|
||||
if (isPg) {
|
||||
setCheckingTools(true);
|
||||
detectPgTools()
|
||||
.then((status) => setPgToolStatus(status))
|
||||
.catch(() =>
|
||||
setPgToolStatus({
|
||||
pg_dump_found: false,
|
||||
pg_restore_found: false,
|
||||
pg_dump_version: null,
|
||||
pg_restore_version: null,
|
||||
pg_dump_source: null,
|
||||
pg_restore_source: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
} else if (isMysql) {
|
||||
setCheckingTools(true);
|
||||
detectMysqlTools()
|
||||
.then((status) => setMysqlToolStatus(status))
|
||||
.catch(() =>
|
||||
setMysqlToolStatus({
|
||||
mysqldumpFound: false,
|
||||
mysqlFound: false,
|
||||
mysqldumpVersion: null,
|
||||
mysqlVersion: null,
|
||||
mysqldumpSource: null,
|
||||
mysqlSource: null,
|
||||
}),
|
||||
)
|
||||
.finally(() => setCheckingTools(false));
|
||||
}
|
||||
}, [isPg, isMysql]);
|
||||
|
||||
// Fetch schemas from the source connection when it changes
|
||||
useEffect(() => {
|
||||
if (!sourceConnectionId) {
|
||||
setAvailableSchemas([]);
|
||||
setSchema("");
|
||||
return;
|
||||
}
|
||||
if (!isPg && !isMysql) {
|
||||
setAvailableSchemas([]);
|
||||
setSchema("");
|
||||
return;
|
||||
}
|
||||
getSchemas(sourceConnectionId)
|
||||
.then((schemas) => setAvailableSchemas(schemas))
|
||||
.catch(() => setAvailableSchemas([]));
|
||||
}, [sourceConnectionId, isPg, isMysql]);
|
||||
|
||||
const runWithProgress = useCallback(
|
||||
async (type: BackupJob["type"], action: () => Promise<unknown>) => {
|
||||
const jobId = `${type}-${Date.now()}`;
|
||||
startJob(jobId, type);
|
||||
pendingJobRef.current = jobId;
|
||||
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
useBackupStore.getState().failJob(jobId, msg);
|
||||
}
|
||||
},
|
||||
[startJob],
|
||||
);
|
||||
|
||||
const handleStartSync = useCallback(async () => {
|
||||
if (!sourceConnectionId || !targetConnectionId) {
|
||||
notify("Please select both source and target connections", "error");
|
||||
return;
|
||||
}
|
||||
if (sourceConnectionId === targetConnectionId) {
|
||||
notify("Source and target must be different", "error");
|
||||
return;
|
||||
}
|
||||
if (!dbType) {
|
||||
notify("Unable to determine database type for sync", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
schema: schema || undefined,
|
||||
tables: undefined,
|
||||
dbType,
|
||||
};
|
||||
|
||||
await runWithProgress("sync", () => {
|
||||
if (isPg) return dbSync(options);
|
||||
if (isMysql) return mysqlSync(options);
|
||||
return sqliteSync(options);
|
||||
});
|
||||
}, [
|
||||
sourceConnectionId,
|
||||
targetConnectionId,
|
||||
dbType,
|
||||
isPg,
|
||||
isMysql,
|
||||
schema,
|
||||
notify,
|
||||
runWithProgress,
|
||||
]);
|
||||
|
||||
const toolsMissing = isPg
|
||||
? pgToolStatus &&
|
||||
(!pgToolStatus.pg_dump_found || !pgToolStatus.pg_restore_found)
|
||||
: isMysql
|
||||
? mysqlToolStatus &&
|
||||
(!mysqlToolStatus.mysqldumpFound || !mysqlToolStatus.mysqlFound)
|
||||
: false;
|
||||
const toolsBundled = isPg
|
||||
? pgToolStatus?.pg_dump_source === "bundled" &&
|
||||
pgToolStatus?.pg_restore_source === "bundled"
|
||||
: isMysql
|
||||
? mysqlToolStatus?.mysqldumpSource === "bundled" &&
|
||||
mysqlToolStatus?.mysqlSource === "bundled"
|
||||
: false;
|
||||
const canStart =
|
||||
sourceConnectionId && targetConnectionId && confirmed && !isRunning;
|
||||
|
||||
const checkingMessage = isPg
|
||||
? "Checking for pg_dump / pg_restore..."
|
||||
: isMysql
|
||||
? "Checking for mysqldump / mysql..."
|
||||
: null;
|
||||
|
||||
const targetConnections = dbType
|
||||
? connections.filter((c) => c.db_type === dbType)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<ArrowLeftRight size={14} className="text-accent" />
|
||||
<span className="text-xs font-medium text-text">DB Sync</span>
|
||||
<span className="text-[11px] text-text-muted">
|
||||
Transfer data between databases via pipe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg mx-auto space-y-6 outline outline-border">
|
||||
{/* Tool check */}
|
||||
{checkingTools && checkingMessage && (
|
||||
<div className="glass p-4 text-center">
|
||||
<p className="text-sm text-text-muted">
|
||||
{checkingMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsMissing && !toolsBundled && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-4 py-4 space-y-2">
|
||||
<p className="text-amber-300 text-sm font-semibold">
|
||||
{isPg
|
||||
? "PostgreSQL tools not found"
|
||||
: "MySQL tools not found"}
|
||||
</p>
|
||||
<p className="text-amber-200/80 text-xs leading-relaxed">
|
||||
Both {isPg ? "pg_dump and pg_restore" : "mysqldump and mysql"} are required for
|
||||
database sync.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs text-amber-200/70 space-y-0.5">
|
||||
{isPg && !pgToolStatus?.pg_dump_found && (
|
||||
<li>pg_dump is missing.</li>
|
||||
)}
|
||||
{isPg && !pgToolStatus?.pg_restore_found && (
|
||||
<li>pg_restore is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqldumpFound && (
|
||||
<li>mysqldump is missing.</li>
|
||||
)}
|
||||
{isMysql && !mysqlToolStatus?.mysqlFound && (
|
||||
<li>mysql is missing.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!checkingTools && !toolsMissing && (
|
||||
<>
|
||||
{/* Configuration card */}
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Source & Target connection pickers */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Source
|
||||
</label>
|
||||
<select
|
||||
value={sourceConnectionId}
|
||||
onChange={(e) => {
|
||||
setSourceConnectionId(
|
||||
e.target.value,
|
||||
);
|
||||
setTargetConnectionId("");
|
||||
}}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">
|
||||
Select source...
|
||||
</option>
|
||||
{connections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium flex items-center gap-1">
|
||||
<Database size={11} />
|
||||
Target
|
||||
</label>
|
||||
<select
|
||||
value={targetConnectionId}
|
||||
onChange={(e) =>
|
||||
setTargetConnectionId(
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "Select target..."
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{targetConnections.map((c) => (
|
||||
<option
|
||||
key={c.id}
|
||||
value={c.id}
|
||||
disabled={
|
||||
c.id === sourceConnectionId
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schema (optional) */}
|
||||
{(isPg || isMysql) && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[11px] uppercase tracking-wider text-text-muted font-medium">
|
||||
Schema{" "}
|
||||
<span className="font-normal normal-case tracking-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
value={schema}
|
||||
onChange={(e) =>
|
||||
setSchema(e.target.value)
|
||||
}
|
||||
disabled={!sourceConnectionId}
|
||||
className="w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm text-text focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">
|
||||
{sourceConnectionId
|
||||
? "All schemas"
|
||||
: "Select a source first"}
|
||||
</option>
|
||||
{availableSchemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow indicator */}
|
||||
{sourceConnectionId && targetConnectionId && (
|
||||
<div className="flex items-center gap-3 text-[11px] text-text-muted">
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === sourceConnectionId,
|
||||
)?.name ?? sourceConnectionId}
|
||||
</span>
|
||||
<ArrowLeftRight
|
||||
size={12}
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
<span className="font-medium text-text">
|
||||
{connections.find(
|
||||
(c) =>
|
||||
c.id === targetConnectionId,
|
||||
)?.name ?? targetConnectionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
<div className="bg-red-500/5 border border-red-500/20 px-4 py-3">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) =>
|
||||
setConfirmed(e.target.checked)
|
||||
}
|
||||
className="mt-0.5 rounded bg-surface border-border accent-red-500 w-4 h-4 cursor-pointer"
|
||||
data-testid="sync-confirm-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-red-300/90 leading-relaxed">
|
||||
I understand this will overwrite data on
|
||||
the target database. This action cannot
|
||||
be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{activeJob && (
|
||||
<div className="px-4">
|
||||
<BackupProgress
|
||||
progress={activeJob.status === "completed" ? 100 : 50}
|
||||
jobType="sync"
|
||||
status={activeJob.status}
|
||||
errorMessage={activeJob.error_message ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end pb-2 pr-2">
|
||||
<Button
|
||||
onClick={handleStartSync}
|
||||
disabled={!canStart}
|
||||
>
|
||||
<ArrowLeftRight
|
||||
size={14}
|
||||
className="mr-1.5"
|
||||
/>
|
||||
{isRunning ? "Syncing..." : "Start Sync"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
describe("TabBar", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders the fixed Query and Changes actions when no tabs are open", () => {
|
||||
render(<TabBar />);
|
||||
expect(screen.getByRole("button", { name: /new query/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Changes queue" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders open tab names", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("posts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets active tab when clicked", async () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
store.openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
await user.click(screen.getByText("users"));
|
||||
expect(useDbViewerStore.getState().activeTabId).toBe(firstTabId);
|
||||
});
|
||||
|
||||
it("opens a new query tab when Query is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TabBar />);
|
||||
await user.click(screen.getByRole("button", { name: /new query/i }));
|
||||
const state = useDbViewerStore.getState();
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
expect(state.tabs[0].tabType).toBe("query");
|
||||
expect(state.activeTabId).toBe(state.tabs[0].id);
|
||||
});
|
||||
|
||||
it("shows the pending change count and toggles the changes panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
});
|
||||
useDbViewerStore.setState({ changesPanelExpanded: false });
|
||||
|
||||
render(<TabBar />);
|
||||
const changesButton = screen.getByRole("button", { name: "Changes queue" });
|
||||
|
||||
await user.click(changesButton);
|
||||
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true);
|
||||
|
||||
await user.click(changesButton);
|
||||
expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false);
|
||||
});
|
||||
|
||||
it("renders a table icon on table tabs", () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-table")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-query")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a query icon on query tabs", () => {
|
||||
useDbViewerStore.getState().openQueryTab();
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-query")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the per-type icon on an object tab", () => {
|
||||
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
|
||||
render(<TabBar />);
|
||||
const icon = screen.getByLabelText(/object icon: functions/i);
|
||||
const svg = icon.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// Regression: the icon must use the SAME handling as the query/table icons —
|
||||
// the svg itself is display:inline with the shared optical-centering classes.
|
||||
// That defeats preflight svg{display:block} (no stacking) and lets
|
||||
// vertical-align:middle center it with the tab name.
|
||||
const cls = svg!.getAttribute("class") ?? "";
|
||||
expect(cls).toContain("inline");
|
||||
expect(cls).toContain("-mt-0.5");
|
||||
expect(screen.getByText("add")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a view icon on view tabs", () => {
|
||||
useDbViewerStore.getState().openTab("main", "order_summary");
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{ name: "order_summary", schema: "main", table_type: "VIEW" },
|
||||
],
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-view")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a create icon and title on an objectForm create tab", () => {
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "sequence",
|
||||
schema: "public",
|
||||
name: "",
|
||||
title: "Create sequence",
|
||||
description: "Create sequence",
|
||||
mode: "create",
|
||||
params: { schema: "public", name: "", action: { op: "create" } },
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create sequence")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an edit icon on an objectForm edit tab", () => {
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "sequence",
|
||||
schema: "public",
|
||||
name: "s",
|
||||
title: "Edit sequence",
|
||||
description: "Edit sequence",
|
||||
mode: "edit",
|
||||
params: { schema: "public", name: "s", action: { op: "alter" } },
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a layers icon on materialized view tabs", () => {
|
||||
useDbViewerStore.getState().openTab("public", "mv_products");
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{
|
||||
name: "mv_products",
|
||||
schema: "public",
|
||||
table_type: "MATERIALIZED VIEW" as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TabBar />);
|
||||
expect(screen.getByTestId("tab-icon-matview")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the changes count as an icon with a badge", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
});
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "posts",
|
||||
newData: { title: "hi" },
|
||||
});
|
||||
|
||||
render(<TabBar />);
|
||||
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||
expect(button.querySelector("svg")).not.toBeNull();
|
||||
expect(within(button).getByText("2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Changes")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the count badge when there are no pending changes", () => {
|
||||
render(<TabBar />);
|
||||
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||
expect(within(button).queryByText(/\d/)).toBeNull();
|
||||
});
|
||||
|
||||
it("closes tab when close button clicked", async () => {
|
||||
useDbViewerStore.getState().openTab("public", "users");
|
||||
useDbViewerStore.getState().openTab("public", "posts", true);
|
||||
const firstTabId = useDbViewerStore.getState().tabs[0].id;
|
||||
|
||||
render(<TabBar />);
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: /close users/i,
|
||||
});
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(1);
|
||||
expect(
|
||||
useDbViewerStore.getState().tabs.find((t) => t.id === firstTabId),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens the changes popover when the button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
});
|
||||
useDbViewerStore.setState({ changesPanelExpanded: false });
|
||||
render(<TabBar />);
|
||||
expect(screen.queryByText(/pending changes/i)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Changes queue" }));
|
||||
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /commit all/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the changes popover on Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
newData: { id: 1 },
|
||||
description: "Insert row into users",
|
||||
});
|
||||
useDbViewerStore.setState({ changesPanelExpanded: true });
|
||||
render(<TabBar />);
|
||||
expect(screen.getByText(/pending changes/i)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByText(/pending changes/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("turns the button border amber when there are pending changes", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
newData: { id: 1 },
|
||||
description: "Insert row into users",
|
||||
});
|
||||
render(<TabBar />);
|
||||
const button = screen.getByRole("button", { name: "Changes queue" });
|
||||
expect(button.className).toContain("border-amber-500");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Drag & drop reorder — keep this test LAST in this file.
|
||||
//
|
||||
// The vitest config does not enable `globals: true`, so RTL's auto-cleanup
|
||||
// never unmounts components between tests. A dnd-kit drag leaves its DndContext
|
||||
// (and document-level listeners) mounted, which silently breaks userEvent/fireEvent
|
||||
// clicks in any LATER test. The drag itself is fully verified here; placing it
|
||||
// last isolates the pollution.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("reorders tabs via drag and drop (horizontal axis only)", async () => {
|
||||
const { act, fireEvent } = await import("@testing-library/react");
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
store.openTab("public", "posts", true);
|
||||
store.openTab("public", "comments", true);
|
||||
|
||||
// jsdom reports zero-sized rects and non-primary pointers by default,
|
||||
// which breaks dnd-kit collision detection + pointer activation.
|
||||
const original = Element.prototype.getBoundingClientRect;
|
||||
Element.prototype.getBoundingClientRect = function () {
|
||||
const text = this.textContent ?? "";
|
||||
const index = text.includes("posts")
|
||||
? 1
|
||||
: text.includes("comments")
|
||||
? 2
|
||||
: 0;
|
||||
const x = index * 100;
|
||||
return {
|
||||
x,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 30,
|
||||
left: x,
|
||||
right: x + 100,
|
||||
top: 0,
|
||||
bottom: 30,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
};
|
||||
|
||||
try {
|
||||
render(<TabBar />);
|
||||
const usersTab = screen.getByRole("tab", { name: "users" });
|
||||
|
||||
// pointerDown lifts the tab (distance constraint >= 4px on move), then
|
||||
// moves it over the last tab and drops.
|
||||
await act(async () => {
|
||||
fireEvent.pointerDown(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 50,
|
||||
clientY: 15,
|
||||
button: 0,
|
||||
isPrimary: true,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerMove(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 160,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerMove(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 260,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.pointerUp(usersTab, {
|
||||
pointerId: 1,
|
||||
clientX: 260,
|
||||
clientY: 15,
|
||||
});
|
||||
});
|
||||
// Flush dnd-kit's post-drag rAF focus-restore so it cannot leak into
|
||||
// later tests (userEvent clicks are order-sensitive in jsdom).
|
||||
await act(async () => {});
|
||||
} finally {
|
||||
Element.prototype.getBoundingClientRect = original;
|
||||
}
|
||||
|
||||
expect(
|
||||
useDbViewerStore.getState().tabs.map((t) => t.table),
|
||||
).toEqual(["posts", "comments", "users"]);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
|
||||
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
import { OBJECT_ICONS } from "./objects/ObjectDetail";
|
||||
|
||||
function SortableTab({
|
||||
tab,
|
||||
isActive,
|
||||
icon,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
tab: ViewerTab;
|
||||
isActive: boolean;
|
||||
icon: ReactNode;
|
||||
onSelect: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform: rawTransform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
// dnd-kit scales the dragged item to the width of whichever tab it is
|
||||
// hovering over (adjustScale). Tabs have different widths, which would warp
|
||||
// the text — always render at scale 1 and let the horizontal strategy handle
|
||||
// positioning.
|
||||
const transform = rawTransform
|
||||
? { ...rawTransform, scaleX: 1, scaleY: 1 }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
{...attributes}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-label={tab.table}
|
||||
{...listeners}
|
||||
onClick={onSelect}
|
||||
className={[
|
||||
"group flex shrink-0 items-center gap-2 border-r border-border px-3 text-sm transition-colors cursor-grab active:cursor-grabbing select-none",
|
||||
isActive ? "bg-canvas text-text" : "text-text-muted hover:text-text",
|
||||
isDragging ? "opacity-50 z-10 ring-1 ring-accent" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="flex-1 text-left select-none">{icon}{tab.table}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
aria-label={`Close ${tab.table}`}
|
||||
className="rounded p-0.5 opacity-60 transition-opacity hover:bg-surface-raised hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const tables = useDbViewerStore((state) => state.tables);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
const setActiveTab = useDbViewerStore((state) => state.setActiveTab);
|
||||
const openQueryTab = useDbViewerStore((state) => state.openQueryTab);
|
||||
const reorderTab = useDbViewerStore((state) => state.reorderTab);
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const changesPanelExpanded = useDbViewerStore(
|
||||
(state) => state.changesPanelExpanded,
|
||||
);
|
||||
const toggleChangesPanel = useDbViewerStore(
|
||||
(state) => state.toggleChangesPanel,
|
||||
);
|
||||
|
||||
// Drag threshold so a click still selects the tab; a deliberate drag (>= 4px)
|
||||
// starts a reorder. Keyboard sorting uses arrow keys, one axis only.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
// Keep the dragged tab on the tab strip: zero out any vertical movement so
|
||||
// dragging is constrained to the horizontal axis only.
|
||||
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
if (from >= 0 && to >= 0) reorderTab(from, to);
|
||||
};
|
||||
|
||||
const pendingCount = changesQueue.filter(
|
||||
(c) => c.status === "pending",
|
||||
).length;
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!changesPanelExpanded) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
toggleChangesPanel();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
toggleChangesPanel();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [changesPanelExpanded, toggleChangesPanel]);
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-stretch border-b border-border">
|
||||
{/* Left: open tabs (scrollable) */}
|
||||
<div
|
||||
className="flex flex-1 min-w-0 items-stretch overflow-x-auto"
|
||||
role="tablist"
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToHorizontalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={tabs.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const objectType =
|
||||
tab.tabType === "table"
|
||||
? tables.find(
|
||||
(t) =>
|
||||
t.schema === tab.schema && t.name === tab.table,
|
||||
)?.table_type
|
||||
: undefined;
|
||||
const icon =
|
||||
tab.tabType === "query" ? (
|
||||
<Terminal
|
||||
data-testid="tab-icon-query"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : tab.tabType === "object" ? (
|
||||
<span
|
||||
aria-label={`object icon: ${tab.objectType}`}
|
||||
className="contents"
|
||||
>
|
||||
{cloneElement(
|
||||
OBJECT_ICONS[tab.objectType!] as ReactElement<{
|
||||
className?: string;
|
||||
}>,
|
||||
{
|
||||
// Same handling as the query/table icons: the svg
|
||||
// itself is display:inline (preflight vertical-align:
|
||||
// middle centers it with the text) with the same
|
||||
// optical-centering nudge. `display: contents` on the
|
||||
// labelled span renders no box, so the geometry is
|
||||
// identical to the bare Terminal/Table2 icons.
|
||||
className:
|
||||
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
) : tab.tabType === "objectForm" ? (
|
||||
tab.form?.mode === "create" ? (
|
||||
<Plus
|
||||
data-testid="tab-icon-form-create"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : (
|
||||
<Pencil
|
||||
data-testid="tab-icon-form-edit"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
)
|
||||
) : objectType === "VIEW" ? (
|
||||
<Eye
|
||||
data-testid="tab-icon-view"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : objectType === "MATERIALIZED VIEW" ? (
|
||||
<Layers
|
||||
data-testid="tab-icon-matview"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
) : (
|
||||
<Table2
|
||||
data-testid="tab-icon-table"
|
||||
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={isActive}
|
||||
icon={icon}
|
||||
onSelect={() => setActiveTab(tab.id)}
|
||||
onClose={() => closeTab(tab.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Right: fixed actions */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 border-l border-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openQueryTab}
|
||||
aria-label="New query tab"
|
||||
className="flex items-center gap-1.5 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover cursor-pointer"
|
||||
>
|
||||
<Play className="h-3 w-3 fill-current" />
|
||||
Query
|
||||
</button>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (changesQueue.length > 0) toggleChangesPanel();
|
||||
}}
|
||||
aria-label="Changes queue"
|
||||
className={[
|
||||
"flex items-center gap-1.5 rounded-md border bg-surface px-2.5 py-1 text-xs font-medium transition-colors cursor-pointer",
|
||||
pendingCount > 0
|
||||
? "text-amber-400 border-amber-500 bg-amber-500/10 hover:bg-amber-500/20"
|
||||
: "border-border text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[16px] h-4 rounded-full bg-amber-500 px-1 text-[10px] font-bold text-white">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{changesPanelExpanded && (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
|
||||
<ChangesQueuePanel onCommitted={onCommitted} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { TableControls, formatDuration } from "./TableControls";
|
||||
import { TooltipProvider } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as exportData from "../../lib/exportData";
|
||||
import type { ViewerTab } from "../../stores/dbViewerStore";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
];
|
||||
|
||||
function makeTab(overrides: Partial<ViewerTab> = {}): ViewerTab {
|
||||
return {
|
||||
id: "tab-1",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50 },
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function seed(tabs: ViewerTab[], activeTabId: string) {
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.setState({ tabs, activeTabId });
|
||||
}
|
||||
|
||||
function renderControls(
|
||||
props: Partial<ComponentProps<typeof TableControls>> = {},
|
||||
) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<TableControls
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="users"
|
||||
columns={columns}
|
||||
rows={[[1]]}
|
||||
hiddenColumns={new Set()}
|
||||
onToggleColumn={() => {}}
|
||||
onRefresh={() => {}}
|
||||
filterRules={[]}
|
||||
onFilterChange={() => {}}
|
||||
sortRules={[]}
|
||||
onSortChange={() => {}}
|
||||
selectedCount={0}
|
||||
selectedRows={[]}
|
||||
onClearSelection={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats milliseconds with two decimals", () => {
|
||||
expect(formatDuration(15)).toBe("15.00ms");
|
||||
});
|
||||
|
||||
it("formats seconds with one decimal once past a second", () => {
|
||||
expect(formatDuration(1500)).toBe("1.5s");
|
||||
expect(formatDuration(3200)).toBe("3.2s");
|
||||
});
|
||||
|
||||
it("formats minutes for long-running queries", () => {
|
||||
expect(formatDuration(90000)).toBe("1.5m");
|
||||
});
|
||||
|
||||
it("returns an empty string when there is no timing", () => {
|
||||
expect(formatDuration(null)).toBe("");
|
||||
expect(formatDuration(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableControls query variant", () => {
|
||||
it("shows Export, Re-run, and Columns on the left", () => {
|
||||
seed([makeTab({ tabType: "query" })], "tab-1");
|
||||
renderControls({ variant: "query" });
|
||||
expect(screen.getByLabelText(/export/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/re-run query/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides table-only controls and the queue", () => {
|
||||
seed([makeTab({ tabType: "query" })], "tab-1");
|
||||
renderControls({ variant: "query" });
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/auto-refresh/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/column filters/i)).toBeNull();
|
||||
expect(screen.queryByLabelText(/sort rules/i)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /action queue/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the execution time from the result with a clock", () => {
|
||||
seed(
|
||||
[
|
||||
makeTab({
|
||||
tabType: "query",
|
||||
data: {
|
||||
columns,
|
||||
rows: [],
|
||||
total_rows: 0,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
execution_time_ms: 15,
|
||||
},
|
||||
}),
|
||||
],
|
||||
"tab-1",
|
||||
);
|
||||
renderControls({ variant: "query" });
|
||||
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("15.00ms")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the row count and pagination", () => {
|
||||
seed(
|
||||
[
|
||||
makeTab({
|
||||
tabType: "query",
|
||||
data: {
|
||||
columns,
|
||||
rows: [[1]],
|
||||
total_rows: 42,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
execution_time_ms: 15,
|
||||
},
|
||||
}),
|
||||
],
|
||||
"tab-1",
|
||||
);
|
||||
renderControls({ variant: "query" });
|
||||
expect(screen.getByText(/of 42/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/next page/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("table variant has no queue button (moved to tab bar) and no execution time", () => {
|
||||
seed(
|
||||
[
|
||||
makeTab({
|
||||
data: {
|
||||
columns,
|
||||
rows: [[1]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
execution_time_ms: 15,
|
||||
},
|
||||
}),
|
||||
],
|
||||
"tab-1",
|
||||
);
|
||||
renderControls({});
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /action queue/i }),
|
||||
).toBeNull();
|
||||
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("15.00ms")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableControls", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls onRefresh when the refresh button is clicked", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
const onRefresh = vi.fn();
|
||||
renderControls({ onRefresh });
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/refresh table/i));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not spin the refresh icon or show the pulse when idle", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
const { container } = renderControls();
|
||||
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("spins the refresh icon and shows the pulse overlay while the tab is loading", () => {
|
||||
seed([makeTab({ loading: true })], "tab-1");
|
||||
const { container } = renderControls();
|
||||
|
||||
expect(container.querySelector(".animate-spin")).not.toBeNull();
|
||||
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the refreshing indicators when auto-refresh fires and clears them when done", () => {
|
||||
vi.useFakeTimers();
|
||||
seed([makeTab()], "tab-1");
|
||||
const onRefresh = vi.fn(() => {
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-1" ? { ...t, loading: true } : t,
|
||||
),
|
||||
}));
|
||||
});
|
||||
const { container } = renderControls({
|
||||
onRefresh,
|
||||
defaultRefreshRate: 5000,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(container.querySelector(".animate-spin")).not.toBeNull();
|
||||
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
|
||||
|
||||
// Once the fetch completes the indicators disappear
|
||||
act(() => {
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-1" ? { ...t, loading: false } : t,
|
||||
),
|
||||
}));
|
||||
});
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("waits for an in-flight refresh to complete before restarting the timer", () => {
|
||||
vi.useFakeTimers();
|
||||
seed([makeTab()], "tab-1");
|
||||
const onRefresh = vi.fn(() => {
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-1" ? { ...t, loading: true } : t,
|
||||
),
|
||||
}));
|
||||
});
|
||||
renderControls({ onRefresh, defaultRefreshRate: 5000 });
|
||||
|
||||
// First interval fires the refresh
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
// While the refresh is still in flight, the timer must NOT fire again
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the refresh completes, a fresh countdown starts
|
||||
act(() => {
|
||||
useDbViewerStore.setState((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === "tab-1" ? { ...t, loading: false } : t,
|
||||
),
|
||||
}));
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("defers auto-refresh when the user switches tabs (resets the timer)", () => {
|
||||
vi.useFakeTimers();
|
||||
const onRefresh = vi.fn();
|
||||
seed([makeTab(), makeTab({ id: "tab-2", table: "orders" })], "tab-1");
|
||||
renderControls({ onRefresh, defaultRefreshRate: 5000 });
|
||||
|
||||
// Not yet a full interval
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4000);
|
||||
});
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
|
||||
// User switches to another tab → countdown restarts
|
||||
act(() => {
|
||||
useDbViewerStore.setState({ activeTabId: "tab-2" });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4000);
|
||||
});
|
||||
expect(onRefresh).not.toHaveBeenCalled();
|
||||
|
||||
// Full interval after the switch finally fires
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the Insert Row button for a materialized view", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls({ isMatview: true });
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the FilterBuilder inside the filter popover", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(
|
||||
screen.getByText("Drop columns here to add filters"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("export dropdown includes the Excel option", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
expect(screen.getByText("JSON")).toBeInTheDocument();
|
||||
expect(screen.getByText("CSV")).toBeInTheDocument();
|
||||
expect(screen.getByText("SQL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Markdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("Excel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("notifies after a successful export", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/export/i));
|
||||
fireEvent.click(screen.getByText("Excel"));
|
||||
const st = useNotificationStore.getState();
|
||||
expect(
|
||||
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,846 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Plus, RefreshCw, Clock, Filter, ArrowUpDown, Download,
|
||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const AUTO_REFRESH_OPTIONS = [
|
||||
{ label: "Off", value: 0 },
|
||||
{ label: "5s", value: 5000 },
|
||||
{ label: "10s", value: 10_000 },
|
||||
{ label: "30s", value: 30_000 },
|
||||
{ label: "1m", value: 60_000 },
|
||||
{ label: "5m", value: 300_000 },
|
||||
] as const;
|
||||
|
||||
const PAGE_SIZES = [50, 100, 200, 500] as const;
|
||||
|
||||
const EXPORT_FORMATS = [
|
||||
{ label: "JSON", ext: "json" },
|
||||
{ label: "CSV", ext: "csv" },
|
||||
{ label: "SQL", ext: "sql" },
|
||||
{ label: "Markdown", ext: "md" },
|
||||
{ label: "Excel", ext: "xlsx" },
|
||||
] as const;
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format an execution duration using the most sensible unit:
|
||||
* ms below a second, seconds (1 decimal) up to a minute, minutes beyond.
|
||||
*/
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (ms == null) return "";
|
||||
if (ms < 1000) return `${ms.toFixed(2)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${(ms / 60_000).toFixed(1)}m`;
|
||||
}
|
||||
|
||||
// ─── sub-components ─────────────────────────────────────
|
||||
|
||||
function DropdownMenu({
|
||||
open,
|
||||
setOpen,
|
||||
align,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
align?: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`absolute top-full mt-1 z-30 min-w-48 rounded-lg bg-surface border border-border shadow-lg py-1 ${
|
||||
align === "right" ? "right-0" : "left-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", operator: "contains", value: "" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-96 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Column Filters</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.operator}
|
||||
onChange={(e) => updateRule(rule.id, { operator: e.target.value as FilterRule["operator"] })}
|
||||
className="w-24 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="eq">=</option>
|
||||
<option value="neq">≠</option>
|
||||
<option value="contains">contains</option>
|
||||
<option value="starts">starts with</option>
|
||||
<option value="ends">ends with</option>
|
||||
<option value="gt">></option>
|
||||
<option value="lt"><</option>
|
||||
<option value="null">is null</option>
|
||||
<option value="notnull">not null</option>
|
||||
</select>
|
||||
{rule.operator !== "null" && rule.operator !== "notnull" && (
|
||||
<input
|
||||
type="text"
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(rule.id, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0"
|
||||
/>
|
||||
)}
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add filter
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortModal({
|
||||
columns,
|
||||
rules,
|
||||
onChange,
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
rules: SortRule[];
|
||||
onChange: (rules: SortRule[]) => void;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const addRule = () => {
|
||||
onChange([
|
||||
...rules,
|
||||
{ id: crypto.randomUUID(), column: columns[0]?.name ?? "", order: "asc" },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRule = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const updateRule = (id: string, patch: Partial<SortRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute top-full left-0 mt-1 z-30 w-72 rounded-lg bg-surface border border-border shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-semibold text-text">Sort Rules</span>
|
||||
<button type="button" onClick={() => setOpen(false)} className="text-text-muted hover:text-text cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
value={rule.column}
|
||||
onChange={(e) => updateRule(rule.id, { column: e.target.value })}
|
||||
className="flex-1 rounded border border-border bg-surface text-xs px-1.5 py-1 text-text min-w-0 cursor-pointer"
|
||||
>
|
||||
{columns.map((c) => <option key={c.name} value={c.name}>{c.name}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={rule.order}
|
||||
onChange={(e) => updateRule(rule.id, { order: e.target.value as "asc" | "desc" })}
|
||||
className="w-20 rounded border border-border bg-surface text-xs px-1 py-1 text-text cursor-pointer"
|
||||
>
|
||||
<option value="asc">ASC</option>
|
||||
<option value="desc">DESC</option>
|
||||
</select>
|
||||
<button type="button" onClick={() => removeRule(rule.id)} className="text-text-muted hover:text-red-400 shrink-0 cursor-pointer">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRule}
|
||||
className="text-xs text-accent hover:underline mt-1 cursor-pointer"
|
||||
>
|
||||
+ Add sort
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── bulk actions dropdown ──────────────────────────────
|
||||
|
||||
function BulkActionsDropdown({
|
||||
columns,
|
||||
selectedRows,
|
||||
schema,
|
||||
table,
|
||||
onClearSelection,
|
||||
}: {
|
||||
columns: ColumnInfo[];
|
||||
selectedRows: unknown[][];
|
||||
schema: string;
|
||||
table: string;
|
||||
onClearSelection: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyJSON = () => {
|
||||
const json = selectedRows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((c, i) => { obj[c.name] = row[i] ?? null; });
|
||||
return obj;
|
||||
});
|
||||
copyToClipboard(JSON.stringify(json, null, 2));
|
||||
};
|
||||
|
||||
const handleCopyCSV = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
||||
for (const row of selectedRows) {
|
||||
csvRows.push(
|
||||
row.map((cell) => {
|
||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}).join(","),
|
||||
);
|
||||
}
|
||||
copyToClipboard(csvRows.join("\n"));
|
||||
};
|
||||
|
||||
const handleCopySQL = () => {
|
||||
const headers = columns.map((c) => c.name);
|
||||
const lines: string[] = [];
|
||||
for (const row of selectedRows) {
|
||||
const vals = row.map((cell) =>
|
||||
cell === null ? "NULL"
|
||||
: typeof cell === "number" ? String(cell)
|
||||
: `'${String(cell).replace(/'/g, "''")}'`,
|
||||
);
|
||||
lines.push(`INSERT INTO ${schema}.${table} (${headers.join(", ")}) VALUES (${vals.join(", ")});`);
|
||||
}
|
||||
copyToClipboard(lines.join("\n"));
|
||||
};
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const pkCol = columns.find((c) => c.is_pk);
|
||||
for (const row of selectedRows) {
|
||||
const pk: Record<string, unknown> = {};
|
||||
if (pkCol) {
|
||||
const ci = columns.findIndex((c) => c.name === pkCol.name);
|
||||
if (ci >= 0) pk[pkCol.name] = row[ci] ?? null;
|
||||
}
|
||||
addChange({
|
||||
type: "delete",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: pk,
|
||||
oldData: Object.fromEntries(columns.map((c, i) => [c.name, row[i] ?? null])),
|
||||
description: `Delete row from ${table}`,
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
onClearSelection();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-accent hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-xs font-medium">Actions</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
<DropdownMenu open={open} setOpen={setOpen} align="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyJSON}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileJson size={13} className="text-text-muted" />
|
||||
Copy as JSON
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCSV}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText size={13} className="text-text-muted" />
|
||||
Copy as CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySQL}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Terminal size={13} className="text-text-muted" />
|
||||
Copy as SQL INSERT
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-red-400 hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Delete selected rows
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── main component ─────────────────────────────────────
|
||||
|
||||
interface TableControlsProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
rows: unknown[][];
|
||||
hiddenColumns: Set<string>;
|
||||
onToggleColumn: (col: string) => void;
|
||||
onRefresh: () => void;
|
||||
filterRules: FilterRule[];
|
||||
onFilterChange: (rules: FilterRule[]) => void;
|
||||
sortRules: SortRule[];
|
||||
onSortChange: (rules: SortRule[]) => void;
|
||||
selectedCount: number;
|
||||
selectedRows: unknown[][];
|
||||
onClearSelection: () => void;
|
||||
defaultRefreshRate?: number;
|
||||
/** Hide data-modifying affordances (e.g. for materialized views). */
|
||||
isMatview?: boolean;
|
||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||
variant?: "table" | "query";
|
||||
}
|
||||
|
||||
export function TableControls({
|
||||
connectionId: _connectionId,
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
hiddenColumns,
|
||||
onToggleColumn,
|
||||
onRefresh,
|
||||
filterRules,
|
||||
onFilterChange,
|
||||
sortRules,
|
||||
onSortChange,
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
onClearSelection,
|
||||
defaultRefreshRate = 0,
|
||||
isMatview = false,
|
||||
variant = "table",
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const tabs = useDbViewerStore((s) => s.tabs);
|
||||
const activeTabId = useDbViewerStore((s) => s.activeTabId);
|
||||
const setPage = useDbViewerStore((s) => s.setPage);
|
||||
const setPageSize = useDbViewerStore((s) => s.setPageSize);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
const isRefreshing = activeTab?.loading ?? false;
|
||||
|
||||
// local state
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [autoRefresh, setAutoRefresh] = useState(defaultRefreshRate);
|
||||
const [autoRefreshOpen, setAutoRefreshOpen] = useState(false);
|
||||
|
||||
// Auto-refresh: a self-restarting timer that only counts down while the tab
|
||||
// is idle. Fires a refresh, waits for it to complete (loading → false),
|
||||
// then starts a fresh countdown. Also resets whenever the active tab changes
|
||||
// so a freshly opened/reopened tab is not immediately refetched.
|
||||
useEffect(() => {
|
||||
if (autoRefresh === 0) return;
|
||||
// While a refresh is in flight, wait for it to finish before counting down
|
||||
if (isRefreshing) return;
|
||||
const id = setTimeout(onRefresh, autoRefresh);
|
||||
return () => clearTimeout(id);
|
||||
}, [autoRefresh, onRefresh, isRefreshing, activeTabId]);
|
||||
|
||||
// pagination
|
||||
const totalRows = activeTab?.data?.total_rows ?? rows.length;
|
||||
const pageSize = activeTab?.pageSize ?? 50;
|
||||
const currentPage = activeTab?.page ?? 1;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
||||
const clampedPage = Math.max(1, Math.min(currentPage, totalPages));
|
||||
const startRow = (clampedPage - 1) * pageSize + 1;
|
||||
const endRow = Math.min(clampedPage * pageSize, totalRows);
|
||||
|
||||
const handlePrev = () => {
|
||||
if (clampedPage > 1 && activeTabId) setPage(activeTabId, clampedPage - 1);
|
||||
};
|
||||
const handleNext = () => {
|
||||
if (clampedPage < totalPages && activeTabId) setPage(activeTabId, clampedPage + 1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (activeTabId) setPageSize(activeTabId, Number(e.target.value));
|
||||
};
|
||||
|
||||
const handleInsertRow = () => {
|
||||
const newData: Record<string, unknown> = {};
|
||||
columns.forEach((c) => { newData[c.name] = null; });
|
||||
addChange({
|
||||
type: "insert",
|
||||
schema,
|
||||
table,
|
||||
primaryKey: {},
|
||||
newData,
|
||||
description: `Insert row into ${table}`,
|
||||
});
|
||||
openTab(schema, table);
|
||||
};
|
||||
|
||||
const handleExport = (format: string) => {
|
||||
const label =
|
||||
EXPORT_FORMATS.find((f) => f.ext === format)?.label ?? format.toUpperCase();
|
||||
try {
|
||||
exportData(rows, columns, format, table);
|
||||
notify(
|
||||
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
|
||||
"success",
|
||||
);
|
||||
} catch (e) {
|
||||
notify(
|
||||
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
const executionTimeMs = activeTab?.data?.execution_time_ms ?? null;
|
||||
|
||||
const refreshControl = (
|
||||
<Tooltip
|
||||
content={
|
||||
isRefreshing
|
||||
? isQuery
|
||||
? "Running…"
|
||||
: "Refreshing…"
|
||||
: isQuery
|
||||
? "Re-trigger query"
|
||||
: "Refresh"
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label={isQuery ? "Re-run query" : "Refresh table"}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={isRefreshing ? "animate-spin text-accent" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const exportControl = (
|
||||
<div className="relative">
|
||||
<Tooltip content="Export" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Export"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={exportOpen} setOpen={setExportOpen}>
|
||||
{EXPORT_FORMATS.map((fmt) => (
|
||||
<button
|
||||
key={fmt.ext}
|
||||
type="button"
|
||||
onClick={() => handleExport(fmt.ext)}
|
||||
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
{fmt.label}
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columnsControl = (
|
||||
<div className="relative">
|
||||
<Tooltip content="Show/hide columns" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColumnMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Toggle columns"
|
||||
>
|
||||
<Columns size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu
|
||||
open={columnMenuOpen}
|
||||
setOpen={setColumnMenuOpen}
|
||||
align={isQuery ? "left" : "right"}
|
||||
>
|
||||
<div className="px-2 py-1 text-[10px] text-text-muted uppercase tracking-wider">
|
||||
Visible columns
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{columns.map((col) => (
|
||||
<button
|
||||
key={col.name}
|
||||
type="button"
|
||||
onClick={() => onToggleColumn(col.name)}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
|
||||
hiddenColumns.has(col.name)
|
||||
? "border-border bg-transparent"
|
||||
: "border-accent bg-accent"
|
||||
}`}
|
||||
>
|
||||
{!hiddenColumns.has(col.name) && (
|
||||
<Check size={10} className="text-white" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{col.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-text-muted">
|
||||
{/* refresh pulse: absolutely positioned so it never causes layout shifts */}
|
||||
{isRefreshing && (
|
||||
<div
|
||||
data-testid="refresh-pulse"
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 pointer-events-none animate-toolbar-pulse bg-accent"
|
||||
/>
|
||||
)}
|
||||
{/* ── left side ──────────────────────────────── */}
|
||||
<div className="flex items-center gap-1">
|
||||
{isQuery ? (
|
||||
<>
|
||||
{exportControl}
|
||||
{refreshControl}
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
{columnsControl}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isMatview && (
|
||||
<>
|
||||
{/* Insert Row */}
|
||||
<Tooltip content="Insert row" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsertRow}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Insert row"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{refreshControl}
|
||||
|
||||
{/* Auto-refresh */}
|
||||
<div className="relative">
|
||||
<Tooltip content={`Auto-refresh: ${autoRefresh > 0 ? `${autoRefresh / 1000}s` : "Off"}`} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoRefreshOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Auto-refresh"
|
||||
>
|
||||
<Clock size={14} />
|
||||
{autoRefresh > 0 && <span className="text-[10px] font-medium">{autoRefresh / 1000}s</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<DropdownMenu open={autoRefreshOpen} setOpen={setAutoRefreshOpen}>
|
||||
{AUTO_REFRESH_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { setAutoRefresh(opt.value); setAutoRefreshOpen(false); }}
|
||||
className={`flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
autoRefresh === opt.value ? "text-accent" : "text-text"
|
||||
}`}
|
||||
>
|
||||
{autoRefresh === opt.value && <Check size={12} />}
|
||||
<span className={autoRefresh === opt.value ? "" : "ml-5"}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Column filters" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
filterRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Column filters"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{filterRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{filterRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<FilterModal
|
||||
columns={columns}
|
||||
rules={filterRules}
|
||||
onChange={onFilterChange}
|
||||
open={filterOpen}
|
||||
setOpen={setFilterOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="relative">
|
||||
<Tooltip content="Sort rules" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortOpen((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised transition-colors cursor-pointer ${
|
||||
sortRules.length > 0 ? "text-accent" : "hover:text-text"
|
||||
}`}
|
||||
aria-label="Sort rules"
|
||||
>
|
||||
<ArrowUpDown size={14} />
|
||||
{sortRules.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-accent text-white text-[10px] font-bold">
|
||||
{sortRules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<SortModal
|
||||
columns={columns}
|
||||
rules={sortRules}
|
||||
onChange={onSortChange}
|
||||
open={sortOpen}
|
||||
setOpen={setSortOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exportControl}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── spacer ──────────────────────────────────── */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* ── right side ─────────────────────────────── */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isQuery && executionTimeMs != null && (
|
||||
<>
|
||||
<span
|
||||
className="flex items-center gap-1.5 tabular-nums"
|
||||
aria-label="Execution time"
|
||||
>
|
||||
<Clock size={12} className="text-text-muted" />
|
||||
{formatDuration(executionTimeMs)}
|
||||
</span>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</>
|
||||
)}
|
||||
{/* Selected count + bulk actions */}
|
||||
{selectedCount > 0 && (
|
||||
<>
|
||||
<span className="text-accent font-medium tabular-nums">
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<BulkActionsDropdown
|
||||
columns={columns}
|
||||
selectedRows={selectedRows}
|
||||
schema={schema}
|
||||
table={table}
|
||||
onClearSelection={onClearSelection}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
className="text-text-muted hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Clear selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isQuery && columnsControl}
|
||||
|
||||
<div className="w-px h-4 bg-border" />
|
||||
|
||||
{/* Row count */}
|
||||
<span className="tabular-nums">
|
||||
{startRow}-{endRow} of {totalRows}
|
||||
</span>
|
||||
|
||||
{/* Page size */}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={handlePageSizeChange}
|
||||
className="rounded border border-border bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-accent"
|
||||
>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrev}
|
||||
disabled={clampedPage <= 1}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="tabular-nums min-w-[3rem] text-center">
|
||||
{clampedPage}/{totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={clampedPage >= totalPages}
|
||||
className="rounded p-0.5 hover:bg-surface-raised disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
import * as exportData from "../../lib/exportData";
|
||||
|
||||
describe("TableOverflowMenu", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: vi.fn() },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
useDbViewerStore.getState().reset();
|
||||
useUiStore.setState({ activeConnectionId: "c1" });
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("offers Create Index… and Create Constraint…", () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
expect(screen.getByText("Create Index…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText("Create Index…"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||
expect(st.tabs[0].form?.kind).toBe("index");
|
||||
expect(st.tabs[0].form?.mode).toBe("create");
|
||||
|
||||
useDbViewerStore.getState().reset();
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
|
||||
fireEvent.click(screen.getByText("Create Constraint…"));
|
||||
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
|
||||
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
|
||||
});
|
||||
|
||||
it("renders menu trigger button", () => {
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows menu options on click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
expect(screen.getByText("Open in new tab")).toBeInTheDocument();
|
||||
expect(screen.getByText("Copy table schema")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (CSV)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Export data (Excel)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onOpenTab when menu item clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenTab = vi.fn().mockReturnValue("tab-1");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={onOpenTab} />);
|
||||
await user.click(screen.getByLabelText(/table options/i));
|
||||
await user.click(screen.getByText("Open in new tab"));
|
||||
expect(onOpenTab).toHaveBeenCalledWith("public", "users", true);
|
||||
});
|
||||
|
||||
it("Copy table schema calls getTableDdl and writes clipboard", async () => {
|
||||
vi.spyOn(commands, "getTableDdl").mockResolvedValue("CREATE TABLE t (id int)");
|
||||
const writeText = (navigator.clipboard as any).writeText as ReturnType<typeof vi.fn>;
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/copy table schema/i));
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith("CREATE TABLE t (id int)"));
|
||||
});
|
||||
|
||||
it("Empty Table opens confirm then stages an empty_table change", async () => {
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/empty table/i));
|
||||
fireEvent.click(screen.getByRole("button", { name: /empty table/i }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "empty_table", schema: "public", table: "t" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("Delete Table opens confirm then stages a drop_table change", async () => {
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([]);
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/delete table/i));
|
||||
await waitFor(() => expect(screen.queryByText(/open in new tab/i)).not.toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete table/i }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q[q.length - 1]).toEqual(expect.objectContaining({ type: "drop_table", schema: "public", table: "t" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("Delete Table fetches dependencies and shows DependencyDialog before confirming", async () => {
|
||||
vi.spyOn(commands, "getObjectDependencies").mockResolvedValue([{ deptype: "n", class: "pg_class", name: "v_orders" }]);
|
||||
render(<TableOverflowMenu schema="public" table="orders" connectionId="c1" onOpenTab={() => "tab-1"} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/delete table/i));
|
||||
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalledWith("c1", "public", "table", "orders"));
|
||||
await waitFor(() => expect(screen.getByText("v_orders")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData");
|
||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
|
||||
});
|
||||
|
||||
it("Excel export calls exportData and notifies", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
const { useNotificationStore } = await import("../../stores/notificationStore");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(excel\)/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "xlsx", "public.t"));
|
||||
const st = useNotificationStore.getState();
|
||||
expect(st.notifications.some((n) => n.message.toLowerCase().includes("exported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("tree kebab export fetches table data when rows are absent", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData").mockImplementation(() => {});
|
||||
const getSpy = vi.spyOn(commands, "getTableData").mockResolvedValue({
|
||||
columns: [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }],
|
||||
rows: [[42]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
});
|
||||
const { useNotificationStore } = await import("../../stores/notificationStore");
|
||||
useNotificationStore.getState().notifications.length = 0;
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(excel\)/i));
|
||||
await waitFor(() =>
|
||||
expect(getSpy).toHaveBeenCalledWith("c1", "public", "t", 1, 1000),
|
||||
);
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
const st = useNotificationStore.getState();
|
||||
expect(st.notifications.some((n) => n.message.includes("Exported"))).toBe(true);
|
||||
});
|
||||
|
||||
it("maintenance items are gated by capability and run via confirm", async () => {
|
||||
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("VACUUM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /vacuum/i }));
|
||||
await screen.findByText(/completed/i);
|
||||
expect(commands.runMaintenance).toHaveBeenCalledWith("c1", "public", "users", "vacuum");
|
||||
});
|
||||
|
||||
it("Edit Table opens a table form tab", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" columns={[{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]} />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("Edit Table…"));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "edit" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical, RefreshCw } from "lucide-react";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import { initialCrudParams } from "../../lib/objectCrud";
|
||||
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
table: string;
|
||||
onOpenTab: (schema: string, table: string, forceNew?: boolean) => string;
|
||||
connectionId?: string;
|
||||
columns?: ColumnInfo[];
|
||||
rows?: unknown[][];
|
||||
dbType?: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label?: string;
|
||||
danger?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
type MaintenanceAction = "vacuum" | "analyze" | "reindex";
|
||||
|
||||
const maintenanceLockCopy: Record<MaintenanceAction, string> = {
|
||||
vacuum: "VACUUM blocks concurrent DDL only on this table.",
|
||||
analyze: "ANALYZE blocks concurrent DDL only on this table.",
|
||||
reindex: "REINDEX takes an ACCESS EXCLUSIVE lock — blocks reads and writes on this table until complete.",
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function TableOverflowMenu({
|
||||
schema,
|
||||
table,
|
||||
onOpenTab,
|
||||
connectionId: connectionIdProp,
|
||||
columns,
|
||||
rows,
|
||||
dbType,
|
||||
}: TableOverflowMenuProps) {
|
||||
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const connectionId = connectionIdProp ?? storeConnectionId;
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const caps = getCapabilities(dbType ?? "postgresql");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState<{ action: MaintenanceAction; lockCopy: string } | null>(null);
|
||||
const [maintenanceRunning, setMaintenanceRunning] = useState(false);
|
||||
const [maintenanceResult, setMaintenanceResult] = useState<
|
||||
{ type: "success" | "error"; message: string; duration_ms: number } | null
|
||||
>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleMaintenanceConfirm = async () => {
|
||||
if (!connectionId || !maintenance) return;
|
||||
setMaintenanceRunning(true);
|
||||
setMaintenanceResult(null);
|
||||
try {
|
||||
const result: MaintenanceResult = await cmd.runMaintenance(
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
maintenance.action,
|
||||
);
|
||||
setMaintenanceResult({ type: "success", message: result.message, duration_ms: result.duration_ms });
|
||||
notify(`${result.message} · ${result.duration_ms}ms`, "success");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setMaintenanceResult({ type: "error", message, duration_ms: 0 });
|
||||
notify(message, "error");
|
||||
} finally {
|
||||
setMaintenanceRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string) => {
|
||||
switch (id) {
|
||||
case "open":
|
||||
onOpenTab(schema, table, true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "copy-schema": {
|
||||
if (!connectionId) break;
|
||||
try {
|
||||
const ddl = await cmd.getTableDdl(connectionId, schema, table);
|
||||
if (navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(ddl);
|
||||
}
|
||||
} catch {
|
||||
/* ignore copy failures */
|
||||
}
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "export-csv":
|
||||
case "export-json":
|
||||
case "export-sql":
|
||||
case "export-md":
|
||||
case "export-xlsx": {
|
||||
const format = id.replace("export-", "");
|
||||
const label =
|
||||
format === "xlsx"
|
||||
? "Excel"
|
||||
: format === "md"
|
||||
? "Markdown"
|
||||
: format.toUpperCase();
|
||||
try {
|
||||
if (rows && rows.length > 0 && columns && columns.length > 0) {
|
||||
exportData(rows, columns, format, `${schema}.${table}`);
|
||||
notify(
|
||||
`Exported ${rows.length} row${rows.length === 1 ? "" : "s"} as ${label}`,
|
||||
"success",
|
||||
);
|
||||
} else if (connectionId) {
|
||||
// Tree kebab: no rows are loaded here — fetch the table data
|
||||
// first, then export (capped at 1000 rows per fetch).
|
||||
const result = await cmd.getTableData(connectionId, schema, table, 1, 1000);
|
||||
if (!result.rows.length) {
|
||||
notify("Nothing to export", "info");
|
||||
} else {
|
||||
exportData(result.rows, result.columns, format, `${schema}.${table}`);
|
||||
const truncated =
|
||||
result.total_rows > result.rows.length
|
||||
? ` (first ${result.rows.length} of ${result.total_rows})`
|
||||
: "";
|
||||
notify(
|
||||
`Exported ${result.rows.length} row${result.rows.length === 1 ? "" : "s"} as ${label}${truncated}`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notify("Nothing to export", "info");
|
||||
}
|
||||
} catch (e) {
|
||||
notify(
|
||||
`Export failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "import":
|
||||
setImportOpen(true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "edit_table": {
|
||||
const columnMeta = (columns ?? []).map((c) => ({
|
||||
name: c.name,
|
||||
type: c.data_type,
|
||||
nullable: c.is_nullable,
|
||||
default: c.default_value,
|
||||
is_pk: c.is_pk,
|
||||
}));
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: table,
|
||||
title: "Edit Table",
|
||||
description: `Edit ${schema}.${table}`,
|
||||
mode: "edit",
|
||||
params: {
|
||||
schema,
|
||||
name: table,
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: columnMeta,
|
||||
old_columns: columnMeta,
|
||||
},
|
||||
},
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "create_index":
|
||||
if (!connectionId) break;
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "index",
|
||||
schema,
|
||||
name: "",
|
||||
title: "Create Index",
|
||||
description: `Create index on ${schema}.${table}`,
|
||||
mode: "create",
|
||||
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
case "create_constraint":
|
||||
if (!connectionId) break;
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "constraint",
|
||||
schema,
|
||||
name: "",
|
||||
title: "Create Constraint",
|
||||
description: `Create constraint on ${schema}.${table}`,
|
||||
mode: "create",
|
||||
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
case "vacuum":
|
||||
case "analyze":
|
||||
case "reindex":
|
||||
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
|
||||
setOpen(false);
|
||||
break;
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
break;
|
||||
case "delete": {
|
||||
if (!connectionId) break;
|
||||
try {
|
||||
const deps = await cmd.getObjectDependencies(connectionId, schema, "table", table);
|
||||
setDropDeps(deps);
|
||||
} catch {
|
||||
setDropDeps([]);
|
||||
}
|
||||
setConfirmAction("delete");
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{ id: "open", label: "Open in new tab" },
|
||||
{ id: "copy-schema", label: "Copy table schema" },
|
||||
{ id: "export-csv", label: "Export data (CSV)" },
|
||||
{ id: "export-json", label: "Export data (JSON)" },
|
||||
{ id: "export-sql", label: "Export data (SQL)" },
|
||||
{ id: "export-md", label: "Export data (Markdown)" },
|
||||
{ id: "export-xlsx", label: "Export data (Excel)" },
|
||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||
{ id: "create_index", label: "Create Index…" },
|
||||
{ id: "create_constraint", label: "Create Constraint…" },
|
||||
...(caps.tableManagement ? [{ id: "edit_table", label: "Edit Table…" }] : []),
|
||||
...(caps.maintenance
|
||||
? [
|
||||
{ id: "maintenance-divider", divider: true },
|
||||
{ id: "vacuum", label: "VACUUM" },
|
||||
{ id: "analyze", label: "ANALYZE" },
|
||||
{ id: "reindex", label: "REINDEX", danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
aria-label="Table options"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreVertical size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||
{items.map((item) =>
|
||||
item.divider ? (
|
||||
<div key={item.id} className="border-t border-border my-1" />
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maintenance && (
|
||||
<AnimatedModal open onClose={() => setMaintenance(null)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">
|
||||
{maintenanceResult
|
||||
? maintenanceResult.type === "success"
|
||||
? "Maintenance Complete"
|
||||
: "Maintenance Failed"
|
||||
: `${capitalize(maintenance.action)}: ${schema}.${table}`}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
{maintenanceResult
|
||||
? `${maintenanceResult.message} · ${maintenanceResult.duration_ms}ms`
|
||||
: maintenance.lockCopy}
|
||||
</p>
|
||||
{maintenanceRunning && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted mb-4">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
<span>Running {maintenance.action}…</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setMaintenance(null)}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceResult ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!maintenanceResult && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleMaintenanceConfirm}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceRunning ? "Running…" : capitalize(maintenance.action)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
|
||||
{confirmAction === "delete" && dropDeps.length > 0 && (
|
||||
<DependencyDialog
|
||||
open
|
||||
deps={dropDeps}
|
||||
onProceed={() => setConfirmAction(null)}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmAction === "empty" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Empty Table: ${table}`}
|
||||
message={`Are you sure you want to delete ALL rows from "${schema}"."${table}"? This action cannot be undone.`}
|
||||
confirmLabel="Empty Table"
|
||||
onConfirm={() => {
|
||||
addChange({
|
||||
type: "empty_table",
|
||||
schema,
|
||||
table,
|
||||
description: `Empty Table: ${schema}.${table}`,
|
||||
});
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmAction === "delete" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title={`Delete Table: ${table}`}
|
||||
message={`Are you sure you want to permanently delete "${schema}"."${table}"? All data will be lost.`}
|
||||
confirmLabel="Delete Table"
|
||||
onConfirm={() => {
|
||||
addChange({
|
||||
type: "drop_table",
|
||||
schema,
|
||||
table,
|
||||
description: `Drop Table: ${schema}.${table}`,
|
||||
});
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ImportDialog
|
||||
open={importOpen}
|
||||
schema={schema}
|
||||
table={table}
|
||||
columns={columns?.map((c) => c.name) ?? []}
|
||||
onStage={(change) => {
|
||||
addChange({
|
||||
type: "bulk_insert",
|
||||
schema: change.schema,
|
||||
table: change.table,
|
||||
columns: change.columns,
|
||||
rows: change.rows,
|
||||
description: change.description,
|
||||
});
|
||||
setImportOpen(false);
|
||||
}}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TableTree } from "./TableTree";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
|
||||
describe("TableTree", () => {
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("renders table names from store", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{ name: "users", schema: "public", table_type: "TABLE" },
|
||||
{ name: "orders", schema: "public", table_type: "TABLE" },
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("users")).toBeInTheDocument();
|
||||
expect(screen.getByText("orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct icon and label for materialized views", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{
|
||||
name: "mv_products",
|
||||
schema: "public",
|
||||
table_type: "MATERIALIZED VIEW" as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("mv_products")).toBeInTheDocument();
|
||||
expect(screen.getByText("Materialized View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct icon and label for views", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{
|
||||
name: "order_summary",
|
||||
schema: "public",
|
||||
table_type: "VIEW",
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("order_summary")).toBeInTheDocument();
|
||||
expect(screen.getByText("View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [{ name: "users", schema: "public", table_type: "TABLE" }],
|
||||
});
|
||||
render(<TableTree />);
|
||||
await user.click(screen.getByText("users"));
|
||||
const state = useDbViewerStore.getState();
|
||||
expect(state.tabs).toHaveLength(1);
|
||||
expect(state.tabs[0]).toMatchObject({ schema: "public", table: "users" });
|
||||
});
|
||||
|
||||
it("shows Loading when schema tree is loading and no tables are present", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No tables")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows No tables when not loading and no tables are present", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: false });
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("No tables")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Loading…")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Table2, Layers, Eye, Key, Type } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function TableTree({ searchQuery, dbType }: { searchQuery?: string; dbType?: string }) {
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
const openTab = useDbViewerStore((s) => s.openTab);
|
||||
const connectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [columnCache, setColumnCache] = useState<
|
||||
Record<string, ColumnInfo[]>
|
||||
>({});
|
||||
|
||||
const q = (searchQuery ?? "").toLowerCase().trim();
|
||||
|
||||
const filteredTables = (
|
||||
currentSchema
|
||||
? tables.filter((t) => t.schema === currentSchema)
|
||||
: tables
|
||||
).filter((t) => !q || t.name.toLowerCase().includes(q));
|
||||
|
||||
const toggle = async (key: string, schema: string, tableName: string) => {
|
||||
const isExpanded = expanded.has(key);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isExpanded) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
// Fetch columns if not cached
|
||||
if (!isExpanded && !columnCache[key] && connectionId) {
|
||||
try {
|
||||
const result = await cmd.getTableData(
|
||||
connectionId,
|
||||
schema,
|
||||
tableName,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
setColumnCache((prev) => ({ ...prev, [key]: result.columns }));
|
||||
} catch {
|
||||
/* ignore, columns will remain unknowns */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTab = (
|
||||
schema: string,
|
||||
table: string,
|
||||
forceNew?: boolean,
|
||||
) => {
|
||||
openTab(schema, table, forceNew);
|
||||
return "tab";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredTables.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
{schemaTreeLoading ? "Loading…" : "No tables"}
|
||||
</div>
|
||||
)}
|
||||
{filteredTables.map((table) => {
|
||||
const key = `${table.schema}.${table.name}`;
|
||||
const isExpanded = expanded.has(key);
|
||||
const cols = columnCache[key] ?? table.columns ?? [];
|
||||
const isMatView = table.table_type === "MATERIALIZED VIEW";
|
||||
const isView = table.table_type === "VIEW";
|
||||
const TypeIcon = isMatView ? Layers : isView ? Eye : Table2;
|
||||
const typeLabel = isMatView
|
||||
? "Materialized View"
|
||||
: isView
|
||||
? "View"
|
||||
: null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="group flex items-center gap-1 px-3 py-1 hover:bg-surface-raised cursor-pointer"
|
||||
onClick={() => openTab(table.schema, table.name)}
|
||||
>
|
||||
<button
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(key, table.schema, table.name);
|
||||
}}
|
||||
className="w-5 h-5 flex items-center justify-center text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
<TypeIcon size={14} className="text-text-muted" />
|
||||
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
||||
{table.name}
|
||||
</span>
|
||||
{typeLabel && (
|
||||
<span className="text-[10px] text-text-subtle shrink-0">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<TableOverflowMenu
|
||||
schema={table.schema}
|
||||
table={table.name}
|
||||
connectionId={connectionId ?? undefined}
|
||||
dbType={dbType}
|
||||
columns={cols}
|
||||
onOpenTab={handleOpenTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="pl-10 pr-3 py-1 space-y-1">
|
||||
{cols.length === 0 && (
|
||||
<div className="text-xs text-text-muted">
|
||||
No columns
|
||||
</div>
|
||||
)}
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex items-center gap-2 text-xs text-text-muted"
|
||||
title={
|
||||
col.is_fk && col.fk_ref
|
||||
? `${col.data_type} → ${col.fk_ref[0]}.${col.fk_ref[1]}`
|
||||
: col.data_type
|
||||
}
|
||||
>
|
||||
{col.is_pk ? (
|
||||
<Key
|
||||
size={12}
|
||||
className="text-accent shrink-0"
|
||||
/>
|
||||
) : col.is_fk ? (
|
||||
<Key
|
||||
size={12}
|
||||
className="text-amber-400 shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<Type
|
||||
size={12}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{col.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-text-subtle truncate"
|
||||
title={col.data_type}
|
||||
>
|
||||
{abbreviateType(col.data_type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { SelectDropdown } from "../ui/SelectDropdown";
|
||||
import { BackupPage } from "./BackupPage";
|
||||
import { RestorePage } from "./RestorePage";
|
||||
import { SyncPage } from "./SyncPage";
|
||||
|
||||
type ToolOperation = "backup" | "restore" | "sync";
|
||||
|
||||
const OPERATION_OPTIONS = [
|
||||
{ value: "backup", label: "Backup" },
|
||||
{ value: "restore", label: "Restore" },
|
||||
{ value: "sync", label: "DB Sync" },
|
||||
];
|
||||
|
||||
export function ToolsPage({ connectionId }: { connectionId: string }) {
|
||||
const [operation, setOperation] = useState<ToolOperation>("backup");
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden">
|
||||
{/* Operation switcher toolbar */}
|
||||
<div className="px-3 pt-3 pb-3 border-b border-border shrink-0">
|
||||
<SelectDropdown
|
||||
value={operation}
|
||||
onChange={(v) => setOperation(v as ToolOperation)}
|
||||
options={OPERATION_OPTIONS}
|
||||
variant="ghost"
|
||||
aria-label="Operation"
|
||||
/>
|
||||
</div>
|
||||
{/* Content — BackupPage/RestorePage/SyncPage each render their own
|
||||
toolbar header and flex-1 overflow-y-auto scroll container, so this
|
||||
wrapper only provides a definite height (h-full resolves against it). */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{operation === "backup" && <BackupPage connectionId={connectionId} />}
|
||||
{operation === "restore" && <RestorePage connectionId={connectionId} />}
|
||||
{operation === "sync" && <SyncPage />}
|
||||
</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" },
|
||||
];
|
||||
@@ -0,0 +1,26 @@
|
||||
interface Props {
|
||||
cols: string[];
|
||||
selected: string[];
|
||||
onToggle: (col: string) => void;
|
||||
}
|
||||
|
||||
export function ColumnPicker({ cols, selected, onToggle }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1" data-testid="column-picker">
|
||||
{cols.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => onToggle(c)}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||
selected.includes(c)
|
||||
? "bg-accent text-white border-accent"
|
||||
: "border-border text-text hover:border-text-muted"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
{
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "user_id",
|
||||
data_type: "int",
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
is_unique: false,
|
||||
is_nullable: true,
|
||||
fk_ref: ["public", "users", "id"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "int",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: true,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
describe("ConstraintForm", () => {
|
||||
it("check: emits the expression", () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "ck",
|
||||
action: { op: "check", expression: "" },
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
|
||||
target: { value: "amount > 0" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ expression: "amount > 0" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("foreign_key: picks referenced table + column", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "fk",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: ["user_id"],
|
||||
ref_schema: "",
|
||||
ref_table: "",
|
||||
ref_columns: [],
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const kindSelect = screen.getByLabelText("Kind");
|
||||
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
|
||||
await screen.findByText("user_id");
|
||||
const refTable = await screen.findByLabelText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
ref_schema: "public",
|
||||
ref_table: "users",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
|
||||
// mock getSchemaGraph to return tables across two schemas
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as any);
|
||||
render(
|
||||
<ConstraintForm
|
||||
connectionId="c1"
|
||||
schemas={["public", "auth"]}
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "fk1",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: ["org_id"],
|
||||
ref_schema: "auth",
|
||||
ref_table: "orgs",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
// referenced-table picker includes the auth.orgs option
|
||||
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { SchemaGraph } from "../../../lib/types";
|
||||
import { ColumnPicker } from "./ColumnPicker";
|
||||
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
const KINDS = ["check", "unique", "primary_key", "foreign_key"];
|
||||
const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"];
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
function refKey(schema: string, table: string): string {
|
||||
return `${schema}.${table}`;
|
||||
}
|
||||
|
||||
function parseRefKey(key: string): { schema: string; table: string } {
|
||||
const parts = key.split(".");
|
||||
if (parts.length >= 2) return { schema: parts[0] ?? "", table: parts.slice(1).join(".") };
|
||||
return { schema: "", table: key };
|
||||
}
|
||||
|
||||
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
|
||||
const p = params as Record<string, unknown>;
|
||||
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||
const kind = (action.op as string) ?? "check";
|
||||
|
||||
const [graph, setGraph] = useState<SchemaGraph>({ tables: [], relationships: [] });
|
||||
|
||||
useEffect(() => {
|
||||
cmd
|
||||
.getSchemaGraph(connectionId, undefined)
|
||||
.then((g: SchemaGraph) => setGraph(g))
|
||||
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||
}, [connectionId]);
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||
|
||||
const tableCols =
|
||||
graph.tables
|
||||
.find((t) => t.name === (p.table as string) && t.schema === (p.schema as string))
|
||||
?.columns.map((c) => c.name) ?? [];
|
||||
|
||||
const refCols =
|
||||
graph.tables
|
||||
.find(
|
||||
(t) =>
|
||||
t.name === (action.ref_table as string) &&
|
||||
t.schema === (action.ref_schema as string),
|
||||
)
|
||||
?.columns.map((c) => c.name) ?? [];
|
||||
|
||||
const selected: string[] = (action.columns as string[]) ?? [];
|
||||
const refSelected: string[] = (action.ref_columns as string[]) ?? [];
|
||||
|
||||
const pick = (list: string[], c: string) =>
|
||||
list.includes(c) ? list.filter((x) => x !== c) : [...list, c];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Table"
|
||||
value={(p.table as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, table: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Constraint name"
|
||||
value={(p.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Kind">
|
||||
<select
|
||||
aria-label="Kind"
|
||||
value={kind}
|
||||
onChange={(e) => onChange({ ...p, action: { op: e.target.value } })}
|
||||
className={controlClass}
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k.replace(/_/g, " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{(kind === "unique" || kind === "primary_key") && (
|
||||
<FormRow label="Columns" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
<ColumnPicker
|
||||
cols={tableCols}
|
||||
selected={selected}
|
||||
onToggle={(c) => setAction({ columns: pick(selected, c) })}
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{kind === "check" && (
|
||||
<FormRow label="Expression">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="CHECK expression"
|
||||
value={(action.expression as string) ?? ""}
|
||||
onChange={(e) => setAction({ expression: e.target.value })}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{kind === "foreign_key" && (
|
||||
<>
|
||||
<FormRow label="Columns" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
<ColumnPicker
|
||||
cols={tableCols}
|
||||
selected={selected}
|
||||
onToggle={(c) => setAction({ columns: pick(selected, c) })}
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="Referenced table">
|
||||
<select
|
||||
aria-label="Referenced table"
|
||||
value={refKey(
|
||||
(action.ref_schema as string) ?? "",
|
||||
(action.ref_table as string) ?? "",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const { schema, table } = parseRefKey(e.target.value);
|
||||
const t = graph.tables.find(
|
||||
(tbl) => tbl.name === table && tbl.schema === schema,
|
||||
);
|
||||
setAction({
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: [],
|
||||
});
|
||||
if (!t) return;
|
||||
const pkCols = t.columns.filter((c) => c.is_pk).map((c) => c.name);
|
||||
if (pkCols.length > 0 && selected.length > 0) {
|
||||
setAction({
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: pkCols.slice(0, selected.length),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a table
|
||||
</option>
|
||||
{graph.tables.map((t) => (
|
||||
<option key={refKey(t.schema, t.name)} value={refKey(t.schema, t.name)}>
|
||||
{t.schema}.{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Referenced columns" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
<ColumnPicker
|
||||
cols={refCols}
|
||||
selected={refSelected}
|
||||
onToggle={(c) => setAction({ ref_columns: pick(refSelected, c) })}
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="On delete">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={(action.on_delete as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_delete: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="On update">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={(action.on_update as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_update: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Deferrable">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="DEFERRABLE"
|
||||
checked={!!action.deferrable}
|
||||
onChange={(e) =>
|
||||
setAction({
|
||||
deferrable: e.target.checked,
|
||||
initially_deferred: e.target.checked ? action.initially_deferred ?? false : false,
|
||||
})
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>DEFERRABLE</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
{!!action.deferrable && (
|
||||
<FormRow label="Initially deferred">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="INITIALLY DEFERRED"
|
||||
checked={!!action.initially_deferred}
|
||||
onChange={(e) => setAction({ initially_deferred: e.target.checked })}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>INITIALLY DEFERRED</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { EnumForm } from "./EnumForm";
|
||||
|
||||
describe("EnumForm", () => {
|
||||
it("create: add/remove labels", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<EnumForm
|
||||
params={{ schema: "public", name: "role", action: { op: "create", labels: ["admin"] } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add value/i }));
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ labels: ["admin", ""] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("add_value op shows value + position + the no-removal note", () => {
|
||||
render(
|
||||
<EnumForm
|
||||
params={{
|
||||
schema: "public",
|
||||
name: "color",
|
||||
action: { op: "add_value", value: "orange", if_not_exists: false, before: null, after: null },
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("New value")).toHaveValue("orange");
|
||||
expect(screen.getByText(/no ALTER TYPE … DROP VALUE/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rename_value op shows from + to", () => {
|
||||
render(
|
||||
<EnumForm
|
||||
params={{
|
||||
schema: "public",
|
||||
name: "color",
|
||||
action: { op: "rename_value", from: "purple", to: "mauve" },
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("From")).toHaveValue("purple");
|
||||
expect(screen.getByPlaceholderText("To")).toHaveValue("mauve");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type EnumOp = "create" | "rename_type" | "add_value" | "rename_value";
|
||||
|
||||
const OP_LABELS: Record<EnumOp, string> = {
|
||||
create: "Create",
|
||||
rename_type: "Rename type",
|
||||
add_value: "Add value",
|
||||
rename_value: "Rename value",
|
||||
};
|
||||
|
||||
function getOp(params: DdlParams): EnumOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const op = action.op;
|
||||
if (op === "rename_type" || op === "add_value" || op === "rename_value") return op;
|
||||
return "create";
|
||||
}
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
function patchActionResetOp(params: DdlParams, op: EnumOp): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const labels = (action.labels as string[]) ?? [];
|
||||
return { ...params, action: { op, labels } };
|
||||
}
|
||||
|
||||
function NoRemovalNote() {
|
||||
return (
|
||||
<p className="text-xs text-text-muted">
|
||||
PostgreSQL has no ALTER TYPE … DROP VALUE. To remove a value, drop and recreate the type.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnumForm({ params, schemas, onChange }: Props) {
|
||||
const op = getOp(params);
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const labels = (action.labels as string[]) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enum name"
|
||||
value={(params.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) => onChange(patchActionResetOp(params, e.target.value as EnumOp))}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(OP_LABELS) as EnumOp[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{OP_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{op === "create" && (
|
||||
<FormRow label="Values" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
{labels.map((l, i) => (
|
||||
<div key={i} className="flex gap-1 items-center">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Value ${i + 1}`}
|
||||
value={l}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
patchAction(params, {
|
||||
labels: labels.map((x, j) => (j === i ? e.target.value : x)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange(patchAction(params, { labels: labels.filter((_, j) => j !== i) }))
|
||||
}
|
||||
className="text-text-muted hover:text-red-400 px-2"
|
||||
aria-label={`Remove value ${i + 1}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(patchAction(params, { labels: [...labels, ""] }))}
|
||||
className="self-start text-xs text-accent hover:text-accent/80"
|
||||
>
|
||||
+ Add value
|
||||
</button>
|
||||
<NoRemovalNote />
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{op === "rename_type" && (
|
||||
<FormRow label="New name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New name"
|
||||
value={(action.new_name as string) ?? ""}
|
||||
onChange={(e) => onChange(patchAction(params, { new_name: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
{op === "add_value" && (
|
||||
<>
|
||||
<FormRow label="New value">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New value"
|
||||
value={(action.value as string) ?? ""}
|
||||
onChange={(e) => onChange(patchAction(params, { value: e.target.value }))}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="If not exists">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.if_not_exists}
|
||||
onChange={(e) => onChange(patchAction(params, { if_not_exists: e.target.checked }))}
|
||||
aria-label="IF NOT EXISTS"
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>IF NOT EXISTS</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Before">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="BEFORE (optional)"
|
||||
value={(action.before as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { before: e.target.value || null, after: null }))
|
||||
}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="After">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="AFTER (optional)"
|
||||
value={(action.after as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { after: e.target.value || null, before: null }))
|
||||
}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<NoRemovalNote />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{op === "rename_value" && (
|
||||
<>
|
||||
<FormRow label="From">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="From"
|
||||
value={(action.from as string) ?? ""}
|
||||
onChange={(e) => onChange(patchAction(params, { from: e.target.value }))}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="To">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="To"
|
||||
value={(action.to as string) ?? ""}
|
||||
onChange={(e) => onChange(patchAction(params, { to: e.target.value }))}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ExtensionForm } from "./ExtensionForm";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
vi.mock("../../../lib/objectCrud", () => ({
|
||||
getAvailableExtensions: vi.fn(),
|
||||
buildObjectDdl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ExtensionForm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("create: lists available extensions and emits name on pick", async () => {
|
||||
vi.mocked(objectCrud.getAvailableExtensions).mockResolvedValue([
|
||||
{ name: "pgcrypto", version: "1.3", comment: null },
|
||||
]);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ExtensionForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
name: "",
|
||||
action: { op: "create", version: null },
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("pgcrypto")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("pgcrypto"));
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "pgcrypto",
|
||||
action: expect.objectContaining({ version: "1.3" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("set_schema op emits new_schema", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ExtensionForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
name: "pgcrypto",
|
||||
action: { op: "set_schema", new_schema: "" },
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("New schema"), {
|
||||
target: { value: "utils" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ new_schema: "utils" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import {
|
||||
getAvailableExtensions,
|
||||
type AvailableExtension,
|
||||
} from "../../../lib/objectCrud";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type ExtensionOp = "create" | "set_schema";
|
||||
|
||||
const OP_LABELS: Record<ExtensionOp, string> = {
|
||||
create: "Install",
|
||||
set_schema: "Set schema",
|
||||
};
|
||||
|
||||
export function ExtensionForm({ connectionId, params, schemas, onChange }: Props) {
|
||||
const p = params as Record<string, unknown>;
|
||||
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||
const op = (action.op as ExtensionOp) ?? "create";
|
||||
|
||||
const [available, setAvailable] = useState<AvailableExtension[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableExtensions(connectionId)
|
||||
.then(setAvailable)
|
||||
.catch(() => setAvailable([]));
|
||||
}, [connectionId]);
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) => {
|
||||
onChange({ ...p, action: { ...action, ...patch } });
|
||||
};
|
||||
|
||||
const pickExtension = (ext: AvailableExtension) => {
|
||||
onChange({
|
||||
...p,
|
||||
name: ext.name,
|
||||
action: { op: "create", version: ext.version || null },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) =>
|
||||
onChange({ ...p, action: { op: e.target.value as ExtensionOp } })
|
||||
}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(OP_LABELS) as ExtensionOp[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{OP_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{op === "create" && (
|
||||
<>
|
||||
<FormRow label="Extension">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Extension name"
|
||||
value={(p.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
{available.length > 0 && (
|
||||
<FormRow label="Available" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-0.5 px-4 py-2">
|
||||
{available.map((ext) => (
|
||||
<button
|
||||
key={ext.name}
|
||||
type="button"
|
||||
onClick={() => pickExtension(ext)}
|
||||
className="text-left text-xs font-medium text-accent hover:text-accent-hover hover:underline"
|
||||
>
|
||||
<span>{ext.name}</span>
|
||||
{ext.version ? (
|
||||
<span className="text-text-muted"> ({ext.version})</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormRow>
|
||||
)}
|
||||
|
||||
<FormRow label="Version">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Version (optional)"
|
||||
value={(action.version as string) ?? ""}
|
||||
onChange={(e) => setAction({ version: e.target.value || null })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{op === "set_schema" && (
|
||||
<FormRow label="New schema">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New schema"
|
||||
value={(action.new_schema as string) ?? ""}
|
||||
onChange={(e) => setAction({ new_schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { FkPanel } from "./FkPanel";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
{
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "user_id", data_type: "int", is_pk: false, is_fk: true, is_unique: false, is_nullable: true, fk_ref: ["public", "users", "id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue(graph as any);
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockReset().mockResolvedValue(["ALTER TABLE ... ADD CONSTRAINT ..."]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderPanel(props: Partial<React.ComponentProps<typeof FkPanel>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onStaged = vi.fn();
|
||||
const utils = render(
|
||||
<FkPanel
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="orders"
|
||||
column="user_id"
|
||||
mode="edit"
|
||||
localColumns={[
|
||||
{ name: "user_id", data_type: "int" },
|
||||
{ name: "amount", data_type: "numeric" },
|
||||
{ name: "created_at", data_type: "timestamp" },
|
||||
]}
|
||||
onClose={onClose}
|
||||
onStaged={onStaged}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onClose, onStaged };
|
||||
}
|
||||
|
||||
/** The columns/actions sections only render once a referenced table is chosen. */
|
||||
async function waitForTable() {
|
||||
await screen.findByLabelText("Table");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Local column 1")).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
describe("FkPanel", () => {
|
||||
it("renders cascading dropdowns with schemas, tables, and PK-first columns", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
|
||||
// Schema select contains public and auth
|
||||
const schema = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...schema.options].map((o) => o.value)).toContain("public");
|
||||
expect([...schema.options].map((o) => o.value)).toContain("auth");
|
||||
});
|
||||
|
||||
// Table select contains public tables by default
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("users");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("orgs");
|
||||
});
|
||||
|
||||
// Once a table is referenced, the local column pair appears, preselected
|
||||
await waitForTable();
|
||||
const local = screen.getByLabelText("Local column 1") as HTMLSelectElement;
|
||||
expect(local.value).toBe("user_id");
|
||||
expect([...local.options].map((o) => o.textContent)).toContain("user_id (int)");
|
||||
|
||||
// Referenced column select lists PKs first and marks them
|
||||
const refCol = screen.getByLabelText("Referenced column 1") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opts = [...refCol.options].map((o) => o.textContent);
|
||||
expect(opts[0]).toMatch(/id/);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates table and column lists when schema changes", async () => {
|
||||
renderPanel();
|
||||
const schema = await screen.findByLabelText("Schema");
|
||||
fireEvent.change(schema, { target: { value: "auth" } });
|
||||
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("orgs");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("users");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the column-pairs and action sections until a referenced table is chosen", async () => {
|
||||
let resolveGraph: (g: unknown) => void = () => {};
|
||||
(cmd.getSchemaGraph as any).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveGraph = resolve;
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
// while the graph is still loading, the rest is hidden
|
||||
expect(screen.queryByLabelText("Local column 1")).toBeNull();
|
||||
// resolve the graph → table auto-selects → sections appear
|
||||
resolveGraph(graph);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Local column 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports adding multiple column pairs", async () => {
|
||||
renderPanel();
|
||||
await waitForTable();
|
||||
fireEvent.click(screen.getByLabelText("Add another column"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText(/Referenced column/)).toHaveLength(2);
|
||||
// removing a pair works
|
||||
fireEvent.click(screen.getByLabelText("Remove pair 2"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stages a foreign key DDL change and closes the panel", async () => {
|
||||
const { onStaged } = renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
|
||||
await waitForTable();
|
||||
fireEvent.change(screen.getByLabelText("On delete"), { target: { value: "CASCADE" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(objectCrud.buildObjectDdl).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"constraint",
|
||||
expect.objectContaining({
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "",
|
||||
action: expect.objectContaining({
|
||||
op: "foreign_key",
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("ALTER TABLE ... ADD CONSTRAINT ...");
|
||||
// hands back the local column + the referenced column's type (for auto-matching)
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("create mode returns the FK inline instead of staging a separate ALTER", async () => {
|
||||
const { onStaged } = renderPanel({ mode: "create" });
|
||||
await screen.findByText("Foreign key");
|
||||
await waitForTable();
|
||||
const refCol = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refCol.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
// no separate ALTER change is staged
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
expect(objectCrud.buildObjectDdl).not.toHaveBeenCalled();
|
||||
|
||||
// the FK definition is handed back for inlining into the CREATE TABLE
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
fk: expect.objectContaining({
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "NO ACTION",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { X, Link, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import { buildObjectDdl } from "../../../lib/objectCrud";
|
||||
import type { SchemaGraph } from "../../../lib/types";
|
||||
|
||||
// Panel selects: transparent, fill the available width (no surface bg like the grid).
|
||||
const panelSelect =
|
||||
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
|
||||
|
||||
export interface FkColumn {
|
||||
name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export interface FkStagedPair {
|
||||
localCol: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
export interface FkDefinition {
|
||||
columns: string[];
|
||||
ref_schema: string;
|
||||
ref_table: string;
|
||||
ref_columns: string[];
|
||||
on_delete?: string;
|
||||
on_update?: string;
|
||||
deferrable?: boolean;
|
||||
initially_deferred?: boolean;
|
||||
}
|
||||
|
||||
interface FkPair {
|
||||
localCol: string;
|
||||
refCol: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string | null;
|
||||
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
|
||||
localColumns: FkColumn[];
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
|
||||
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
|
||||
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
|
||||
}
|
||||
|
||||
const FK_ACTIONS = [
|
||||
"NO ACTION",
|
||||
"RESTRICT",
|
||||
"CASCADE",
|
||||
"SET NULL",
|
||||
"SET DEFAULT",
|
||||
];
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
flush = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FkPanel({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
localColumns,
|
||||
mode,
|
||||
onClose,
|
||||
onStaged,
|
||||
}: Props) {
|
||||
const [graph, setGraph] = useState<SchemaGraph>({
|
||||
tables: [],
|
||||
relationships: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
|
||||
const [pairs, setPairs] = useState<FkPair[]>(() =>
|
||||
column
|
||||
? [{ localCol: column, refCol: "" }]
|
||||
: [{ localCol: "", refCol: "" }],
|
||||
);
|
||||
const [refSchema, setRefSchema] = useState<string>(schema);
|
||||
const [refTable, setRefTable] = useState<string>("");
|
||||
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
|
||||
const [onUpdate, setOnUpdate] = useState<string>("NO ACTION");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
cmd.getSchemaGraph(connectionId, undefined)
|
||||
.then((g) => {
|
||||
if (!active) return;
|
||||
setGraph(g);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!active) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
const localNames = useMemo(
|
||||
() => localColumns.map((c) => c.name),
|
||||
[localColumns],
|
||||
);
|
||||
|
||||
const schemas = useMemo(
|
||||
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
|
||||
[graph],
|
||||
);
|
||||
|
||||
const tablesInSchema = useMemo(
|
||||
() =>
|
||||
graph.tables
|
||||
.filter((t) => t.schema === refSchema)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[graph, refSchema],
|
||||
);
|
||||
|
||||
const refTableInfo = useMemo(
|
||||
() => tablesInSchema.find((t) => t.name === refTable),
|
||||
[tablesInSchema, refTable],
|
||||
);
|
||||
|
||||
const refColumns = useMemo(() => {
|
||||
if (!refTableInfo) return [];
|
||||
return [...refTableInfo.columns].sort((a, b) => {
|
||||
if (a.is_pk && !b.is_pk) return -1;
|
||||
if (!a.is_pk && b.is_pk) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [refTableInfo]);
|
||||
|
||||
// Defaults: keep selections valid as the graph loads / inputs change.
|
||||
useEffect(() => {
|
||||
if (schemas.length > 0 && !schemas.includes(refSchema)) {
|
||||
setRefSchema(schemas[0] ?? "");
|
||||
}
|
||||
}, [schemas, refSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
tablesInSchema.length > 0 &&
|
||||
!tablesInSchema.some((t) => t.name === refTable)
|
||||
) {
|
||||
setRefTable(tablesInSchema[0]?.name ?? "");
|
||||
} else if (tablesInSchema.length === 0) {
|
||||
setRefTable("");
|
||||
}
|
||||
}, [tablesInSchema, refTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localNames.length === 0) return;
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
localNames.includes(p.localCol)
|
||||
? p
|
||||
: { ...p, localCol: localNames[0] ?? "" },
|
||||
),
|
||||
);
|
||||
}, [localNames]);
|
||||
|
||||
useEffect(() => {
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
p.refCol && refColumns.some((c) => c.name === p.refCol)
|
||||
? p
|
||||
: { ...p, refCol: refColumns[0]?.name ?? "" },
|
||||
),
|
||||
);
|
||||
}, [refColumns]);
|
||||
|
||||
const setPair = (i: number, key: keyof FkPair, value: string) =>
|
||||
setPairs((prev) =>
|
||||
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
|
||||
);
|
||||
|
||||
const addPair = () =>
|
||||
setPairs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
localCol: localNames[0] ?? "",
|
||||
refCol: refColumns[0]?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
const removePair = (i: number) =>
|
||||
setPairs((prev) =>
|
||||
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
|
||||
);
|
||||
|
||||
const typeMappings = pairs
|
||||
.map((p) => ({
|
||||
localType:
|
||||
localColumns.find((c) => c.name === p.localCol)?.data_type ??
|
||||
"",
|
||||
refType:
|
||||
refTableInfo?.columns.find((c) => c.name === p.refCol)
|
||||
?.data_type ?? "",
|
||||
}))
|
||||
.filter((m) => m.localType !== "" || m.refType !== "");
|
||||
|
||||
const tableLabel = table.trim() === "" ? "unnamed table" : table;
|
||||
|
||||
const addFk = async () => {
|
||||
const cols = pairs.map((p) => p.localCol);
|
||||
const refCols = pairs.map((p) => p.refCol);
|
||||
if (
|
||||
cols.length === 0 ||
|
||||
cols.some((c) => c === "") ||
|
||||
refCols.some((c) => c === "")
|
||||
)
|
||||
return;
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
const refTypes: Record<string, string> = {};
|
||||
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
|
||||
const pairsResult = pairs.map((p) => ({
|
||||
localCol: p.localCol,
|
||||
refType: refTypes[p.refCol] ?? "",
|
||||
}));
|
||||
const fk: FkDefinition = {
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
};
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Inline the FK into the CREATE TABLE — no separate ALTER change.
|
||||
onStaged?.({ pairs: pairsResult, fk });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const sqls = await buildObjectDdl(connectionId, "constraint", {
|
||||
schema,
|
||||
table,
|
||||
name: "",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
},
|
||||
});
|
||||
sqls.forEach((sql, i) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description:
|
||||
sqls.length > 1
|
||||
? `Add FK ${cols.join(", ")} → ${refSchema}.${refTable} (${i + 1}/${sqls.length})`
|
||||
: `Add FK ${cols.join(", ")} → ${refSchema}.${refTable}`,
|
||||
}),
|
||||
);
|
||||
onStaged?.({ pairs: pairsResult });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Link size={14} />
|
||||
<span className="text-sm font-semibold">Foreign key</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-text-muted hover:text-text"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
{table.trim() === "" && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
|
||||
Enter a table name in the form first — a foreign key
|
||||
needs a named table.
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
Loading schema graph…
|
||||
</p>
|
||||
)}
|
||||
{!loading && graph.tables.length === 0 && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
No tables available for foreign key reference.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && graph.tables.length > 0 && (
|
||||
<>
|
||||
<Section label="Select a Schema">
|
||||
<select
|
||||
aria-label="Schema"
|
||||
value={refSchema}
|
||||
onChange={(e) => setRefSchema(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{schemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Select a Table to reference to">
|
||||
<select
|
||||
aria-label="Table"
|
||||
value={refTable}
|
||||
onChange={(e) => setRefTable(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{tablesInSchema.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
{refSchema && refTable && (
|
||||
<>
|
||||
<Section
|
||||
flush
|
||||
label={`Select columns from ${schema}.${tableLabel} to reference to`}
|
||||
>
|
||||
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
|
||||
<div className="border-r border-border px-4 py-1">
|
||||
<span className="truncate">
|
||||
{schema}.{tableLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-1">
|
||||
<span className="truncate">
|
||||
{refSchema}.{refTable || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{pairs.map((pair, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-2 items-center border-b border-border"
|
||||
>
|
||||
<div className="border-r border-border px-2 py-1">
|
||||
<select
|
||||
aria-label={`Local column ${i + 1}`}
|
||||
value={pair.localCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"localCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{localColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1">
|
||||
<select
|
||||
aria-label={`Referenced column ${i + 1}`}
|
||||
value={pair.refCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"refCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{refColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
{c.is_pk
|
||||
? " (PK)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pairs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pair ${i + 1}`}
|
||||
onClick={() =>
|
||||
removePair(i)
|
||||
}
|
||||
className="shrink-0 text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another column"
|
||||
onClick={addPair}
|
||||
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
|
||||
>
|
||||
<Plus size={12} className="inline" />{" "}
|
||||
Add another column
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{typeMappings.length > 0 && (
|
||||
<Section label="Types will be updated">
|
||||
<ul className="space-y-0.5">
|
||||
{typeMappings.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="px-2 font-mono text-xs text-text"
|
||||
>
|
||||
<span className="text-text-muted mr-2">
|
||||
{m.localType || "?"}
|
||||
</span>
|
||||
<span className="text-accent">
|
||||
→
|
||||
</span>{" "}
|
||||
<span className="">
|
||||
{m.refType || "?"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Action if referenced row is updated">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={onUpdate}
|
||||
onChange={(e) =>
|
||||
setOnUpdate(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Action if referenced row is removed">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={onDelete}
|
||||
onChange={(e) =>
|
||||
setOnDelete(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFk}
|
||||
disabled={
|
||||
staging ||
|
||||
table.trim() === "" ||
|
||||
pairs.length === 0 ||
|
||||
pairs.some((p) => p.localCol === "" || p.refCol === "")
|
||||
}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Add FK
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { FunctionForm } from "./FunctionForm";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
|
||||
vi.mock("../../editor/SqlEditorField", () => ({
|
||||
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("FunctionForm", () => {
|
||||
it("regression: Body row opts out of focus-within outline; normal rows keep it", async () => {
|
||||
const params: DdlParams = {
|
||||
schema: "public",
|
||||
name: "add",
|
||||
is_procedure: false,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [],
|
||||
return_type: "int",
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
render(<FunctionForm kind="function" params={params} onChange={() => {}} />);
|
||||
|
||||
// Monaco rows must NOT get the amber in-cell-editing outline.
|
||||
const editor = await screen.findByTestId("sql-editor");
|
||||
const bodyRow = editor.closest("div.flex.flex-row");
|
||||
expect(bodyRow?.className ?? "").not.toContain("focus-within:outline");
|
||||
|
||||
// Normal rows still carry the outline — the opt-out must be scoped.
|
||||
const operationRow = screen
|
||||
.getByLabelText("Operation")
|
||||
.closest("div.flex.flex-row");
|
||||
expect(operationRow?.className ?? "").toContain("focus-within:outline");
|
||||
});
|
||||
|
||||
it("function: renders args grid + return type; emits body", async () => {
|
||||
const onChange = vi.fn();
|
||||
const params: DdlParams = {
|
||||
schema: "public",
|
||||
name: "add",
|
||||
is_procedure: false,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [{ mode: "in", name: "a", type: "int" }],
|
||||
return_type: "int",
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
|
||||
fireEvent.change(await screen.findByTestId("sql-editor"), {
|
||||
target: { value: "BEGIN RETURN a; END" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ body: "BEGIN RETURN a; END" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a schema dropdown when schemas are provided", () => {
|
||||
const onChange = vi.fn();
|
||||
const params: DdlParams = {
|
||||
schema: "public",
|
||||
name: "add",
|
||||
is_procedure: false,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [],
|
||||
return_type: "int",
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
render(
|
||||
<FunctionForm
|
||||
kind="function"
|
||||
params={params}
|
||||
schemas={["public", "utils"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("Schema");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: "utils" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ schema: "utils" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("procedure: hides return type", () => {
|
||||
const params: DdlParams = {
|
||||
schema: "public",
|
||||
name: "p",
|
||||
is_procedure: true,
|
||||
action: {
|
||||
op: "create_or_replace",
|
||||
args: [],
|
||||
return_type: null,
|
||||
language: "plpgsql",
|
||||
body: "",
|
||||
volatility: null,
|
||||
strict: false,
|
||||
},
|
||||
};
|
||||
render(
|
||||
<FunctionForm kind="procedure" params={params} onChange={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Return type")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drop op: renders arg_types list", () => {
|
||||
const onChange = vi.fn();
|
||||
const params: DdlParams = {
|
||||
schema: "public",
|
||||
name: "add",
|
||||
is_procedure: false,
|
||||
action: { op: "drop", arg_types: ["int"] },
|
||||
};
|
||||
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByPlaceholderText("Arg types (comma-separated)"), {
|
||||
target: { value: "int, int" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ arg_types: ["int", "int"] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass, monoInputClass } from "./formRow";
|
||||
|
||||
const SqlEditorField = lazy(() =>
|
||||
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
kind: "function" | "procedure";
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
interface Arg {
|
||||
mode: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
type FunctionOp = "create_or_replace" | "drop";
|
||||
|
||||
const MODES = ["in", "out", "inout", "variadic"];
|
||||
const LANGS = ["plpgsql", "sql", "c"];
|
||||
const VOL = ["", "IMMUTABLE", "STABLE", "VOLATILE"];
|
||||
|
||||
function getOp(params: DdlParams): FunctionOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return action.op === "drop" ? "drop" : "create_or_replace";
|
||||
}
|
||||
|
||||
function patchAction(
|
||||
params: DdlParams,
|
||||
patch: Record<string, unknown>,
|
||||
): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
function patchTopLevel(
|
||||
params: DdlParams,
|
||||
kind: "function" | "procedure",
|
||||
patch: Record<string, unknown>,
|
||||
): DdlParams {
|
||||
return {
|
||||
...params,
|
||||
...patch,
|
||||
is_procedure: kind === "procedure" ? true : params.is_procedure,
|
||||
};
|
||||
}
|
||||
|
||||
export function FunctionForm({ kind, params, schemas, onChange }: Props) {
|
||||
const op = getOp(params);
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const args = (action.args as Arg[]) ?? [];
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) =>
|
||||
onChange(patchAction(params, patch));
|
||||
|
||||
const setArg = (i: number, patch: Partial<Arg>) =>
|
||||
setAction({
|
||||
args: args.map((a, j) => (j === i ? { ...a, ...patch } : a)),
|
||||
});
|
||||
|
||||
const placeholder = kind === "procedure" ? "Procedure name" : "Function name";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
|
||||
}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={(params.name as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchTopLevel(params, kind, { name: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) => onChange({ ...params, action: { op: e.target.value } })}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="create_or_replace">Create / replace</option>
|
||||
<option value="drop">Drop by signature</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{op === "create_or_replace" && (
|
||||
<>
|
||||
<FormSectionHeader label="Arguments" count={args.length} />
|
||||
{args.map((a, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center gap-2"
|
||||
>
|
||||
<select
|
||||
value={a.mode}
|
||||
onChange={(e) => setArg(i, { mode: e.target.value })}
|
||||
aria-label={`Argument ${i + 1} mode`}
|
||||
className="w-24 shrink-0 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none"
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-text-muted w-8 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="name"
|
||||
value={a.name}
|
||||
onChange={(e) => setArg(i, { name: e.target.value })}
|
||||
className={`${monoInputClass} flex-1`}
|
||||
/>
|
||||
<span className="text-border">:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="type"
|
||||
value={a.type}
|
||||
onChange={(e) => setArg(i, { type: e.target.value })}
|
||||
className={`${monoInputClass} flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAction({ args: args.filter((_, j) => j !== i) })}
|
||||
className="text-text-muted px-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setAction({
|
||||
args: [...args, { mode: "in", name: "", type: "" }],
|
||||
})
|
||||
}
|
||||
className="text-xs text-accent hover:text-accent-hover"
|
||||
>
|
||||
+ Add argument
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{kind === "function" && (
|
||||
<FormRow label="Return type">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Return type"
|
||||
value={(action.return_type as string | null) ?? ""}
|
||||
onChange={(e) => setAction({ return_type: e.target.value })}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
<FormRow label="Language">
|
||||
<select
|
||||
aria-label="Language"
|
||||
value={(action.language as string) ?? "plpgsql"}
|
||||
onChange={(e) => setAction({ language: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{LANGS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Volatility">
|
||||
<select
|
||||
aria-label="Volatility"
|
||||
value={(action.volatility as string) ?? ""}
|
||||
onChange={(e) => setAction({ volatility: e.target.value || null })}
|
||||
className={controlClass}
|
||||
>
|
||||
{VOL.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v || "(default VOLATILE)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Strict">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.strict}
|
||||
onChange={(e) => setAction({ strict: e.target.checked })}
|
||||
aria-label="STRICT"
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>STRICT (RETURNS NULL ON NULL INPUT)</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Body" className="items-stretch" outline={false}>
|
||||
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<textarea
|
||||
rows={6}
|
||||
value={(action.body as string) ?? ""}
|
||||
onChange={(e) => setAction({ body: e.target.value })}
|
||||
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full font-mono">
|
||||
<SqlEditorField
|
||||
value={(action.body as string) ?? ""}
|
||||
onChange={(v) => setAction({ body: v })}
|
||||
height={140}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{op === "drop" && (
|
||||
<FormRow label="Arg types">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Arg types (comma-separated)"
|
||||
value={(action.arg_types as string[] | undefined)?.join(", ") ?? ""}
|
||||
onChange={(e) =>
|
||||
setAction({
|
||||
arg_types: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className={monoInputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { IndexForm } from "./IndexForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "int",
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
is_nullable: false,
|
||||
fk_ref: null,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
data_type: "text",
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
is_unique: false,
|
||||
is_nullable: true,
|
||||
fk_ref: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
describe("IndexForm", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockReset();
|
||||
});
|
||||
|
||||
it("loads table columns and toggles a column into the index", async () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<IndexForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "i",
|
||||
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("email")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("email"));
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ columns: ["email"] }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles unique", () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue({
|
||||
tables: [{ name: "users", schema: "public", table_type: "BASE TABLE", columns: [] }],
|
||||
relationships: [],
|
||||
} as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<IndexForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "users",
|
||||
name: "i",
|
||||
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("Unique"));
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ unique: true }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { SchemaGraph } from "../../../lib/types";
|
||||
import { ColumnPicker } from "./ColumnPicker";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
const METHODS = ["", "btree", "hash", "gist", "gin", "brin"];
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
export function IndexForm({ connectionId, params, schemas, onChange }: Props) {
|
||||
const p = params as Record<string, unknown>;
|
||||
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||
const [cols, setCols] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
cmd
|
||||
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
|
||||
.then((g: SchemaGraph) => {
|
||||
const t = g.tables.find(
|
||||
(t) => t.name === (p.table as string) && t.schema === (p.schema as string),
|
||||
);
|
||||
setCols(t ? t.columns.map((c) => c.name) : []);
|
||||
})
|
||||
.catch(() => setCols([]));
|
||||
}, [connectionId, p.schema, p.table]);
|
||||
|
||||
const selected: string[] = (action.columns as string[]) ?? [];
|
||||
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(p.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Table"
|
||||
value={(p.table as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, table: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Index name"
|
||||
value={(p.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...p, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Unique">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.unique}
|
||||
onChange={(e) => setAction({ unique: e.target.checked })}
|
||||
aria-label="Unique"
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>Unique</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
<FormRow label="Method">
|
||||
<select
|
||||
aria-label="Method"
|
||||
value={(action.method as string) ?? ""}
|
||||
onChange={(e) => setAction({ method: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{METHODS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m || "(default btree)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Columns" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
<ColumnPicker
|
||||
cols={cols}
|
||||
selected={selected}
|
||||
onToggle={(c) =>
|
||||
setAction({
|
||||
columns: selected.includes(c)
|
||||
? selected.filter((x) => x !== c)
|
||||
: [...selected, c],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="Predicate">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WHERE predicate (optional)"
|
||||
value={(action.predicate as string) ?? ""}
|
||||
onChange={(e) => setAction({ predicate: e.target.value || null })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SequenceForm } from "./SequenceForm";
|
||||
import { EnumForm } from "./EnumForm";
|
||||
import { ExtensionForm } from "./ExtensionForm";
|
||||
import { ViewForm } from "./ViewForm";
|
||||
import { IndexForm } from "./IndexForm";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import { FunctionForm } from "./FunctionForm";
|
||||
import { TriggerForm } from "./TriggerForm";
|
||||
import { RoleForm } from "./RoleForm";
|
||||
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
kind: ObjectKind;
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
/** Renders the matching CRUD form for a kind (shared by the context menu and the form tab). */
|
||||
export function KindForm({ connectionId, kind, params, schemas, onChange }: Props) {
|
||||
switch (kind) {
|
||||
case "sequence":
|
||||
return <SequenceForm params={params} schemas={schemas} onChange={onChange} />;
|
||||
case "enum":
|
||||
return <EnumForm params={params} schemas={schemas} onChange={onChange} />;
|
||||
case "extension":
|
||||
return (
|
||||
<ExtensionForm
|
||||
connectionId={connectionId}
|
||||
params={params}
|
||||
schemas={schemas}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "view":
|
||||
return <ViewForm params={params} schemas={schemas} onChange={onChange} />;
|
||||
case "index":
|
||||
return (
|
||||
<IndexForm
|
||||
connectionId={connectionId}
|
||||
params={params}
|
||||
schemas={schemas}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "constraint":
|
||||
return (
|
||||
<ConstraintForm
|
||||
connectionId={connectionId}
|
||||
params={params}
|
||||
schemas={schemas}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "function":
|
||||
case "procedure":
|
||||
return <FunctionForm kind={kind} params={params} schemas={schemas} onChange={onChange} />;
|
||||
case "trigger":
|
||||
return (
|
||||
<TriggerForm
|
||||
connectionId={connectionId}
|
||||
params={params}
|
||||
schemas={schemas}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "table":
|
||||
// Unreachable in the form-tab flow (ObjectFormTab delegates table to
|
||||
// TableForm directly); kept only so the switch is exhaustive.
|
||||
return null;
|
||||
case "role":
|
||||
return <RoleForm connectionId={connectionId} params={params} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectContextMenu, DROP_TITLE } from "./ObjectContextMenu";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
|
||||
describe("ObjectContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("DROP_TITLE covers the table and role kinds (exhaustiveness)", () => {
|
||||
expect(DROP_TITLE.table).toBe("table");
|
||||
expect(DROP_TITLE.role).toBe("role");
|
||||
});
|
||||
|
||||
it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => {
|
||||
render(
|
||||
<ObjectContextMenu
|
||||
connectionId="c1"
|
||||
objectType="sequence"
|
||||
item={{ schema: "public", name: "s" }}
|
||||
onRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByText("Edit…"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||
expect(st.tabs[0].form?.mode).toBe("edit");
|
||||
expect(st.tabs[0].form?.kind).toBe("sequence");
|
||||
expect(st.tabs[0].form?.params?.name).toBe("s");
|
||||
expect(st.tabs[0].form?.params?.schema).toBe("public");
|
||||
});
|
||||
|
||||
it("Create on a sequence opens an objectForm tab in create mode", async () => {
|
||||
render(
|
||||
<ObjectContextMenu
|
||||
connectionId="c1"
|
||||
objectType="sequence"
|
||||
item={{ schema: "public", name: "s" }}
|
||||
onRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByText("Create…"));
|
||||
const st = useDbViewerStore.getState();
|
||||
expect(st.tabs).toHaveLength(1);
|
||||
expect(st.tabs[0].tabType).toBe("objectForm");
|
||||
expect(st.tabs[0].form?.mode).toBe("create");
|
||||
expect(st.tabs[0].form?.kind).toBe("sequence");
|
||||
expect(st.tabs[0].form?.params?.name).toBe("");
|
||||
});
|
||||
|
||||
it("Drop fetches dependencies then stages the drop", async () => {
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue([
|
||||
'DROP SEQUENCE "public"."s"',
|
||||
]);
|
||||
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||
render(
|
||||
<ObjectContextMenu
|
||||
connectionId="c1"
|
||||
objectType="sequence"
|
||||
item={{ schema: "public", name: "s" }}
|
||||
onRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
|
||||
fireEvent.click(screen.getByText("Drop…"));
|
||||
// DependencyDialog with no deps → Proceed is enabled
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /proceed/i }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /proceed/i }));
|
||||
await waitFor(() =>
|
||||
expect(addChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "ddl",
|
||||
sql: 'DROP SEQUENCE "public"."s"',
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { DependencyDialog } from "../DependencyDialog";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
dropCrudParams,
|
||||
initialCrudParams,
|
||||
type CrudItem,
|
||||
type ObjectKind,
|
||||
} from "../../../lib/objectCrud";
|
||||
import { getObjectDependencies } from "../../../lib/commands";
|
||||
import type { DependencyInfo } from "../../../lib/types";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
objectType: ObjectKind;
|
||||
item: CrudItem;
|
||||
onRefresh: () => void;
|
||||
/** Controlled open state (e.g. driven by a row's right-click). */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Extra non-CRUD actions appended below the divider (Copy DDL, Dependencies…). */
|
||||
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
|
||||
}
|
||||
|
||||
export const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
sequence: "sequence",
|
||||
enum: "type",
|
||||
view: "view",
|
||||
extension: "extension",
|
||||
index: "index",
|
||||
constraint: "constraint",
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
table: "table",
|
||||
role: "role",
|
||||
};
|
||||
|
||||
export function ObjectContextMenu({
|
||||
connectionId,
|
||||
objectType,
|
||||
item,
|
||||
onRefresh,
|
||||
open,
|
||||
onOpenChange,
|
||||
extraItems,
|
||||
}: Props) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [deps, setDeps] = useState<DependencyInfo[] | null>(null);
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isOpen = open ?? internalOpen;
|
||||
const setOpen = (v: boolean) => {
|
||||
if (onOpenChange) onOpenChange(v);
|
||||
else setInternalOpen(v);
|
||||
};
|
||||
|
||||
// Track the current open state in a ref so the document-level outside-click
|
||||
// handler (registered once) only closes a menu that is actually open. Without
|
||||
// this, closed instances would fire onOpenChange(false) on every mousedown
|
||||
// and clobber the shared open key in the controlled (row right-click) case.
|
||||
const openRef = useRef(isOpen);
|
||||
openRef.current = isOpen;
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
if (
|
||||
openRef.current &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const kind = objectType;
|
||||
|
||||
const openFormTab = (mode: "create" | "edit") => {
|
||||
const title = `${mode === "create" ? "Create" : "Edit"} ${kind}`;
|
||||
const description = `${mode === "create" ? "Create" : "Edit"} ${
|
||||
item.name || kind
|
||||
}`;
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind,
|
||||
schema:
|
||||
mode === "create"
|
||||
? useDbViewerStore.getState().currentSchema ?? item.schema ?? "public"
|
||||
: item.schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
title,
|
||||
description,
|
||||
mode,
|
||||
params: initialCrudParams(kind, item, mode),
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const startDrop = async () => {
|
||||
setOpen(false);
|
||||
let d: DependencyInfo[] = [];
|
||||
try {
|
||||
d = await getObjectDependencies(connectionId, item.schema, kind, item.name);
|
||||
} catch {
|
||||
d = [];
|
||||
}
|
||||
setDeps(d);
|
||||
};
|
||||
const confirmDrop = async () => {
|
||||
const sqls = await buildObjectDdl(
|
||||
connectionId,
|
||||
kind,
|
||||
dropCrudParams(kind, item),
|
||||
);
|
||||
sqls.forEach((sql) =>
|
||||
addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `Drop ${DROP_TITLE[kind]} ${item.name}`,
|
||||
}),
|
||||
);
|
||||
setDeps(null);
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
aria-label="actions"
|
||||
className="text-text-muted hover:text-text cursor-pointer"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-6 z-20 w-40 rounded-lg border border-border bg-surface py-1 text-sm text-text shadow-lg">
|
||||
<button
|
||||
onClick={() => openFormTab("create")}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
|
||||
>
|
||||
Create…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openFormTab("edit")}
|
||||
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
|
||||
>
|
||||
Edit…
|
||||
</button>
|
||||
<button
|
||||
onClick={startDrop}
|
||||
className="block w-full text-left px-3 py-1.5 text-red-400 hover:bg-border/30 cursor-pointer"
|
||||
>
|
||||
Drop…
|
||||
</button>
|
||||
{extraItems && extraItems.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
{extraItems.map((it) => (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick();
|
||||
}}
|
||||
className={`block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer ${
|
||||
it.danger ? "text-red-400" : ""
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DependencyDialog
|
||||
open={!!deps}
|
||||
deps={deps ?? []}
|
||||
onProceed={confirmDrop}
|
||||
onCancel={() => setDeps(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
ObjectDetail,
|
||||
OBJECT_ICONS,
|
||||
SINGULAR_LABELS,
|
||||
TYPE_LABELS,
|
||||
} from "./ObjectDetail";
|
||||
import * as commands from "../../../lib/commands";
|
||||
|
||||
describe("ObjectDetail", () => {
|
||||
it("renders an enum's labels as list items", () => {
|
||||
render(
|
||||
<ObjectDetail
|
||||
connectionId="c1"
|
||||
type="enums"
|
||||
item={{ name: "role", schema: "public", labels: ["admin", "user"] }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("admin")).toBeInTheDocument();
|
||||
expect(screen.getByText("user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a function's return type and language", () => {
|
||||
render(
|
||||
<ObjectDetail
|
||||
connectionId="c1"
|
||||
type="functions"
|
||||
item={{
|
||||
name: "add",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: ["integer", "text"],
|
||||
argument_names: ["a", "b"],
|
||||
argument_modes: ["IN", "IN"],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN RETURN a+b; END",
|
||||
kind: "f",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("int")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("roles detail renders RoleDetail", () => {
|
||||
vi.spyOn(commands, "getRolePrivileges").mockResolvedValue([]);
|
||||
render(
|
||||
<ObjectDetail
|
||||
connectionId="c1"
|
||||
type="roles"
|
||||
item={{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("registries include roles", () => {
|
||||
expect(TYPE_LABELS.roles).toBe("Roles");
|
||||
expect(SINGULAR_LABELS.roles).toBe("role");
|
||||
expect(OBJECT_ICONS.roles).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,983 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
FunctionSquare,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ObjectType,
|
||||
FunctionInfo,
|
||||
TriggerInfo,
|
||||
SequenceInfo,
|
||||
EnumInfo,
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RoleInfo,
|
||||
} from "../../../lib/types";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "Functions",
|
||||
triggers: "Triggers",
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
roles: "Roles",
|
||||
};
|
||||
|
||||
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
functions: "function",
|
||||
triggers: "trigger",
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
roles: "role",
|
||||
};
|
||||
|
||||
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
functions: (
|
||||
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
||||
),
|
||||
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||
roles: <ShieldCheck size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
export type AnyObject =
|
||||
| FunctionInfo
|
||||
| TriggerInfo
|
||||
| SequenceInfo
|
||||
| EnumInfo
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo
|
||||
| RoleInfo;
|
||||
|
||||
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
|
||||
|
||||
const SQL_KEYWORDS = new Set([
|
||||
"ADD",
|
||||
"ALL",
|
||||
"ALTER",
|
||||
"AND",
|
||||
"ANY",
|
||||
"AS",
|
||||
"ASC",
|
||||
"BEGIN",
|
||||
"BETWEEN",
|
||||
"BY",
|
||||
"CALL",
|
||||
"CASCADE",
|
||||
"CASE",
|
||||
"CAST",
|
||||
"CHECK",
|
||||
"CLOSE",
|
||||
"COLLATE",
|
||||
"COLUMN",
|
||||
"COMMIT",
|
||||
"CONSTRAINT",
|
||||
"CONTINUE",
|
||||
"CREATE",
|
||||
"CROSS",
|
||||
"CURRENT",
|
||||
"CURSOR",
|
||||
"DECLARE",
|
||||
"DEFAULT",
|
||||
"DELETE",
|
||||
"DESC",
|
||||
"DISTINCT",
|
||||
"DO",
|
||||
"DROP",
|
||||
"ELSE",
|
||||
"ELSIF",
|
||||
"END",
|
||||
"EXCEPTION",
|
||||
"EXECUTE",
|
||||
"EXISTS",
|
||||
"EXIT",
|
||||
"FETCH",
|
||||
"FOR",
|
||||
"FOREIGN",
|
||||
"FROM",
|
||||
"FULL",
|
||||
"FUNCTION",
|
||||
"GRANT",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"IF",
|
||||
"IN",
|
||||
"INDEX",
|
||||
"INNER",
|
||||
"INSERT",
|
||||
"INTO",
|
||||
"IS",
|
||||
"JOIN",
|
||||
"KEY",
|
||||
"LANGUAGE",
|
||||
"LEFT",
|
||||
"LIMIT",
|
||||
"LOOP",
|
||||
"NOT",
|
||||
"NULL",
|
||||
"OF",
|
||||
"OFFSET",
|
||||
"ON",
|
||||
"OPEN",
|
||||
"OR",
|
||||
"ORDER",
|
||||
"OUTER",
|
||||
"OVER",
|
||||
"PERFORM",
|
||||
"PLPGSQL",
|
||||
"PRIMARY",
|
||||
"PROCEDURE",
|
||||
"QUERY",
|
||||
"RAISE",
|
||||
"REFERENCES",
|
||||
"REPLACE",
|
||||
"RETURN",
|
||||
"RETURNS",
|
||||
"REVOKE",
|
||||
"RIGHT",
|
||||
"ROLLBACK",
|
||||
"ROW",
|
||||
"ROWS",
|
||||
"SCHEMA",
|
||||
"SELECT",
|
||||
"SET",
|
||||
"STRICT",
|
||||
"TABLE",
|
||||
"THEN",
|
||||
"TO",
|
||||
"TRIGGER",
|
||||
"UNION",
|
||||
"UPDATE",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"VIEW",
|
||||
"WHEN",
|
||||
"WHERE",
|
||||
"WHILE",
|
||||
"WITH",
|
||||
]);
|
||||
|
||||
const SQL_TYPES = new Set([
|
||||
"BIGINT",
|
||||
"BIGSERIAL",
|
||||
"BIT",
|
||||
"BOOL",
|
||||
"BOOLEAN",
|
||||
"BPCHAR",
|
||||
"BYTEA",
|
||||
"CHAR",
|
||||
"CHARACTER",
|
||||
"DATE",
|
||||
"DECIMAL",
|
||||
"DOUBLE",
|
||||
"FLOAT",
|
||||
"FLOAT4",
|
||||
"FLOAT8",
|
||||
"INT",
|
||||
"INT2",
|
||||
"INT4",
|
||||
"INT8",
|
||||
"INTEGER",
|
||||
"INTERVAL",
|
||||
"JSON",
|
||||
"JSONB",
|
||||
"MONEY",
|
||||
"NAME",
|
||||
"NUMERIC",
|
||||
"OID",
|
||||
"REAL",
|
||||
"SERIAL",
|
||||
"SMALLINT",
|
||||
"TEXT",
|
||||
"TIME",
|
||||
"TIMESTAMP",
|
||||
"TIMESTAMPTZ",
|
||||
"UUID",
|
||||
"VARBIT",
|
||||
"VARCHAR",
|
||||
"VOID",
|
||||
"XML",
|
||||
]);
|
||||
|
||||
interface Token {
|
||||
text: string;
|
||||
kind:
|
||||
| "keyword"
|
||||
| "type"
|
||||
| "string"
|
||||
| "comment"
|
||||
| "number"
|
||||
| "operator"
|
||||
| "plain";
|
||||
}
|
||||
|
||||
function tokenizeLine(line: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < line.length) {
|
||||
if (/\s/.test(line[i])) {
|
||||
let ws = "";
|
||||
while (i < line.length && /\s/.test(line[i])) {
|
||||
ws += line[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ text: ws, kind: "plain" });
|
||||
continue;
|
||||
}
|
||||
if (line[i] === "-" && line[i + 1] === "-") {
|
||||
tokens.push({ text: line.slice(i), kind: "comment" });
|
||||
return tokens;
|
||||
}
|
||||
if (line[i] === "/" && line[i + 1] === "*") {
|
||||
const end = line.indexOf("*/", i + 2);
|
||||
if (end !== -1) {
|
||||
tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
|
||||
i = end + 2;
|
||||
} else {
|
||||
tokens.push({ text: line.slice(i), kind: "comment" });
|
||||
return tokens;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line[i] === "$") {
|
||||
let dollar = "";
|
||||
const start = i;
|
||||
while (i < line.length && line[i] === "$") {
|
||||
dollar += "$";
|
||||
i++;
|
||||
}
|
||||
let tag = "";
|
||||
if (dollar.length === 1 && i < line.length && line[i] !== "$") {
|
||||
while (i < line.length && line[i] !== "$") {
|
||||
tag += line[i];
|
||||
i++;
|
||||
}
|
||||
if (line[i] === "$") {
|
||||
i++;
|
||||
dollar = `$${tag}$`;
|
||||
}
|
||||
}
|
||||
const endTag = dollar;
|
||||
const endIdx = line.indexOf(endTag, i);
|
||||
if (endIdx !== -1) {
|
||||
tokens.push({
|
||||
text: line.slice(start, endIdx + endTag.length),
|
||||
kind: "string",
|
||||
});
|
||||
i = endIdx + endTag.length;
|
||||
} else {
|
||||
tokens.push({ text: line.slice(start), kind: "string" });
|
||||
return tokens;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (line[i] === "'") {
|
||||
let str = "'";
|
||||
i++;
|
||||
while (i < line.length) {
|
||||
if (line[i] === "'" && line[i + 1] === "'") {
|
||||
str += "''";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (line[i] === "'") {
|
||||
str += "'";
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
str += line[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ text: str, kind: "string" });
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(line[i])) {
|
||||
let num = "";
|
||||
while (i < line.length && /[0-9.]/.test(line[i])) {
|
||||
num += line[i];
|
||||
i++;
|
||||
}
|
||||
tokens.push({ text: num, kind: "number" });
|
||||
continue;
|
||||
}
|
||||
if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
|
||||
let op = line[i];
|
||||
i++;
|
||||
if (i < line.length) {
|
||||
const pair = op + line[i];
|
||||
if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
|
||||
op = pair;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ text: op, kind: "operator" });
|
||||
continue;
|
||||
}
|
||||
let word = "";
|
||||
while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
|
||||
word += line[i];
|
||||
i++;
|
||||
}
|
||||
if (word) {
|
||||
const upper = word.toUpperCase();
|
||||
if (SQL_KEYWORDS.has(upper)) {
|
||||
tokens.push({ text: word, kind: "keyword" });
|
||||
} else if (SQL_TYPES.has(upper)) {
|
||||
tokens.push({ text: word, kind: "type" });
|
||||
} else {
|
||||
tokens.push({ text: word, kind: "plain" });
|
||||
}
|
||||
} else {
|
||||
// Catch-all for any character not matched above (non-ASCII, symbols, etc.)
|
||||
tokens.push({ text: line[i], kind: "plain" });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function SyntaxCode({
|
||||
source,
|
||||
language: _language,
|
||||
}: {
|
||||
source: string;
|
||||
language?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLines = 60;
|
||||
|
||||
// Memoize the tokenized output — source doesn't change while viewing
|
||||
const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
|
||||
const lines: string[] = source.split("\n");
|
||||
const total: number = lines.length;
|
||||
const isTruncated: boolean = !expanded && total > maxLines;
|
||||
const display: string[] = isTruncated
|
||||
? lines.slice(0, maxLines)
|
||||
: lines;
|
||||
const maxNum: number = String(display.length).length;
|
||||
const tokenized = display.map((line: string) => ({
|
||||
tokens: tokenizeLine(line),
|
||||
}));
|
||||
return {
|
||||
displayLines: tokenized,
|
||||
maxLineNum: maxNum,
|
||||
truncated: isTruncated,
|
||||
totalLines: total,
|
||||
};
|
||||
}, [source, expanded]);
|
||||
|
||||
const TOKEN_COLORS: Record<string, string> = {
|
||||
keyword: "text-blue-400",
|
||||
type: "text-emerald-400",
|
||||
string: "text-amber-300",
|
||||
comment: "text-text-subtle italic",
|
||||
number: "text-purple-400",
|
||||
operator: "text-text-muted",
|
||||
plain: "text-text",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-x-auto overscroll-x-none">
|
||||
<pre className="text-xs leading-6 font-mono whitespace-pre w-max min-w-full">
|
||||
{displayLines.map(
|
||||
(entry: { tokens: Token[] }, i: number) => {
|
||||
const { tokens } = entry;
|
||||
const num = String(i + 1).padStart(maxLineNum, " ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex hover:bg-surface/30"
|
||||
>
|
||||
<span
|
||||
className="inline-block text-right select-none text-text-subtle border-r border-border pr-3 mx-3 shrink-0"
|
||||
style={{
|
||||
minWidth: `${maxLineNum + 2}ch`,
|
||||
}}
|
||||
>
|
||||
{num}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre">
|
||||
{tokens.length === 1 &&
|
||||
tokens[0].text.trim() === ""
|
||||
? "\u00A0"
|
||||
: tokens.map((t, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className={
|
||||
TOKEN_COLORS[t.kind]
|
||||
}
|
||||
>
|
||||
{t.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
{truncated && (
|
||||
<div className="flex items-center justify-center py-1.5 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Show all {totalLines} lines…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{expanded && totalLines > maxLines && (
|
||||
<div className="flex items-center justify-center py-1.5 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFunctionDetail(f: FunctionInfo) {
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Signature
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Returns
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{f.return_type || "void"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Language
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.language}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Kind
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.kind === "f" ? "Function" : "Procedure"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{f.schema}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{f.argument_names.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Arguments
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.argument_names.length} total
|
||||
</span>
|
||||
</div>
|
||||
{f.argument_names.map((name, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<div className="w-24 shrink-0">
|
||||
<span className="text-xs text-text-muted">
|
||||
{f.argument_modes?.[i] &&
|
||||
f.argument_modes[i] !==
|
||||
"IN" && (
|
||||
<span className="text-amber-400 font-medium mr-1">
|
||||
{f.argument_modes[i]}
|
||||
</span>
|
||||
)}
|
||||
#{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{name}
|
||||
</span>
|
||||
<span className="mx-2 text-border">:</span>
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{f.argument_types?.[i] || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{f.source && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Source
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.language}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode
|
||||
source={f.source}
|
||||
language={f.language}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(connectionId: string, type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
case "roles":
|
||||
return <RoleDetail connectionId={connectionId} item={item as RoleInfo} />;
|
||||
case "functions":
|
||||
return renderFunctionDetail(item as FunctionInfo);
|
||||
case "procedures":
|
||||
return renderFunctionDetail(item as FunctionInfo);
|
||||
case "indexes": {
|
||||
const idx = item as IndexInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Index
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{idx.table}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Method
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{idx.method}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Unique
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
|
||||
>
|
||||
{idx.is_unique ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Size
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{idx.size_bytes ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{idx.columns.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Columns
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{idx.columns.length}
|
||||
</span>
|
||||
</div>
|
||||
{idx.columns.map((col, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{idx.definition && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode source={idx.definition} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "constraints": {
|
||||
const c = item as ConstraintInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Constraint
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Type
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{c.contype}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{c.table}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Deferrable
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
|
||||
>
|
||||
{c.deferrable ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Validated
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
|
||||
>
|
||||
{c.validated ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.columns.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Columns
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{c.columns.length}
|
||||
</span>
|
||||
</div>
|
||||
{c.columns.map((col, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{c.definition && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode source={c.definition} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "triggers": {
|
||||
const t = item as TriggerInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{t.table_schema}.{t.table_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Event
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{t.event_manipulation}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Timing
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{t.action_timing} {t.action_orientation}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Status
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${t.enabled === "O" ? "text-emerald-400" : "text-red-400"}`}
|
||||
>
|
||||
{t.enabled === "O"
|
||||
? "Enabled"
|
||||
: t.enabled === "D"
|
||||
? "Disabled"
|
||||
: t.enabled}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{t.schema}
|
||||
</span>
|
||||
</div>
|
||||
{t.action_statement && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode source={t.action_statement} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "sequences": {
|
||||
const s = item as SequenceInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Sequence Values
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||
Current Value
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{s.current_value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||
Increment
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{s.increment}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||
Start
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{s.start_value}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||
Min / Max
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{s.min_value} / {s.max_value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">
|
||||
Cycle
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${s.cycle ? "text-amber-400" : "text-text-muted"}`}
|
||||
>
|
||||
{s.cycle ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "enums": {
|
||||
const e = item as EnumInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{e.schema}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Values
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{e.labels.length} labels
|
||||
</span>
|
||||
</div>
|
||||
{e.labels.map((label, i) => (
|
||||
<div
|
||||
key={label}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "extensions": {
|
||||
const e = item as ExtensionInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Extension
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Version
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{e.version}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-2 px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{e.schema}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{e.comment && (
|
||||
<div className="border-b border-border px-4 py-2.5">
|
||||
<span className="text-xs text-text-muted block mb-1">
|
||||
Comment
|
||||
</span>
|
||||
<p className="text-sm text-text leading-relaxed">
|
||||
{e.comment}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ObjectDetailProps {
|
||||
connectionId: string;
|
||||
type: ObjectType;
|
||||
item: AnyObject;
|
||||
}
|
||||
|
||||
export function ObjectDetail({ connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(connectionId, type, item)}</>;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ObjectFormTab } from "./ObjectFormTab";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
import {
|
||||
useDbViewerStore,
|
||||
type ViewerTab,
|
||||
} from "../../../stores/dbViewerStore";
|
||||
|
||||
vi.mock("../../../lib/objectCrud", () => ({
|
||||
buildObjectDdl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../editor/SqlEditorField", () => ({
|
||||
SqlEditorField: ({ value, readOnly }: { value: string; readOnly?: boolean }) => (
|
||||
<textarea data-testid="sql-editor" readOnly={readOnly} value={value} onChange={() => {}} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ObjectFormTab", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
});
|
||||
|
||||
const connectionId = "c1";
|
||||
const baseTab: ViewerTab = {
|
||||
id: "form-tab-1",
|
||||
schema: "public",
|
||||
table: "Create sequence",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: null,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: false,
|
||||
tabType: "objectForm",
|
||||
objectType: null,
|
||||
form: {
|
||||
kind: "sequence",
|
||||
title: "Create sequence",
|
||||
description: "Create my_seq",
|
||||
mode: "create",
|
||||
params: { schema: "public", name: "my_seq", action: { op: "create" } },
|
||||
},
|
||||
};
|
||||
|
||||
it("renders the form for a sequence kind in Visual view", () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||
]);
|
||||
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles to SQL and shows the generated SQL, then back to Visual", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||
]);
|
||||
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "SQL" }));
|
||||
const ed = await screen.findByTestId("sql-editor");
|
||||
expect(ed).toHaveValue('CREATE SEQUENCE "public"."my_seq" START WITH 1;');
|
||||
expect(ed).toHaveProperty("readOnly", true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Visual" }));
|
||||
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages one ddl change and closes the tab", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||
]);
|
||||
useDbViewerStore.setState({
|
||||
tabs: [baseTab],
|
||||
activeTabId: baseTab.id,
|
||||
});
|
||||
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||
|
||||
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(addChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "ddl",
|
||||
sql: 'CREATE SEQUENCE "public"."my_seq" START WITH 1;',
|
||||
description: "Create my_seq",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stages multiple statements with (n/total) descriptions", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
"DROP SEQUENCE \"public\".\"my_seq\";",
|
||||
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
|
||||
]);
|
||||
useDbViewerStore.setState({
|
||||
tabs: [baseTab],
|
||||
activeTabId: baseTab.id,
|
||||
});
|
||||
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||
|
||||
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addChange).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ description: "Create my_seq (1/2)" }),
|
||||
);
|
||||
expect(addChange).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ description: "Create my_seq (2/2)" }),
|
||||
);
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows a builder error and disables Stage", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockRejectedValueOnce(
|
||||
"Name is required",
|
||||
);
|
||||
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Name is required/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /stage/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders only the uppercase kicker, not a duplicate title heading", () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([]);
|
||||
render(
|
||||
<ObjectFormTab
|
||||
connectionId={connectionId}
|
||||
tab={{
|
||||
...baseTab,
|
||||
table: "Create Function",
|
||||
form: {
|
||||
...baseTab.form!,
|
||||
kind: "function",
|
||||
title: "Create Function",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// Kicker: lowercase DOM text uppercased by CSS
|
||||
expect(screen.getByText("create function")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Create Function")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { KindForm } from "./KindForm";
|
||||
import { TableForm } from "./TableForm";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
type DdlParams,
|
||||
} from "../../../lib/objectCrud";
|
||||
import { useDbViewerStore, type ViewerTab } from "../../../stores/dbViewerStore";
|
||||
|
||||
const SqlEditorField = lazy(() =>
|
||||
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
tab: ViewerTab;
|
||||
}
|
||||
|
||||
export function ObjectFormTab({ connectionId, tab }: Props) {
|
||||
const form = tab.form;
|
||||
if (!form) return null;
|
||||
|
||||
if (form.kind === "table") {
|
||||
return <TableForm connectionId={connectionId} tab={tab} />;
|
||||
}
|
||||
|
||||
const { kind, params, description, mode } = form;
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const open = true;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
buildObjectDdl(connectionId, kind, params)
|
||||
.then((sqls) => {
|
||||
if (active) {
|
||||
setPreview(sqls.join("\n;\n"));
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) {
|
||||
setPreview("");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, connectionId, kind, params]);
|
||||
|
||||
const stage = async () => {
|
||||
const sqls = await buildObjectDdl(connectionId, kind, params);
|
||||
sqls.forEach((sql, i) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description:
|
||||
sqls.length > 1
|
||||
? `${description} (${i + 1}/${sqls.length})`
|
||||
: description,
|
||||
}),
|
||||
);
|
||||
useDbViewerStore.getState().closeTab(tab.id);
|
||||
};
|
||||
|
||||
const schemas = useDbViewerStore((s) => s.schemas);
|
||||
|
||||
const handleChange = (next: DdlParams) => {
|
||||
useDbViewerStore.getState().updateFormTabParams(tab.id, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-transparent">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{mode} {kind}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Visual"
|
||||
onClick={() => setView("visual")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "visual"
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="SQL"
|
||||
onClick={() => setView("sql")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "sql"
|
||||
? "bg-surface-raised text-text"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={stage}
|
||||
disabled={!!error}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Stage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{view === "visual" ? (
|
||||
<KindForm
|
||||
connectionId={connectionId}
|
||||
kind={kind}
|
||||
params={params}
|
||||
schemas={schemas}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-4 py-3">
|
||||
<Suspense
|
||||
fallback={
|
||||
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">
|
||||
{preview}
|
||||
</pre>
|
||||
}
|
||||
>
|
||||
<SqlEditorField
|
||||
value={preview}
|
||||
onChange={() => {}}
|
||||
readOnly
|
||||
height={420}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
<p className="text-xs text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
});
|
||||
|
||||
const baseRole: RoleInfo = {
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
};
|
||||
|
||||
describe("RoleDetail", () => {
|
||||
it("renders role attributes + memberships + grants editor", async () => {
|
||||
render(<RoleDetail connectionId="c1" item={baseRole} />);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
expect(screen.getByText("Can login")).toBeInTheDocument();
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders memberships when present", () => {
|
||||
render(
|
||||
<RoleDetail
|
||||
connectionId="c1"
|
||||
item={{
|
||||
...baseRole,
|
||||
memberships: [{ role: "app", member: "other", grantor: "postgres", admin_option: true }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("other")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormSectionHeader } from "./formRow";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
item: RoleInfo;
|
||||
}
|
||||
|
||||
interface AttributeRow {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function RoleDetail({ connectionId, item }: Props) {
|
||||
const attributes: AttributeRow[] = [
|
||||
{ label: "Can login", value: item.can_login ? "Yes" : "No", highlight: item.can_login },
|
||||
{ label: "Superuser", value: item.superuser ? "Yes" : "No", highlight: item.superuser },
|
||||
{ label: "Create databases", value: item.create_db ? "Yes" : "No", highlight: item.create_db },
|
||||
{ label: "Create roles", value: item.create_role ? "Yes" : "No", highlight: item.create_role },
|
||||
{ label: "Inherit privileges", value: item.inherit ? "Yes" : "No", highlight: item.inherit },
|
||||
{ label: "Replication", value: item.replication ? "Yes" : "No", highlight: item.replication },
|
||||
{ label: "Bypass RLS", value: item.bypass_rls ? "Yes" : "No", highlight: item.bypass_rls },
|
||||
{ label: "Connection limit", value: String(item.connection_limit) },
|
||||
{ label: "Valid until", value: item.valid_until ?? "Never" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Role</span>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">Name</span>
|
||||
<span className="text-sm text-accent font-mono">{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Attributes</span>
|
||||
</div>
|
||||
{attributes.map((attr) => (
|
||||
<div key={attr.label} className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
|
||||
<span className="text-xs text-text-muted">{attr.label}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-1">
|
||||
<span className={`text-sm ${attr.highlight ? "text-emerald-400" : "text-text-muted"}`}>
|
||||
{attr.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={item.memberships.length} />
|
||||
{item.memberships.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">Not a member of any role.</p>
|
||||
)}
|
||||
{item.memberships.map((m, i) => (
|
||||
<div key={`${m.role}-${m.member}-${i}`} className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">Role</span>
|
||||
<span className="text-sm text-accent font-mono">{m.member}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">of {m.role}</span>
|
||||
{m.admin_option && (
|
||||
<span className="ml-2 text-[10px] text-amber-400 uppercase tracking-wider">admin</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RoleGrantsEditor connectionId={connectionId} role={item.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleForm } from "./RoleForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["CREATE ROLE app;"]);
|
||||
vi.spyOn(cmd, "getRoles").mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "",
|
||||
name: mode === "edit" ? "app" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
login: mode === "edit",
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("RoleForm", () => {
|
||||
it("create role with options writes params", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={baseParams("create")}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("Role name"), { target: { value: "app" } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "app" }));
|
||||
|
||||
fireEvent.click(screen.getByLabelText("LOGIN"));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ login: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("edit mode password field is blank with keep hint", () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
mode="edit"
|
||||
params={baseParams("edit")}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/leave blank to keep/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages a create role ddl via the queue", async () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "",
|
||||
name: "app",
|
||||
action: {
|
||||
op: "create",
|
||||
login: true,
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "secret",
|
||||
members: [],
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("CREATE ROLE app;");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface RoleMember {
|
||||
name: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
interface RoleAction {
|
||||
op: "create" | "edit" | "drop";
|
||||
login: boolean;
|
||||
superuser: boolean;
|
||||
createdb: boolean;
|
||||
createrole: boolean;
|
||||
inherit: boolean;
|
||||
replication: boolean;
|
||||
bypassrls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string;
|
||||
password: string;
|
||||
members: RoleMember[];
|
||||
}
|
||||
|
||||
interface RoleParams {
|
||||
schema: string;
|
||||
name: string;
|
||||
action: RoleAction;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
mode?: "create" | "edit";
|
||||
params: Record<string, unknown>;
|
||||
onChange: (p: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { key: keyof Omit<RoleAction, "op" | "connection_limit" | "valid_until" | "password" | "members">; label: string }[] = [
|
||||
{ key: "login", label: "LOGIN" },
|
||||
{ key: "superuser", label: "SUPERUSER" },
|
||||
{ key: "createdb", label: "CREATEDB" },
|
||||
{ key: "createrole", label: "CREATEROLE" },
|
||||
{ key: "inherit", label: "INHERIT" },
|
||||
{ key: "replication", label: "REPLICATION" },
|
||||
{ key: "bypassrls", label: "BYPASSRLS" },
|
||||
];
|
||||
|
||||
function patchAction(params: RoleParams, patch: Partial<RoleAction>): RoleParams {
|
||||
return { ...params, action: { ...params.action, ...patch } };
|
||||
}
|
||||
|
||||
export function RoleForm({ connectionId, mode = "create", params, onChange }: Props) {
|
||||
const typed = params as unknown as RoleParams;
|
||||
const action = typed.action;
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
|
||||
const setParams = (next: RoleParams) => onChange(next as unknown as Record<string, unknown>);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
cmd
|
||||
.getRoles(connectionId)
|
||||
.then((list) => {
|
||||
if (active) setRoles(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setRoles([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setError(null);
|
||||
cmd
|
||||
.buildObjectDdl(connectionId, "role", params)
|
||||
.then((sqls) => {
|
||||
if (active) setPreview(Array.isArray(sqls) ? sqls.join("\n;\n") : (sqls as string));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) {
|
||||
setPreview("");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, params]);
|
||||
|
||||
const availableRoles = useMemo(
|
||||
() => roles.filter((r) => r.name !== typed.name),
|
||||
[roles, typed.name],
|
||||
);
|
||||
|
||||
const toggleMember = (name: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? [...action.members, { name, admin: false }]
|
||||
: action.members.filter((m) => m.name !== name);
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const setMemberAdmin = (name: string, admin: boolean) => {
|
||||
const next = action.members.map((m) => (m.name === name ? { ...m, admin } : m));
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const stage = async () => {
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", params);
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${mode === "edit" ? "Edit" : "Create"} role ${typed.name}`,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-transparent">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{mode} role
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Visual"
|
||||
onClick={() => setView("visual")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "visual" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="SQL"
|
||||
onClick={() => setView("sql")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "sql" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={stage}
|
||||
disabled={!!error || staging || !typed.name.trim()}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Stage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-3">
|
||||
{view === "visual" ? (
|
||||
<>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Role name"
|
||||
value={typed.name}
|
||||
onChange={(e) => setParams({ ...typed, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Options" />
|
||||
<div className="px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{OPTIONS.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs text-text cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={!!action[key]}
|
||||
onChange={(e) =>
|
||||
setParams(patchAction(typed, { [key]: e.target.checked } as Partial<RoleAction>))
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormRow label="Connection limit">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="-1 for unlimited"
|
||||
value={action.connection_limit}
|
||||
onChange={(e) =>
|
||||
setParams(
|
||||
patchAction(typed, {
|
||||
connection_limit: e.target.value === "" ? -1 : Number(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Valid until">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={action.valid_until}
|
||||
onChange={(e) => setParams(patchAction(typed, { valid_until: e.target.value }))}
|
||||
className={controlClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Password">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "leave blank to keep current password"
|
||||
: "Password"
|
||||
}
|
||||
value={action.password}
|
||||
onChange={(e) => setParams(patchAction(typed, { password: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={action.members.length} />
|
||||
{availableRoles.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No other roles available.</p>
|
||||
)}
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
{availableRoles.map((r) => {
|
||||
const member = action.members.find((m) => m.name === r.name);
|
||||
return (
|
||||
<div key={r.name} className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!member}
|
||||
onChange={(e) => toggleMember(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="font-mono">{r.name}</span>
|
||||
</label>
|
||||
{member && (
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.admin}
|
||||
onChange={(e) => setMemberAdmin(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
ADMIN
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">{preview}</pre>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["GRANT SELECT ON TABLE public.users TO app;"]);
|
||||
});
|
||||
|
||||
describe("RoleGrantsEditor", () => {
|
||||
it("toggling a privilege stages a GRANT ddl change", async () => {
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Object class"), { target: { value: "table" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Schema"), { target: { value: "public" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Object name"), { target: { value: "users" } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("SELECT"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /grant/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q.some((c) => c.type === "ddl" && c.sql.startsWith("GRANT"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows first 5 privileges while collapsed, expands to show all", async () => {
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Fewer than 5 entries — visible immediately without expanding.
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("SELECT")).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("truncates long sections to 5 entries with a fade and Show more", async () => {
|
||||
const entries: PrivilegeEntry[] = Array.from({ length: 7 }, (_, i) => ({
|
||||
object_class: "table",
|
||||
schema: "public",
|
||||
name: `t${i + 1}`,
|
||||
privileges: ["SELECT"],
|
||||
grantable: false,
|
||||
}));
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue(entries);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Collapsed by default: first 5 visible, rest hidden.
|
||||
expect(await screen.findByText("public.t1")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show 2 more/i }));
|
||||
expect(await screen.findByText("public.t6")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t7")).toBeInTheDocument();
|
||||
// Header chevron can collapse it back.
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle table privileges/i }));
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
type ObjectClass = "table" | "sequence" | "routine" | "schema" | "database";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const CLASS_LABELS: Record<ObjectClass, string> = {
|
||||
table: "Table",
|
||||
sequence: "Sequence",
|
||||
routine: "Routine",
|
||||
schema: "Schema",
|
||||
database: "Database",
|
||||
};
|
||||
|
||||
const PRIVILEGES: Record<ObjectClass, string[]> = {
|
||||
table: ["SELECT", "INSERT", "UPDATE", "TRUNCATE", "REFERENCES", "TRIGGER"],
|
||||
sequence: ["USAGE", "SELECT", "UPDATE"],
|
||||
routine: ["EXECUTE"],
|
||||
schema: ["USAGE", "CREATE"],
|
||||
database: ["CONNECT", "CREATE", "TEMP"],
|
||||
};
|
||||
|
||||
export function RoleGrantsEditor({ connectionId, role }: Props) {
|
||||
const [privileges, setPrivileges] = useState<PrivilegeEntry[]>([]);
|
||||
const [objectClass, setObjectClass] = useState<ObjectClass>("table");
|
||||
const [objectSchema, setObjectSchema] = useState("");
|
||||
const [objectName, setObjectName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [grantOption, setGrantOption] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getRolePrivileges(connectionId, role)
|
||||
.then((list) => {
|
||||
if (active) {
|
||||
setPrivileges(Array.isArray(list) ? list : []);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, role]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ObjectClass, PrivilegeEntry[]>();
|
||||
privileges.forEach((p) => {
|
||||
const list = map.get(p.object_class) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.object_class, list);
|
||||
});
|
||||
return map;
|
||||
}, [privileges]);
|
||||
|
||||
const togglePrivilege = (priv: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(priv)) next.delete(priv);
|
||||
else next.add(priv);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const submit = async (op: "grant" | "revoke") => {
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", {
|
||||
schema: "",
|
||||
name: "",
|
||||
action: {
|
||||
op,
|
||||
object_class: objectClass,
|
||||
object_schema: objectSchema || null,
|
||||
object_name: objectName,
|
||||
privileges: Array.from(selected),
|
||||
grantee: role,
|
||||
grant_option: grantOption,
|
||||
},
|
||||
});
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${op.toUpperCase()} ${Array.from(selected).join(",")} on ${objectClass} ${objectSchema ? `${objectSchema}.` : ""}${objectName} to ${role}`,
|
||||
}),
|
||||
);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FormSectionHeader label="Current privileges" count={privileges.length} />
|
||||
{loading && <p className="px-4 py-2 text-xs text-text-muted">Loading...</p>}
|
||||
{grouped.size === 0 && !loading && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No privileges found for this role.</p>
|
||||
)}
|
||||
{Array.from(grouped.entries()).map(([cls, list]) => {
|
||||
const isExpanded = !!expanded[cls];
|
||||
const hasMore = list.length > PREVIEW_COUNT;
|
||||
const visible = !isExpanded && hasMore ? list.slice(0, PREVIEW_COUNT) : list;
|
||||
return (
|
||||
<div key={cls} className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${CLASS_LABELS[cls]} privileges`}
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: !e[cls] }))}
|
||||
className="w-full px-4 py-1 flex items-center justify-between hover:bg-surface-raised/40 cursor-pointer"
|
||||
>
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{CLASS_LABELS[cls]}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-text-muted tabular-nums">
|
||||
{list.length}
|
||||
</span>
|
||||
{hasMore && (
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 text-text-muted transition-transform duration-150 ${
|
||||
isExpanded ? "-rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative overflow-hidden">
|
||||
{visible.map((entry) => (
|
||||
<div
|
||||
key={`${entry.object_class}:${entry.schema ?? ""}:${entry.name}`}
|
||||
className="px-4 py-1.5 flex items-center gap-2 text-xs text-text"
|
||||
>
|
||||
<span className="font-mono text-accent">
|
||||
{entry.schema ? `${entry.schema}.` : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
{entry.privileges.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!isExpanded && hasMore && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-canvas to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: true }))}
|
||||
className="px-4 pb-2 text-[11px] text-accent hover:text-accent-hover cursor-pointer"
|
||||
>
|
||||
Show {list.length - PREVIEW_COUNT} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FormSectionHeader label="Edit privileges" />
|
||||
|
||||
<FormRow label="Object class">
|
||||
<select
|
||||
aria-label="Object class"
|
||||
value={objectClass}
|
||||
onChange={(e) => {
|
||||
setObjectClass(e.target.value as ObjectClass);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(CLASS_LABELS) as ObjectClass[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CLASS_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Schema">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={objectSchema}
|
||||
onChange={(e) => setObjectSchema(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Object name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Object name"
|
||||
value={objectName}
|
||||
onChange={(e) => setObjectName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Privileges">
|
||||
<div className="flex-1 px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{PRIVILEGES[objectClass].map((priv) => (
|
||||
<label key={priv} className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={priv}
|
||||
checked={selected.has(priv)}
|
||||
onChange={() => togglePrivilege(priv)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{priv}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Grant option">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={grantOption}
|
||||
onChange={(e) => setGrantOption(e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>WITH GRANT OPTION</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("grant")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Grant
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("revoke")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
className="rounded-lg bg-surface px-3 py-1.5 text-xs font-medium text-text border border-border hover:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { SequenceForm } from "./SequenceForm";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
|
||||
function StatefulSequenceForm({ initialParams }: { initialParams: DdlParams }) {
|
||||
const [params, setParams] = useState(initialParams);
|
||||
return <SequenceForm params={params} onChange={setParams} />;
|
||||
}
|
||||
|
||||
describe("SequenceForm", () => {
|
||||
it("renders create fields and emits params on change", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SequenceForm
|
||||
params={{
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: "1",
|
||||
min_value: "1",
|
||||
max_value: "9",
|
||||
start: "1",
|
||||
cycle: false,
|
||||
},
|
||||
}}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText("Sequence name")).toHaveValue("s");
|
||||
fireEvent.change(screen.getByPlaceholderText("Sequence name"), {
|
||||
target: { value: "s2" },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "s2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a schema dropdown when schemas are provided", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SequenceForm
|
||||
params={{ schema: "public", name: "s", action: { op: "create" } }}
|
||||
schemas={["public", "utils"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("Schema");
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "public" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: "utils" } });
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ schema: "utils" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("the editing outline wraps only the value area, not the label cell", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<SequenceForm
|
||||
params={{ schema: "public", name: "s", action: { op: "create" } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The children wrapper (direct parent of the input) carries the amber
|
||||
// focus-within editing outline, exactly like a grid editing cell.
|
||||
const input = screen.getByPlaceholderText("Sequence name");
|
||||
const valueArea = input.parentElement;
|
||||
expect(valueArea).not.toBeNull();
|
||||
expect(valueArea!.className).toContain("focus-within:outline");
|
||||
expect(valueArea!.className).toContain("focus-within:outline-amber-400/20");
|
||||
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
|
||||
|
||||
// The label cell must stay clean: no ancestor of the label may carry
|
||||
// the editing outline (regression: the old row-level outline lit up the
|
||||
// whole row, label cell included).
|
||||
const label = screen.getByText("Name");
|
||||
expect(label.closest('[class*="focus-within:outline"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("switching to restart shows only the with-field", () => {
|
||||
render(
|
||||
<StatefulSequenceForm
|
||||
initialParams={{
|
||||
schema: "public",
|
||||
name: "s",
|
||||
action: {
|
||||
op: "create",
|
||||
increment: "1",
|
||||
min_value: "1",
|
||||
max_value: "9",
|
||||
start: "1",
|
||||
cycle: false,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const opSelect = screen.getByLabelText("Operation");
|
||||
fireEvent.change(opSelect, { target: { value: "restart" } });
|
||||
expect(screen.getByPlaceholderText("Restart with")).toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Increment")).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText("Start")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("CYCLE")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type SequenceOp = "create" | "alter" | "restart";
|
||||
|
||||
const OP_LABELS: Record<SequenceOp, string> = {
|
||||
create: "Create",
|
||||
alter: "Alter",
|
||||
restart: "Restart",
|
||||
};
|
||||
|
||||
function getOp(params: DdlParams): SequenceOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const op = action.op;
|
||||
if (op === "alter" || op === "restart") return op;
|
||||
return "create";
|
||||
}
|
||||
|
||||
function patchAction(
|
||||
params: DdlParams,
|
||||
patch: Record<string, unknown>,
|
||||
): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
export function SequenceForm({ params, schemas, onChange }: Props) {
|
||||
const op = getOp(params);
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Sequence name"
|
||||
value={(params.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { op: e.target.value }))
|
||||
}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(OP_LABELS) as SequenceOp[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{OP_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{(op === "create" || op === "alter") && (
|
||||
<>
|
||||
<FormRow label="Increment">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Increment"
|
||||
value={(action.increment as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { increment: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Min value">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Min value"
|
||||
value={(action.min_value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { min_value: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Max value">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Max value"
|
||||
value={(action.max_value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { max_value: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
{op === "create" && (
|
||||
<FormRow label="Start">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Start"
|
||||
value={(action.start as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { start: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
<FormRow label="Cycle">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.cycle}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { cycle: e.target.checked }))
|
||||
}
|
||||
aria-label="CYCLE"
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>CYCLE</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{op === "restart" && (
|
||||
<FormRow label="Restart with">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Restart with"
|
||||
value={(action.with as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange(patchAction(params, { with: e.target.value }))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TableForm } from "./TableForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import { useConnectionStore } from "../../../stores/connectionStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildRebuildScript").mockReset().mockResolvedValue("");
|
||||
vi.spyOn(cmd, "getTableRebuildReadiness").mockReset().mockResolvedValue({ ok: true, reasons: [] });
|
||||
vi.spyOn(cmd, "getTableColumns").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getTablespaces").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getConstraints").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "public",
|
||||
name: mode === "edit" ? "users" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: [],
|
||||
old_columns:
|
||||
mode === "edit"
|
||||
? [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }]
|
||||
: undefined,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function seedFormTab(tab: any) {
|
||||
useDbViewerStore.setState({ tabs: [{ ...tab, tabType: tab.tabType ?? "objectForm" }], activeTabId: tab.id });
|
||||
}
|
||||
|
||||
describe("TableForm", () => {
|
||||
it("add/remove rows; composite PK allowed", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: baseParams("create"),
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
const cols = () => (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
expect(cols()).toHaveLength(2);
|
||||
// the first added column auto-starts as the PK
|
||||
expect(cols()[0].is_pk).toBe(true);
|
||||
// marking a second column as PK keeps both (composite key)
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("PK"));
|
||||
expect(cols().filter((c: any) => c.is_pk)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reorder triggers rebuild path and readiness refusal blocks staging", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: false, reasons: ["table has triggers"] });
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
// order differs => rebuild; readiness not ok => refusal copy + Stage disabled
|
||||
expect(await screen.findByText(/has triggers/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stage" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("rebuild ok stages a single rebuild_table change", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: true, reasons: [] });
|
||||
(cmd.buildRebuildScript as any).mockResolvedValue("CREATE TABLE _t();");
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/Rebuild/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("rebuild_table");
|
||||
expect(q[0].sql).toBe("CREATE TABLE _t();");
|
||||
});
|
||||
|
||||
it("edit diff (no reorder) stages ddl per statement after stale guard passes", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
]);
|
||||
(cmd.buildObjectDdl as any).mockResolvedValue([
|
||||
'ALTER TABLE "public"."users" ADD COLUMN "email" text',
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/committed/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
});
|
||||
|
||||
it("stale guard blocks staging when live columns differ", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
name: "id",
|
||||
data_type: "bigint",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
expect(await screen.findByText(/changed since you opened/i)).toBeInTheDocument();
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("create-mode grid renders header and cog settings menus", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const headers = screen.getAllByText("Name");
|
||||
expect(headers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parameters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default Value")).toBeInTheDocument();
|
||||
// first row (id, integer): Auto-Increment is available
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Auto-Increment")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]); // close
|
||||
// second row (sku, text): no Auto-Increment
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Auto-Increment")).toBeNull();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds auto_increment integer to serial in the DDL payload", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Column settings"));
|
||||
fireEvent.click(screen.getByLabelText("Auto-Increment"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[0].auto_increment).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Column settings")); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.type === "serial"));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "id", type: "serial", nullable: false, is_pk: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("folds unique to the DDL payload column", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: false, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("Unique"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[1].unique).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.name === "sku" && c.unique === true));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "sku", unique: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("schema select auto-selects the current schema in create mode", () => {
|
||||
useDbViewerStore.setState({ schemas: ["public", "audit"] });
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "audit",
|
||||
name: "",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const sel = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
expect(sel.value).toBe("audit");
|
||||
fireEvent.change(sel, { target: { value: "public" } });
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("public");
|
||||
});
|
||||
|
||||
it("staging an FK applies the referenced type to the local column", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue(["ALTER TABLE \"public\".\"products\" ADD FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\")"]);
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Set foreign key")); // category_id (id is PK → no FK icon)
|
||||
await screen.findByText("Foreign key");
|
||||
const refColSel = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refColSel.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
const addFkButtons = screen.getAllByRole("button", { name: "Add FK" });
|
||||
fireEvent.click(addFkButtons[addFkButtons.length - 1]);
|
||||
await waitFor(() => {
|
||||
const cols = (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
const col = cols.find((c: any) => c.name === "category_id");
|
||||
expect(col.type).toBe("int");
|
||||
expect(col.fk).toBe(true);
|
||||
});
|
||||
// the FK is staged inline into the CREATE TABLE (single change), not as a separate ALTER
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => {
|
||||
const stageCall = (cmd.buildObjectDdl as any).mock.calls.find((call: any) =>
|
||||
call[1] === "table" &&
|
||||
call[2]?.action?.foreign_keys?.some((fk: any) => fk.columns[0] === "category_id"),
|
||||
);
|
||||
expect(stageCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows staged FK changes in the Foreign keys section", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql: 'ALTER TABLE "public"."products" ADD FOREIGN KEY ("category_id") REFERENCES "public"."categories" ("id") ON DELETE CASCADE',
|
||||
description: "Add FK category_id → public.categories",
|
||||
});
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
expect(await screen.findByText(/Foreign key relation to/)).toBeInTheDocument();
|
||||
expect(screen.getByText("public.categories")).toBeInTheDocument();
|
||||
expect(screen.getByText(/products.category_id → categories.id/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/· CASCADE/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the FK panel with the column preselected", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "category_id", data_type: "int", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null }] },
|
||||
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const fkButtons = screen.getAllByLabelText("Set foreign key");
|
||||
// id is the PK column → its FK icon is hidden; category_id is the only one
|
||||
expect(fkButtons).toHaveLength(1);
|
||||
fireEvent.click(fkButtons[0]);
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
const local = (await screen.findByLabelText("Local column 1")) as HTMLSelectElement;
|
||||
expect(local.value).toBe("category_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TableForm SQLite mode", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({
|
||||
connections: [
|
||||
{
|
||||
id: "c1",
|
||||
db_type: "sqlite",
|
||||
name: "SQLite",
|
||||
host: "",
|
||||
port: null,
|
||||
username: null,
|
||||
database: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
favorite: false,
|
||||
} as any,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useConnectionStore.setState({ connections: [] });
|
||||
});
|
||||
|
||||
it("shows SQLite types in the dropdown and not serial", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "main",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const typeSel = screen.getByLabelText("type") as HTMLSelectElement;
|
||||
const values = Array.from(typeSel.options).map((o) => o.value);
|
||||
expect(values).toContain("integer");
|
||||
expect(values).toContain("text");
|
||||
expect(values).toContain("real");
|
||||
expect(values).toContain("blob");
|
||||
expect(values).not.toContain("serial");
|
||||
});
|
||||
|
||||
it("shows INTEGER PRIMARY KEY AUTOINCREMENT in the SQL preview", async () => {
|
||||
(cmd.buildObjectDdl as any).mockResolvedValue([
|
||||
'CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)',
|
||||
]);
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "main",
|
||||
name: "t",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Column settings"));
|
||||
fireEvent.click(screen.getByLabelText("Auto-Increment"));
|
||||
fireEvent.click(screen.getByLabelText("SQL"));
|
||||
expect(
|
||||
await screen.findByText('CREATE TABLE "main"."t" ("id" INTEGER PRIMARY KEY AUTOINCREMENT)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fixes schema to main and hides the schema picker", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "t",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
expect(screen.queryByLabelText("Schema")).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("main");
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TriggerForm } from "./TriggerForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
|
||||
vi.mock("../../../lib/commands", () => ({ getFunctions: vi.fn() }));
|
||||
|
||||
const baseParams: DdlParams = {
|
||||
schema: "public",
|
||||
name: "tr",
|
||||
action: {
|
||||
op: "create",
|
||||
table: "orders",
|
||||
timing: "BEFORE",
|
||||
events: ["INSERT"],
|
||||
orientation: "ROW",
|
||||
function_schema: "public",
|
||||
function_name: "",
|
||||
function_args: [],
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("TriggerForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists only trigger-returning functions", async () => {
|
||||
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
name: "audit_fn",
|
||||
schema: "public",
|
||||
return_type: "trigger",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: null,
|
||||
kind: "f",
|
||||
},
|
||||
{
|
||||
name: "not_a_trigger",
|
||||
schema: "public",
|
||||
return_type: "void",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: null,
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TriggerForm
|
||||
connectionId="c1"
|
||||
params={baseParams}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("audit_fn")).toBeInTheDocument();
|
||||
expect(screen.queryByText("not_a_trigger")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits timing + events", async () => {
|
||||
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TriggerForm connectionId="c1" params={baseParams} onChange={onChange} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(cmd.getFunctions).toHaveBeenCalledWith("c1", "public"));
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue("BEFORE"), {
|
||||
target: { value: "AFTER" },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ timing: "AFTER" }),
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("UPDATE"));
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ events: ["INSERT", "UPDATE"] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getFunctions } from "../../../lib/commands";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import type { FunctionInfo } from "../../../lib/types";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type TriggerOp = "create" | "enable" | "disable";
|
||||
|
||||
const TIMINGS = ["BEFORE", "AFTER", "INSTEAD OF"];
|
||||
const EVENTS = ["INSERT", "UPDATE", "DELETE", "TRUNCATE"];
|
||||
const ORIENT = ["ROW", "STATEMENT"];
|
||||
|
||||
function getOp(params: DdlParams): TriggerOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const op = action.op as string;
|
||||
if (op === "enable" || op === "disable") return op;
|
||||
return "create";
|
||||
}
|
||||
|
||||
function patchAction(
|
||||
params: DdlParams,
|
||||
patch: Record<string, unknown>,
|
||||
): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
export function TriggerForm({ connectionId, params, schemas, onChange }: Props) {
|
||||
const [fns, setFns] = useState<FunctionInfo[]>([]);
|
||||
const op = getOp(params);
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const events = (action.events as string[]) ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getFunctions(connectionId, (params.schema as string) || undefined)
|
||||
.then((all) => {
|
||||
if (!cancelled) {
|
||||
setFns(all.filter((f) => f.return_type === "trigger"));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFns([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectionId, params.schema]);
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) =>
|
||||
onChange(patchAction(params, patch));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Trigger name"
|
||||
value={(params.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) =>
|
||||
onChange({ ...params, action: { op: e.target.value } })
|
||||
}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="create">Create</option>
|
||||
<option value="enable">Enable</option>
|
||||
<option value="disable">Disable</option>
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
{op === "create" && (
|
||||
<>
|
||||
<FormRow label="Table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Table"
|
||||
value={(action.table as string) ?? ""}
|
||||
onChange={(e) => setAction({ table: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Timing">
|
||||
<select
|
||||
aria-label="Timing"
|
||||
value={(action.timing as string) ?? "BEFORE"}
|
||||
onChange={(e) => setAction({ timing: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{TIMINGS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Events" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-wrap gap-1 px-4 py-2">
|
||||
{EVENTS.map((ev) => (
|
||||
<button
|
||||
type="button"
|
||||
key={ev}
|
||||
onClick={() =>
|
||||
setAction({
|
||||
events: events.includes(ev)
|
||||
? events.filter((x) => x !== ev)
|
||||
: [...events, ev],
|
||||
})
|
||||
}
|
||||
className={`text-xs px-2 py-1 rounded border transition-colors ${
|
||||
events.includes(ev)
|
||||
? "bg-accent text-white border-accent"
|
||||
: "border-border text-text hover:border-text-muted"
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="Orientation">
|
||||
<select
|
||||
aria-label="Orientation"
|
||||
value={(action.orientation as string) ?? "ROW"}
|
||||
onChange={(e) => setAction({ orientation: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{ORIENT.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
FOR EACH {o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Function">
|
||||
<select
|
||||
aria-label="Function"
|
||||
value={(action.function_name as string) ?? ""}
|
||||
onChange={(e) => {
|
||||
const f = fns.find((fn) => fn.name === e.target.value);
|
||||
setAction({
|
||||
function_name: e.target.value,
|
||||
function_schema: f?.schema ?? (params.schema as string),
|
||||
});
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="">(trigger function)</option>
|
||||
{fns.map((f) => (
|
||||
<option key={`${f.schema}.${f.name}`} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Function args">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Function args (comma-separated)"
|
||||
value={(action.function_args as string[] | undefined)?.join(", ") ?? ""}
|
||||
onChange={(e) =>
|
||||
setAction({
|
||||
function_args: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="When">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WHEN (optional)"
|
||||
value={(action.when as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
setAction({ when: e.target.value || null })
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(op === "enable" || op === "disable") && (
|
||||
<FormRow label="Table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Table"
|
||||
value={(action.table as string) ?? ""}
|
||||
onChange={(e) => setAction({ table: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ViewForm } from "./ViewForm";
|
||||
|
||||
vi.mock("../../editor/SqlEditorField", () => ({
|
||||
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ViewForm", () => {
|
||||
it("create: emits the definition", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ViewForm
|
||||
params={{ schema: "public", name: "v", materialized: false, action: { op: "create", definition: "SELECT 1" } }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(await screen.findByTestId("sql-editor"), { target: { value: "SELECT 2" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ definition: "SELECT 2" }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("matview replace shows a note about drop+create", () => {
|
||||
render(
|
||||
<ViewForm
|
||||
params={{ schema: "public", name: "mv", materialized: true, action: { op: "replace", definition: "SELECT 1" } }}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/drop and recreate/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { DdlParams } from "../../../lib/objectCrud";
|
||||
import { FormRow, inputClass, controlClass } from "./formRow";
|
||||
|
||||
const SqlEditorField = lazy(() =>
|
||||
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
params: DdlParams;
|
||||
schemas?: string[];
|
||||
onChange: (p: DdlParams) => void;
|
||||
}
|
||||
|
||||
type ViewOp = "create" | "replace";
|
||||
|
||||
function getOp(params: DdlParams): ViewOp {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
const op = action.op;
|
||||
if (op === "replace") return "replace";
|
||||
return "create";
|
||||
}
|
||||
|
||||
function patchAction(
|
||||
params: DdlParams,
|
||||
patch: Record<string, unknown>,
|
||||
): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
export function ViewForm({ params, schemas, onChange }: Props) {
|
||||
const op = getOp(params);
|
||||
const isMat = !!params.materialized;
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormRow label="Schema">
|
||||
{schemas && schemas.length > 0 ? (
|
||||
<select
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
aria-label="Schema"
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>Schema</option>
|
||||
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={(params.schema as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, schema: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={isMat ? "Materialized view name" : "View name"}
|
||||
value={(params.name as string) ?? ""}
|
||||
onChange={(e) => onChange({ ...params, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow label="Operation">
|
||||
<select
|
||||
aria-label="Operation"
|
||||
value={op}
|
||||
onChange={(e) => onChange(patchAction(params, { op: e.target.value }))}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="create">{isMat ? "Create" : "Create or replace"}</option>
|
||||
{isMat && <option value="replace">Replace (drop + create)</option>}
|
||||
</select>
|
||||
</FormRow>
|
||||
{isMat && op === "replace" && (
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<p className="text-xs text-text-muted">
|
||||
Materialized views cannot be CREATE OR REPLACE — this will drop and recreate.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FormRow label={isMat ? "Definition" : "Body"} className="items-stretch" outline={false}>
|
||||
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<textarea
|
||||
rows={6}
|
||||
value={(action.definition as string) ?? ""}
|
||||
onChange={(e) => onChange(patchAction(params, { definition: e.target.value }))}
|
||||
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full font-mono">
|
||||
<SqlEditorField
|
||||
value={(action.definition as string) ?? ""}
|
||||
onChange={(v) => onChange(patchAction(params, { definition: v }))}
|
||||
height={140}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
</div>
|
||||
</FormRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const inputClass =
|
||||
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||
export const controlClass =
|
||||
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
|
||||
export const monoInputClass =
|
||||
"min-w-0 flex-1 bg-transparent px-3 font-mono text-xs text-text outline-none placeholder:text-text-muted";
|
||||
|
||||
export interface FormRowProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** When false, the row shows no amber focus outline (e.g. Monaco rows). */
|
||||
outline?: boolean;
|
||||
}
|
||||
|
||||
export function FormRow({ label, children, className, outline = true }: FormRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"border-b border-border flex flex-row items-stretch",
|
||||
className ?? "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
"flex-1 min-w-0 flex flex-row items-center",
|
||||
outline
|
||||
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400/20 focus-within:outline-offset-[-2px]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormSectionHeader({
|
||||
label,
|
||||
count,
|
||||
}: {
|
||||
label: string;
|
||||
count?: number | string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
{count !== undefined && (
|
||||
<span className="text-[10px] text-text-subtle">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user