v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)
* chore: bump version to 0.5.0 (Task 1) * feat(store): v7 migration — favorites + recent_connections (Task 2) * feat(models): add favorite to Connection + Store CRUD (Task 3) * feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4) * chore: bump version to 0.5.0 (Task 1) — lockfile * feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5) * feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6) * feat(db): get_table_data editability flags + ctid/rowid locator (Task 7) * feat(db): execute_change no-PK locator guard + affected-count check (Task 8) * feat(db): preserve bigint precision as string on PG read path (Task 9) * feat(db): get_indexes / get_constraints commands (Task 10) * feat(store): favorites + recents Store methods (Task 11) * feat(commands): favorites/recents IPC + register indexes/constraints (Task 12) * feat(store): connectionStore favorites/recents/move-selection (Task 13) * feat(lib): recent-connections pure helpers (Task 14) * feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15) * feat(grid): pure editability + filter-operator + cell transform (Task 16) * feat(grid): pure keyboard-nav helper (Task 17) * feat(grid): CellEditor inline editor (Task 18) * feat(grid): CellContextMenu + RowDetailDrawer (Task 19) * feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20) * feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21) * feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22) * feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23) * feat(home): move-to-folder + recents strip + status wiring (Task 24) * feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25) * docs: v0.5.0 status + roadmap updates (Task 26) * polish: empty/error/loading states for v0.5.0 surfaces (Task 28) * feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A) * feat(home): connection card kebab menu — favorite/test/manage (FEAT-B) * fix(home): populate server_version/latency_ms in test_connection + clean online display * style(home): swap grab handle and kebab positions on connection card * style(home): nudge kebab menu to right-1 * style(home): nudge kebab menu to right-0.5 * feat(home): Escape clears + exits focused search * feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A) * feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B) * feat(grid): enums + FK options fed into CellEditor (GRID-C) * feat(grid): FK dropdown display-column labels + placeholder + empty state * fix(grid): FK dropdown renders as fixed overlay to avoid clipping * fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open * style(grid): move FK reference icon to the start of the cell * feat(grid): optimistic staged cell values + pending dot, cleared on refetch * fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace * fix(db-viewer): type getLocator for staged-value matching * test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard * fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name * test(db-viewer): use index access instead of .at() for TS lib target * feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5) * style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling * style(grid): harden FK dropdown minWidth to 360px * style(grid): cap FK dropdown cells at 3 * style(grid): cap FK dropdown cells at 4 * fix(grid): pending dot clears on commit — values stay until refetch * fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data * docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
@@ -13,7 +13,7 @@ const tags: Tag[] = [
|
||||
const conn: Connection = {
|
||||
id: "c1", name: "Prod DB", db_type: "postgresql", host: "prod.example.com",
|
||||
port: 5432, username: null, folder_id: null, keychain_ref: null,
|
||||
tag_ids: ["t1", "t2"], created_at: "", updated_at: "",
|
||||
tag_ids: ["t1", "t2"], favorite: false, created_at: "", updated_at: "",
|
||||
};
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
@@ -22,6 +22,7 @@ function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
describe("ConnectionCard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useUiStore.setState({ selectedItemIds: [] });
|
||||
});
|
||||
|
||||
@@ -43,6 +44,24 @@ describe("ConnectionCard", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
|
||||
expect(screen.getByLabelText("Drag to move connection")).toBeInTheDocument();
|
||||
});
|
||||
it("mounts the connection actions kebab menu with action callbacks", () => {
|
||||
const onEdit = vi.fn();
|
||||
const onDuplicate = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
render(
|
||||
<ConnectionCard
|
||||
connection={conn}
|
||||
tags={tags}
|
||||
onEdit={onEdit}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||
});
|
||||
it("omits port for sqlite", () => {
|
||||
const sqlite = { ...conn, db_type: "sqlite" as const, host: "/data/x.db", port: null };
|
||||
render(<ConnectionCard connection={sqlite} tags={tags} />, { wrapper: Wrapper });
|
||||
@@ -74,4 +93,17 @@ describe("ConnectionCard", () => {
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(useUiStore.getState().selectedItemIds).toContain(conn.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("no longer renders a favorite star (replaced by kebab menu)", () => {
|
||||
render(<ConnectionCard connection={conn} tags={tags} />, { wrapper: Wrapper });
|
||||
expect(screen.queryByLabelText(/favorite|unfavorite/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("no longer renders a status indicator and still shows name/host/tags", () => {
|
||||
render(<ConnectionCard connection={{ ...conn, favorite: true }} tags={tags} />, { wrapper: Wrapper });
|
||||
expect(screen.queryByLabelText(/check connection/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Prod DB")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod.example.com:5432")).toBeInTheDocument();
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { memo } from "react";
|
||||
import type { Connection, Tag } from "../../lib/types";
|
||||
import type { Connection, ConnectionInput, Tag } from "../../lib/types";
|
||||
import { DbIcon, DB_LABELS } from "../../lib/dbIcons";
|
||||
import { ENV_LABELS, ENV_COLORS } from "../../lib/environment";
|
||||
import { TagBadge } from "../tags/TagBadge";
|
||||
import { ConnectionCardMenu } from "./ConnectionCardMenu";
|
||||
import { Check, GripVertical } from "lucide-react";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
@@ -13,6 +14,35 @@ interface ConnectionCardProps {
|
||||
tags: Tag[];
|
||||
onTagToggle?: (id: string) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
onEdit?: (connection: Connection) => void;
|
||||
onDuplicate?: (connection: Connection) => void;
|
||||
onDelete?: (connection: Connection) => void;
|
||||
}
|
||||
|
||||
export function buildConfigFromConnection(conn: Connection, password: string | null): ConnectionInput {
|
||||
return {
|
||||
name: conn.name,
|
||||
db_type: conn.db_type,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
folder_id: conn.folder_id,
|
||||
tag_ids: conn.tag_ids,
|
||||
password,
|
||||
database: conn.database ?? null,
|
||||
environment: conn.environment ?? null,
|
||||
ssh_host: conn.ssh_host ?? null,
|
||||
ssh_port: conn.ssh_port ?? null,
|
||||
ssh_user: conn.ssh_user ?? null,
|
||||
ssh_auth_method: (conn.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
|
||||
ssh_private_key_path: conn.ssh_private_key_path ?? null,
|
||||
ssh_password: null,
|
||||
ssh_passphrase: null,
|
||||
ssl_mode: (conn.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
|
||||
ssl_ca_path: conn.ssl_ca_path ?? null,
|
||||
ssl_cert_path: conn.ssl_cert_path ?? null,
|
||||
ssl_key_path: conn.ssl_key_path ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function ConnectionCardBase({
|
||||
@@ -20,6 +50,9 @@ function ConnectionCardBase({
|
||||
tags,
|
||||
onTagToggle,
|
||||
onOpenDbViewer,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: ConnectionCardProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
@@ -68,7 +101,7 @@ function ConnectionCardBase({
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab z-10"
|
||||
aria-label="Drag to move connection"
|
||||
>
|
||||
<GripVertical size={14} className="text-text-muted" />
|
||||
@@ -107,6 +140,12 @@ function ConnectionCardBase({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionCardMenu
|
||||
connection={connection}
|
||||
onEdit={() => onEdit?.(connection)}
|
||||
onDuplicate={() => onDuplicate?.(connection)}
|
||||
onDelete={() => onDelete?.(connection)}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConnectionCardMenu } from "./ConnectionCardMenu";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
vi.mock("../../lib/commands", () => ({
|
||||
getConnectionPassword: vi.fn(),
|
||||
testConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
const conn: Connection = {
|
||||
id: "c1",
|
||||
name: "Prod DB",
|
||||
db_type: "postgresql",
|
||||
host: "prod.example.com",
|
||||
port: 5432,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
function renderMenu() {
|
||||
const onEdit = vi.fn();
|
||||
const onDuplicate = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const utils = render(
|
||||
<ConnectionCardMenu
|
||||
connection={conn}
|
||||
onEdit={onEdit}
|
||||
onDuplicate={onDuplicate}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onEdit, onDuplicate, onDelete };
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
await userEvent.click(screen.getByLabelText("Connection actions"));
|
||||
}
|
||||
|
||||
describe("ConnectionCardMenu", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a kebab trigger and opens the menu on click", async () => {
|
||||
renderMenu();
|
||||
expect(screen.getByLabelText("Connection actions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
|
||||
await openMenu();
|
||||
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test connection")).toBeInTheDocument();
|
||||
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("favorite label reflects connection.favorite", async () => {
|
||||
const { unmount } = renderMenu();
|
||||
await openMenu();
|
||||
expect(screen.getByText("Add to favorites")).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<ConnectionCardMenu
|
||||
connection={{ ...conn, favorite: true }}
|
||||
onEdit={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await openMenu();
|
||||
expect(screen.getByText("Remove from favorites")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking favorite calls toggleFavorite and closes the menu", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useConnectionStore.getState(), "toggleFavorite")
|
||||
.mockResolvedValue(undefined);
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByText("Add to favorites"));
|
||||
expect(spy).toHaveBeenCalledWith("c1");
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("test connection keeps the menu open and shows online status", async () => {
|
||||
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||
vi.mocked(commands.testConnection).mockResolvedValue({
|
||||
ok: true,
|
||||
server_version: "15.2",
|
||||
latency_ms: 12,
|
||||
} as any);
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
|
||||
await userEvent.click(screen.getByText("Test connection"));
|
||||
|
||||
// Menu stays open while the check runs
|
||||
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/online/i)).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getByText(/15\.2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/12ms/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("test connection shows just Online when no version/latency reported", async () => {
|
||||
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||
vi.mocked(commands.testConnection).mockResolvedValue({ ok: true } as any);
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
|
||||
await userEvent.click(screen.getByText("Test connection"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText("Online", { exact: true }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText(/unknown/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/0ms/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("test connection shows the offline error text", async () => {
|
||||
vi.mocked(commands.getConnectionPassword).mockResolvedValue("pw");
|
||||
vi.mocked(commands.testConnection).mockResolvedValue({
|
||||
ok: false,
|
||||
error: "connection refused",
|
||||
} as any);
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
|
||||
await userEvent.click(screen.getByText("Test connection"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/connection refused/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("Manage expands to reveal Edit/Duplicate/Delete and Edit calls onEdit + closes", async () => {
|
||||
const { onEdit } = renderMenu();
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByText("Manage"));
|
||||
|
||||
expect(screen.getByText("Edit…")).toBeInTheDocument();
|
||||
expect(screen.getByText("Duplicate")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete…")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Edit…"));
|
||||
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Edit…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Duplicate calls onDuplicate + closes the menu", async () => {
|
||||
const { onDuplicate } = renderMenu();
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByText("Manage"));
|
||||
await userEvent.click(screen.getByText("Duplicate"));
|
||||
expect(onDuplicate).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Delete is rendered in red, calls onDelete + closes the menu", async () => {
|
||||
const { onDelete } = renderMenu();
|
||||
await openMenu();
|
||||
await userEvent.click(screen.getByText("Manage"));
|
||||
|
||||
const deleteItem = screen.getByText("Delete…");
|
||||
expect(deleteItem.className).toContain("text-red");
|
||||
|
||||
await userEvent.click(deleteItem);
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on outside mousedown", async () => {
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on Escape", async () => {
|
||||
renderMenu();
|
||||
await openMenu();
|
||||
expect(screen.getByText("Manage")).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Copy,
|
||||
Loader2,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Connection } from "../../lib/types";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
import { buildConfigFromConnection } from "./ConnectionCard";
|
||||
import { useConnectionStatus } from "./useConnectionStatus";
|
||||
|
||||
interface ConnectionCardMenuProps {
|
||||
connection: Connection;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const menuItemClass =
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-text hover:bg-surface transition-colors cursor-pointer";
|
||||
|
||||
/**
|
||||
* Kebab (⋮) actions menu for a connection card. Hosts the favorite toggle,
|
||||
* on-demand connection test (inline status), and a Manage submenu
|
||||
* (Edit… / Duplicate / Delete…). Closes on outside mousedown, Escape, and
|
||||
* after selecting an action.
|
||||
*/
|
||||
export function ConnectionCardMenu({
|
||||
connection,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: ConnectionCardMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const { state, info, check } = useConnectionStatus(
|
||||
connection.id,
|
||||
(pw) => buildConfigFromConnection(connection, pw),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
}
|
||||
};
|
||||
// Capture phase so we fire before other stopPropagation handlers
|
||||
document.addEventListener("mousedown", handleMouseDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (!next) setManageOpen(false);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setManageOpen(false);
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
state === "checking"
|
||||
? "Testing…"
|
||||
: state === "online"
|
||||
? `Online${info ? ` · ${info}` : ""}`
|
||||
: state === "offline"
|
||||
? info
|
||||
: "Test connection";
|
||||
|
||||
const statusIcon =
|
||||
state === "checking" ? (
|
||||
<Loader2 size={14} className="animate-spin text-text-muted" />
|
||||
) : state === "online" ? (
|
||||
<CircleCheck size={14} className="shrink-0 text-green-500" />
|
||||
) : state === "offline" ? (
|
||||
<CircleX size={14} className="shrink-0 text-red-500" />
|
||||
) : (
|
||||
<Activity size={14} className="text-text-muted" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 right-0.5 z-10" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Connection actions"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
}}
|
||||
className={`rounded-md p-1 text-text-muted hover:text-text hover:bg-surface transition-colors cursor-pointer ${
|
||||
open ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute top-full right-0 mt-1 w-64 rounded-md border border-border bg-canvas shadow-lg z-20 py-1 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void useConnectionStore
|
||||
.getState()
|
||||
.toggleFavorite(connection.id)
|
||||
.catch(() => {});
|
||||
close();
|
||||
}}
|
||||
className={menuItemClass}
|
||||
>
|
||||
<Star
|
||||
size={14}
|
||||
className={
|
||||
connection.favorite
|
||||
? "text-amber-400 fill-amber-400"
|
||||
: "text-text-muted"
|
||||
}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{connection.favorite
|
||||
? "Remove from favorites"
|
||||
: "Add to favorites"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void check();
|
||||
}}
|
||||
className={menuItemClass}
|
||||
>
|
||||
{statusIcon}
|
||||
<span
|
||||
className={
|
||||
state === "offline"
|
||||
? "whitespace-normal break-words text-red-500"
|
||||
: "truncate"
|
||||
}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageOpen((m) => !m)}
|
||||
className={`${menuItemClass} justify-between`}
|
||||
>
|
||||
<span>Manage</span>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-text-muted transition-transform ${
|
||||
manageOpen ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{manageOpen && (
|
||||
<div className="mt-1 border-t border-border pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onEdit();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6`}
|
||||
>
|
||||
<Pencil size={14} className="text-text-muted" />
|
||||
Edit…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDuplicate();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6`}
|
||||
>
|
||||
<Copy size={14} className="text-text-muted" />
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
close();
|
||||
}}
|
||||
className={`${menuItemClass} pl-6 !text-red-500 hover:!text-red-400`}
|
||||
>
|
||||
<Trash2 size={14} className="text-red-500" />
|
||||
Delete…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { Connection, Folder, Tag } from "../../lib/types";
|
||||
const makeConn = (id: string, folder_id: string | null = null): Connection => ({
|
||||
id, name: `Conn ${id}`, db_type: "postgresql", host: "h", port: 5432,
|
||||
username: null, folder_id, keychain_ref: null, tag_ids: [],
|
||||
created_at: "", updated_at: "", environment: null,
|
||||
created_at: "", updated_at: "", environment: null, favorite: false,
|
||||
});
|
||||
|
||||
const folders: Folder[] = [
|
||||
|
||||
@@ -109,6 +109,9 @@ interface ConnectionGridProps {
|
||||
onEditFolder?: (folder: Folder) => void;
|
||||
onDeleteFolder?: (folder: Folder) => void;
|
||||
onOpenDbViewer?: (connectionId: string) => void;
|
||||
onEditConnection?: (conn: Connection) => void;
|
||||
onDuplicateConnection?: (conn: Connection) => void;
|
||||
onDeleteConnection?: (conn: Connection) => void;
|
||||
}
|
||||
|
||||
export function ConnectionGrid({
|
||||
@@ -122,6 +125,9 @@ export function ConnectionGrid({
|
||||
onEditFolder,
|
||||
onDeleteFolder,
|
||||
onOpenDbViewer,
|
||||
onEditConnection,
|
||||
onDuplicateConnection,
|
||||
onDeleteConnection,
|
||||
}: ConnectionGridProps) {
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
|
||||
@@ -272,6 +278,9 @@ export function ConnectionGrid({
|
||||
tags={tags}
|
||||
onTagToggle={onTagToggle}
|
||||
onOpenDbViewer={onOpenDbViewer}
|
||||
onEdit={onEditConnection}
|
||||
onDuplicate={onDuplicateConnection}
|
||||
onDelete={onDeleteConnection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MoveToFolderDialog } from "./MoveToFolderDialog";
|
||||
import type { Folder } from "../../lib/types";
|
||||
|
||||
const makeFolder = (id: string, name: string): Folder => ({
|
||||
id,
|
||||
name,
|
||||
parent_id: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
});
|
||||
|
||||
describe("MoveToFolderDialog", () => {
|
||||
it("lists folders + Root and calls onConfirm with the chosen id", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MoveToFolderDialog
|
||||
open
|
||||
folders={[makeFolder("f1", "Prod")]}
|
||||
selectedCount={3}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/3 items/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Prod"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /move/i }));
|
||||
expect(onConfirm).toHaveBeenCalledWith("f1");
|
||||
});
|
||||
|
||||
it("Root option passes null", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<MoveToFolderDialog
|
||||
open
|
||||
folders={[]}
|
||||
selectedCount={1}
|
||||
onConfirm={onConfirm}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/root/i));
|
||||
fireEvent.click(screen.getByRole("button", { name: /move/i }));
|
||||
expect(onConfirm).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("Cancel button calls onClose", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MoveToFolderDialog
|
||||
open
|
||||
folders={[]}
|
||||
selectedCount={2}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Move button is disabled until an option is chosen", () => {
|
||||
render(
|
||||
<MoveToFolderDialog
|
||||
open
|
||||
folders={[makeFolder("f1", "Prod")]}
|
||||
selectedCount={1}
|
||||
onConfirm={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const move = screen.getByRole("button", { name: /move/i });
|
||||
expect(move).toBeDisabled();
|
||||
fireEvent.click(screen.getByText("Prod"));
|
||||
expect(move).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Folder as FolderIcon, Check } from "lucide-react";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import type { Folder } from "../../lib/types";
|
||||
|
||||
interface MoveToFolderDialogProps {
|
||||
open: boolean;
|
||||
folders: Folder[];
|
||||
selectedCount: number;
|
||||
onConfirm: (targetFolderId: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MoveToFolderDialog({
|
||||
open,
|
||||
folders,
|
||||
selectedCount,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: MoveToFolderDialogProps) {
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState(false);
|
||||
|
||||
// Reset on close so a fresh open starts unselected (null is a valid target = Root).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTarget(null);
|
||||
setSelected(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(target);
|
||||
};
|
||||
|
||||
const isSelected = (id: string | null) => target === id;
|
||||
|
||||
return (
|
||||
<AnimatedModal open={open} onClose={onClose}>
|
||||
<div className="w-[360px]">
|
||||
<h3 className="font-heading text-lg text-text mb-4">
|
||||
Move {selectedCount} item{selectedCount !== 1 ? "s" : ""} to folder
|
||||
</h3>
|
||||
<div className="max-h-[300px] overflow-y-auto space-y-1 pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget(null);
|
||||
setSelected(true);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
|
||||
isSelected(null)
|
||||
? "bg-accent/10 text-text"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<FolderIcon size={14} />
|
||||
<span className="flex-1">Root (no folder)</span>
|
||||
{isSelected(null) && <Check size={14} className="text-accent" />}
|
||||
</button>
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget(folder.id);
|
||||
setSelected(true);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors cursor-pointer ${
|
||||
isSelected(folder.id)
|
||||
? "bg-accent/10 text-text"
|
||||
: "text-text-muted hover:text-text hover:bg-surface-raised"
|
||||
}`}
|
||||
>
|
||||
<FolderIcon size={14} />
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{isSelected(folder.id) && <Check size={14} className="text-accent" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!selected}>
|
||||
Move
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { RecentConnectionsStrip } from "./RecentConnectionsStrip";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
const makeConn = (id: string): Connection => ({
|
||||
id,
|
||||
name: id.toUpperCase(),
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
username: null,
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
favorite: false,
|
||||
});
|
||||
|
||||
describe("RecentConnectionsStrip", () => {
|
||||
it("renders up to 8 recent connections and calls onOpen on click", () => {
|
||||
const onOpen = vi.fn();
|
||||
const recents = Array.from({ length: 10 }, (_, i) => makeConn(`c${i}`));
|
||||
render(<RecentConnectionsStrip recents={recents} onOpen={onOpen} />);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(8);
|
||||
fireEvent.click(screen.getByText("C0"));
|
||||
expect(onOpen).toHaveBeenCalledWith("c0");
|
||||
});
|
||||
|
||||
it("renders nothing when the list is empty", () => {
|
||||
const { container } = render(
|
||||
<RecentConnectionsStrip recents={[]} onOpen={vi.fn()} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DbIcon } from "../../lib/dbIcons";
|
||||
import type { Connection } from "../../lib/types";
|
||||
|
||||
interface RecentConnectionsStripProps {
|
||||
recents: Connection[];
|
||||
onOpen: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentConnectionsStrip({
|
||||
recents,
|
||||
onOpen,
|
||||
}: RecentConnectionsStripProps) {
|
||||
if (recents.length === 0) return null;
|
||||
|
||||
const visible = recents.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wide mb-2">
|
||||
Recent
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{visible.map((connection) => (
|
||||
<button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(connection.id)}
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface border border-border hover:border-border-hover text-sm text-text transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<DbIcon type={connection.db_type} size={14} />
|
||||
<span className="truncate max-w-[180px]">{connection.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useConnectionStatus } from "./useConnectionStatus";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
describe("useConnectionStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts idle and becomes online with server_version/latency info after a successful check", async () => {
|
||||
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||
vi.spyOn(commands, "testConnection").mockResolvedValue({
|
||||
ok: true,
|
||||
server_version: "15.2",
|
||||
latency_ms: 12,
|
||||
} as any);
|
||||
const { result } = renderHook(() =>
|
||||
useConnectionStatus("c1", () => ({
|
||||
name: "P",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
password: "pw",
|
||||
} as any)),
|
||||
);
|
||||
expect(result.current.state).toBe("idle");
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(result.current.state).toBe("online");
|
||||
expect(result.current.info).toContain("15.2");
|
||||
expect(result.current.info).toContain("12ms");
|
||||
});
|
||||
|
||||
it("becomes offline with error info when testConnection fails", async () => {
|
||||
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||
vi.spyOn(commands, "testConnection").mockResolvedValue({
|
||||
ok: false,
|
||||
error: "timeout",
|
||||
} as any);
|
||||
const { result } = renderHook(() =>
|
||||
useConnectionStatus("c1", () => ({
|
||||
name: "P",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
} as any)),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(result.current.state).toBe("offline");
|
||||
expect(result.current.info).toContain("timeout");
|
||||
});
|
||||
|
||||
it("online with no version/latency reported keeps info empty (no unknown/0ms fallback)", async () => {
|
||||
vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
|
||||
const { result } = renderHook(() =>
|
||||
useConnectionStatus("c1", () => ({
|
||||
name: "P",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
username: "u",
|
||||
} as any)),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(result.current.state).toBe("online");
|
||||
expect(result.current.info).toBe("");
|
||||
});
|
||||
|
||||
it("debounces: rapid check() calls run one check; a re-check is allowed after 2s", async () => {
|
||||
vi.useFakeTimers({ now: 100_000 });
|
||||
try {
|
||||
const pw = vi.spyOn(commands, "getConnectionPassword").mockResolvedValue("pw");
|
||||
vi.spyOn(commands, "testConnection").mockResolvedValue({ ok: true } as any);
|
||||
const { result } = renderHook(() =>
|
||||
useConnectionStatus("c1", () => ({
|
||||
name: "P",
|
||||
db_type: "postgresql",
|
||||
host: "h",
|
||||
port: 5432,
|
||||
} as any)),
|
||||
);
|
||||
await act(async () => {
|
||||
const p1 = result.current.check();
|
||||
const p2 = result.current.check();
|
||||
await Promise.all([p1, p2]);
|
||||
});
|
||||
expect(pw).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await act(async () => {
|
||||
await result.current.check();
|
||||
});
|
||||
expect(pw).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ConnectionInput, ConnectionTestResult } from "../../lib/types";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export type ConnectionStatusState = "idle" | "checking" | "online" | "offline";
|
||||
|
||||
const DEBOUNCE_MS = 2000;
|
||||
const RESET_MS = 5000;
|
||||
|
||||
/**
|
||||
* Extracted from the former status-dot UI: runs a connection test on
|
||||
* demand, debounced to once per 2s, and auto-resets to "idle" 5s after the
|
||||
* last check. `check()` is stable (useCallback) and safe to hand to menu
|
||||
* handlers; it no-ops while a check is already in flight or within the
|
||||
* debounce window.
|
||||
*/
|
||||
export function useConnectionStatus(
|
||||
connectionId: string,
|
||||
buildConfig: (password: string | null) => ConnectionInput,
|
||||
): { state: ConnectionStatusState; info: string; check: () => Promise<void> } {
|
||||
const [state, setState] = useState<ConnectionStatusState>("idle");
|
||||
const [info, setInfo] = useState<string>("");
|
||||
const timeoutRef = useRef<number | null>(null);
|
||||
const lastCheckRef = useRef<number>(0);
|
||||
// Mirror `state` in a ref so `check` stays stable while still being able to
|
||||
// skip a second run while one is already in flight.
|
||||
const stateRef = useRef<ConnectionStatusState>("idle");
|
||||
|
||||
const setStateBoth = useCallback((next: ConnectionStatusState) => {
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (stateRef.current === "checking") return;
|
||||
if (now - lastCheckRef.current < DEBOUNCE_MS) return;
|
||||
lastCheckRef.current = now;
|
||||
setStateBoth("checking");
|
||||
setInfo("");
|
||||
|
||||
try {
|
||||
const password = await cmd.getConnectionPassword(connectionId);
|
||||
const input = buildConfig(password);
|
||||
const result: ConnectionTestResult = await cmd.testConnection(input);
|
||||
if (result.ok) {
|
||||
const parts: string[] = [];
|
||||
if (result.server_version) parts.push(result.server_version);
|
||||
if (result.latency_ms != null) parts.push(`${result.latency_ms}ms`);
|
||||
setInfo(parts.join(" · "));
|
||||
setStateBoth("online");
|
||||
} else {
|
||||
setInfo(result.error ?? "offline");
|
||||
setStateBoth("offline");
|
||||
}
|
||||
} catch (e) {
|
||||
setInfo(e instanceof Error ? e.message : "offline");
|
||||
setStateBoth("offline");
|
||||
}
|
||||
|
||||
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setStateBoth("idle");
|
||||
setInfo("");
|
||||
}, RESET_MS);
|
||||
}, [connectionId, buildConfig, setStateBoth]);
|
||||
|
||||
return { state, info, check };
|
||||
}
|
||||
@@ -34,6 +34,22 @@ describe("ChangesQueuePanel", () => {
|
||||
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({
|
||||
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
|
||||
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);
|
||||
|
||||
@@ -30,6 +30,22 @@ function formatChangeLabel(change: QueueItem): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
|
||||
return change.table ?? "-";
|
||||
}
|
||||
|
||||
export function ChangesQueuePanel() {
|
||||
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const removeChange = useDbViewerStore((state) => state.removeChange);
|
||||
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
||||
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
|
||||
}
|
||||
|
||||
if (committedCount > 0) {
|
||||
onCommitted?.();
|
||||
notify(`${committedCount} change(s) committed`, "success");
|
||||
}
|
||||
|
||||
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { DbViewerScreen } from "./DbViewerScreen";
|
||||
import {
|
||||
DbViewerScreen,
|
||||
deriveStagedValues,
|
||||
derivePendingCellKeys,
|
||||
pickDisplayColumn,
|
||||
} from "./DbViewerScreen";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("../../hooks/useDbConnection", () => ({
|
||||
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: () => ({
|
||||
getVirtualItems: () => [],
|
||||
getTotalSize: () => 0,
|
||||
useVirtualizer: ({ count }: any) => ({
|
||||
getVirtualItems: () =>
|
||||
count > 0
|
||||
? Array.from({ length: count }, (_, i) => ({
|
||||
key: i,
|
||||
index: i,
|
||||
start: i * 36,
|
||||
size: 36,
|
||||
}))
|
||||
: [],
|
||||
getTotalSize: () => count * 36,
|
||||
measureElement: () => {},
|
||||
}),
|
||||
}));
|
||||
@@ -64,6 +78,8 @@ const mockQueryResult = {
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1]],
|
||||
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("full flow: editing a cell shows the staged value + pending dot in the grid", async () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||
store.setTabData(tabId, {
|
||||
columns: [
|
||||
{ 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 },
|
||||
],
|
||||
rows: [[1, "Alice"]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
const cell = await waitFor(() => screen.getByText("Alice"));
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "Enter" });
|
||||
// the editor's textarea is the last textbox (toolbar filter input is first)
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
const input = textboxes[textboxes.length - 1]!;
|
||||
fireEvent.change(input, { target: { value: "Alicia" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
// staged change carries the correct table (was the root-cause bug)
|
||||
const staged = useDbViewerStore.getState().changesQueue[0];
|
||||
expect(staged?.table).toBe("users");
|
||||
expect(staged?.schema).toBe("public");
|
||||
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
// committing the change clears the pending dot but keeps the value until refetch
|
||||
act(() => {
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.markChangeCommitted(
|
||||
useDbViewerStore.getState().changesQueue[0].id,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
});
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
// clearing the queue clears the optimistic display
|
||||
act(() => {
|
||||
useDbViewerStore.getState().clearChanges();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Alicia")).toBeNull();
|
||||
});
|
||||
|
||||
it("deriveStagedValues maps queue updates to optimistic cell values", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": "Alicia",
|
||||
});
|
||||
});
|
||||
|
||||
it("deriveStagedValues ignores failed/other-table changes and handles NULL", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: null },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "orders",
|
||||
primaryKey: { id: 1 }, oldData: { x: 1 }, newData: { x: 2 },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-3", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "X" },
|
||||
status: "failed" as const, error: "boom", createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("derivePendingCellKeys only includes pending updates (dot clears on commit)", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 2 }, oldData: { name: "Bob" }, newData: { name: "Bobby" },
|
||||
status: "committed" as const, createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(derivePendingCellKeys(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the New Query button", () => {
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables Insert Row for a materialized-view tab", async () => {
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{ name: "mat_users", schema: "public", table_type: "MATERIALIZED VIEW" },
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-mv",
|
||||
schema: "public",
|
||||
table: "mat_users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: mockQueryResult,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-mv",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches the active tab after a successful Commit All", async () => {
|
||||
useUiStore.setState({ activeConnectionId: "c1" });
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getTableData = vi
|
||||
.spyOn(commands, "getTableData")
|
||||
.mockResolvedValue({
|
||||
columns: mockQueryResult.columns,
|
||||
rows: [[2]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
|
||||
useDbViewerStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: mockQueryResult,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-1",
|
||||
changesQueue: [],
|
||||
changesPanelExpanded: true,
|
||||
});
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
newData: { id: 2, name: "Alice" },
|
||||
description: "Insert row into users",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
|
||||
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("fetches enum labels and FK reference rows for the active table tab", async () => {
|
||||
const getEnums = vi
|
||||
.spyOn(commands, "getEnums")
|
||||
.mockResolvedValue([
|
||||
{
|
||||
name: "user_role",
|
||||
schema: "public",
|
||||
labels: ["admin", "user"],
|
||||
},
|
||||
]);
|
||||
const getTableData = 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: false,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1], [2]],
|
||||
total_rows: 2,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
|
||||
useDbViewerStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
columns: [
|
||||
{
|
||||
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: "user_id",
|
||||
data_type: "integer",
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
fk_ref: ["users", "id"],
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
data_type: "user_role",
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1, 2, "admin"]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-1",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enum labels are fetched for the tab's schema (cached per schema).
|
||||
await waitFor(() =>
|
||||
expect(getEnums).toHaveBeenCalledWith("c1", "public"),
|
||||
);
|
||||
// FK reference rows are fetched from the referenced table (page 1, 50).
|
||||
await waitFor(() =>
|
||||
expect(getTableData).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"public",
|
||||
"users",
|
||||
1,
|
||||
50,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("pickDisplayColumn prefers name-like columns over the ref column", () => {
|
||||
const cols = [
|
||||
{ name: "id", data_type: "integer" },
|
||||
{ name: "email", data_type: "text" },
|
||||
{ name: "name", data_type: "text" },
|
||||
];
|
||||
expect(pickDisplayColumn(cols, "id")).toBe("name");
|
||||
expect(pickDisplayColumn(cols, "id", "email")).toBe("email");
|
||||
});
|
||||
|
||||
it("pickDisplayColumn falls back to the ref column when nothing is name-like", () => {
|
||||
const cols = [{ name: "id", data_type: "integer" }];
|
||||
expect(pickDisplayColumn(cols, "id")).toBe("id");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { TableTree } from "./TableTree";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import { QueriesPanel } from "../queries/QueriesPanel";
|
||||
import { useQueryStore } from "../../stores/queryStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { EnumInfo } from "../../lib/types";
|
||||
import type { FkOption } from "../grid/CellEditor";
|
||||
import type { QueueItem } from "../../stores/dbViewerStore";
|
||||
|
||||
/**
|
||||
* Derive optimistic staged cell values from the changes queue for a table
|
||||
* tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL).
|
||||
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
|
||||
* Pending + committed updates count (survive until refetch); Clear All
|
||||
* empties the queue so the optimistic display vanishes.
|
||||
*/
|
||||
export function deriveStagedValues(
|
||||
changesQueue: QueueItem[],
|
||||
schema: string,
|
||||
table: string,
|
||||
rows: unknown[][],
|
||||
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||
): Record<string, string | null> {
|
||||
const map: Record<string, string | null> = {};
|
||||
const updates = changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "update" &&
|
||||
(c.status === "pending" || c.status === "committed") &&
|
||||
c.schema === schema &&
|
||||
c.table === table &&
|
||||
c.primaryKey &&
|
||||
c.newData,
|
||||
);
|
||||
if (updates.length === 0) return map;
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const loc = getLocator(row);
|
||||
for (const c of updates) {
|
||||
const pk = c.primaryKey!;
|
||||
const matches = Object.entries(pk).every(
|
||||
([k, v]) => String(loc[k]) === String(v),
|
||||
);
|
||||
if (!matches) continue;
|
||||
const colName = Object.keys(c.newData!)[0];
|
||||
if (!colName) continue;
|
||||
map[`${rowIdx}:${colName}`] =
|
||||
(c.newData![colName] as string | null) ?? null;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of cells with a PENDING update only — drives the amber pending dot.
|
||||
* Once a change is committed the dot clears even though the optimistic value
|
||||
* (from `deriveStagedValues`) stays until the refetch lands.
|
||||
*/
|
||||
export function derivePendingCellKeys(
|
||||
changesQueue: QueueItem[],
|
||||
schema: string,
|
||||
table: string,
|
||||
rows: unknown[][],
|
||||
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||
): Record<string, boolean> {
|
||||
const keys: Record<string, boolean> = {};
|
||||
const updates = changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "update" &&
|
||||
c.status === "pending" &&
|
||||
c.schema === schema &&
|
||||
c.table === table &&
|
||||
c.primaryKey &&
|
||||
c.newData,
|
||||
);
|
||||
if (updates.length === 0) return keys;
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const loc = getLocator(row);
|
||||
for (const c of updates) {
|
||||
const pk = c.primaryKey!;
|
||||
const matches = Object.entries(pk).every(
|
||||
([k, v]) => String(loc[k]) === String(v),
|
||||
);
|
||||
if (!matches) continue;
|
||||
const colName = Object.keys(c.newData!)[0];
|
||||
if (!colName) continue;
|
||||
keys[`${rowIdx}:${colName}`] = true;
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a human-friendly display column for FK option labels from the
|
||||
* referenced table's columns: prefer name-like columns, else the first
|
||||
* text-ish column that isn't the ref column, else the ref column itself.
|
||||
*/
|
||||
export function pickDisplayColumn(
|
||||
columns: { name: string; data_type: string }[],
|
||||
refCol: string,
|
||||
preferred?: string,
|
||||
): string {
|
||||
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
|
||||
const nameLike = [
|
||||
"name",
|
||||
"title",
|
||||
"label",
|
||||
"username",
|
||||
"email",
|
||||
"full_name",
|
||||
"display_name",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"description",
|
||||
];
|
||||
for (const n of nameLike) {
|
||||
if (columns.some((c) => c.name === n)) return n;
|
||||
}
|
||||
const textish = columns.find(
|
||||
(c) =>
|
||||
c.name !== refCol &&
|
||||
/text|char|name|uuid/i.test(c.data_type),
|
||||
);
|
||||
return textish ? textish.name : refCol;
|
||||
}
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
connectionId: string;
|
||||
@@ -48,6 +167,7 @@ export function DbViewerScreen({
|
||||
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
@@ -84,6 +204,19 @@ export function DbViewerScreen({
|
||||
const filterRules = activeTab?.filterRules ?? [];
|
||||
const sortRules = activeTab?.sortRules ?? [];
|
||||
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||
|
||||
const isMatview =
|
||||
activeTab && activeTab.tabType === "table"
|
||||
? tables.some(
|
||||
(t) =>
|
||||
t.schema === activeTab.schema &&
|
||||
t.name === activeTab.table &&
|
||||
t.table_type === "MATERIALIZED VIEW",
|
||||
)
|
||||
: false;
|
||||
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
@@ -96,6 +229,18 @@ export function DbViewerScreen({
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const fetchingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// CellEditor options for the active table tab: PG enum labels (cached per
|
||||
// connection+schema) + FK reference rows (page 1, 50 per FK column).
|
||||
const [editorOptions, setEditorOptions] = useState<{
|
||||
enums: Record<string, string[]>;
|
||||
fks: Record<string, FkOption[]>;
|
||||
fkPlaceholders: Record<string, string>;
|
||||
} | null>(null);
|
||||
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
|
||||
// Key identifying the (connection, tab, schema) the options were fetched for;
|
||||
// guards against refetching on every render while data updates in place.
|
||||
const editorOptionsKeyRef = useRef<string>("");
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (tab: NonNullable<typeof activeTab>) => {
|
||||
if (fetchingRef.current.has(tab.id)) return;
|
||||
@@ -139,6 +284,31 @@ export function DbViewerScreen({
|
||||
}
|
||||
}
|
||||
|
||||
const handleStageEdit = useCallback(
|
||||
(payload: {
|
||||
type: "update";
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
}) => {
|
||||
if (!activeTab) return;
|
||||
const { type: _, ...rest } = payload;
|
||||
stageCellEdit({ tabId: activeTab.id, ...rest });
|
||||
},
|
||||
[activeTab, stageCellEdit],
|
||||
);
|
||||
|
||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||
setRowDetailIdx(rowIndex);
|
||||
}, []);
|
||||
|
||||
const handleCommitted = useCallback(() => {
|
||||
if (!activeTab) return;
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Read the active tab from the store directly so the Monaco keybinding action
|
||||
// (which keeps the first onRun closure) always sees the latest query text.
|
||||
const handleRunQuery = useCallback(() => {
|
||||
@@ -243,6 +413,108 @@ export function DbViewerScreen({
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Feed the grid's CellEditor with enum labels + FK reference rows for the
|
||||
// active table tab. Fetched once per tab/schema (enums additionally cached
|
||||
// per connection+schema across tabs); a failed fetch for one FK column is
|
||||
// skipped without breaking the tab. Never refetches on in-place data updates.
|
||||
useEffect(() => {
|
||||
const key =
|
||||
activeTab && activeTab.tabType === "table" && activeTab.data
|
||||
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
|
||||
: "";
|
||||
if (key === editorOptionsKeyRef.current) return;
|
||||
editorOptionsKeyRef.current = key;
|
||||
if (!key || !activeTab || !activeTab.data) {
|
||||
setEditorOptions(null);
|
||||
return;
|
||||
}
|
||||
const cols = activeTab.data.columns;
|
||||
const tab = activeTab;
|
||||
void (async () => {
|
||||
const enums: Record<string, string[]> = {};
|
||||
const fks: Record<string, FkOption[]> = {};
|
||||
const fkPlaceholders: Record<string, string> = {};
|
||||
|
||||
// PG enums: fetched once per connection+schema, reused across tabs.
|
||||
const cacheKey = `${connectionId}:${tab.schema}`;
|
||||
let enumList = enumCacheRef.current.get(cacheKey);
|
||||
if (!enumList) {
|
||||
try {
|
||||
enumList = await cmd.getEnums(connectionId, tab.schema);
|
||||
enumCacheRef.current.set(cacheKey, enumList);
|
||||
} catch {
|
||||
enumList = [];
|
||||
}
|
||||
}
|
||||
for (const col of cols) {
|
||||
const match = enumList.find((e) => e.name === col.data_type);
|
||||
if (match) enums[col.name] = match.labels;
|
||||
}
|
||||
|
||||
// FK options: referenced rows (page 1, 50) per FK column.
|
||||
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
|
||||
await Promise.all(
|
||||
fkCols.map(async (col) => {
|
||||
const [refTable, refCol] = col.fk_ref!;
|
||||
try {
|
||||
const result = await cmd.getTableData(
|
||||
connectionId,
|
||||
tab.schema,
|
||||
refTable,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
const refIdx = result.columns.findIndex(
|
||||
(c) => c.name === refCol,
|
||||
);
|
||||
if (refIdx >= 0) {
|
||||
const displayCol = pickDisplayColumn(
|
||||
result.columns,
|
||||
refCol,
|
||||
);
|
||||
const displayIdx =
|
||||
displayCol === refCol
|
||||
? refIdx
|
||||
: result.columns.findIndex(
|
||||
(c) => c.name === displayCol,
|
||||
);
|
||||
fks[col.name] = result.rows.map((row) => {
|
||||
const refValue = String(row[refIdx]);
|
||||
const dispValue =
|
||||
displayIdx >= 0 && displayIdx !== refIdx
|
||||
? String(row[displayIdx])
|
||||
: "";
|
||||
return {
|
||||
value: refValue,
|
||||
label:
|
||||
dispValue && dispValue !== refValue
|
||||
? `${refValue} — ${dispValue}`
|
||||
: refValue,
|
||||
// Referenced-row cells for the FK-reference-style
|
||||
// one-row dropdown (first 5 columns).
|
||||
cells: result.columns
|
||||
.slice(0, 5)
|
||||
.map((c, i) => ({
|
||||
name: c.name,
|
||||
value: String(row[i] ?? ""),
|
||||
})),
|
||||
};
|
||||
});
|
||||
fkPlaceholders[col.name] = `Search ${refTable}…`;
|
||||
}
|
||||
} catch {
|
||||
// Skip this FK column; the cell keeps the plain editor.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Apply only if no newer fetch superseded this one (tab/schema switched).
|
||||
if (editorOptionsKeyRef.current === key) {
|
||||
setEditorOptions({ enums, fks, fkPlaceholders });
|
||||
}
|
||||
})();
|
||||
}, [activeTab, connectionId]);
|
||||
|
||||
// Smart default sort: apply once when data first loads for a tab
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
function renderQueryWorkspace() {
|
||||
const getLocator = (row: unknown[]) => {
|
||||
const pkCol = columns.find((c) => c.is_pk);
|
||||
if (pkCol) {
|
||||
const pkIndex = columns.findIndex(
|
||||
(c) => c.name === pkCol.name,
|
||||
);
|
||||
return { [pkCol.name]: row[pkIndex] };
|
||||
}
|
||||
const dbType = currentConnection?.db_type ?? "postgresql";
|
||||
const locatorIndex = columns.length;
|
||||
if (dbType === "sqlite") {
|
||||
return { rowid: row[locatorIndex] };
|
||||
}
|
||||
return { ctid: row[locatorIndex] };
|
||||
};
|
||||
|
||||
// Staged cell values derived from the changes queue (single source of
|
||||
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
|
||||
// committed updates survive until refetch; Clear All empties the queue
|
||||
// so the optimistic display and pending dots vanish immediately.
|
||||
const stagedValues = activeTab?.data
|
||||
? deriveStagedValues(
|
||||
changesQueue,
|
||||
activeTab.schema,
|
||||
activeTab.table,
|
||||
activeTab.data.rows,
|
||||
getLocator,
|
||||
)
|
||||
: {};
|
||||
const pendingKeys = activeTab?.data
|
||||
? derivePendingCellKeys(
|
||||
changesQueue,
|
||||
activeTab.schema,
|
||||
activeTab.table,
|
||||
activeTab.data.rows,
|
||||
getLocator,
|
||||
)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
<TabBar onCommitted={handleCommitted} />
|
||||
{!activeTab ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
{currentView === "queries" ? (
|
||||
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
)
|
||||
}
|
||||
variant="query"
|
||||
isMatview={isMatview}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onClearSelection={() =>
|
||||
setSelectedRows(new Set())
|
||||
}
|
||||
isMatview={isMatview}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(
|
||||
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rowDetailIdx !== null && activeTab?.data && (
|
||||
<RowDetailDrawer
|
||||
columns={activeTab.data.columns}
|
||||
row={activeTab.data.rows[rowDetailIdx]}
|
||||
onClose={() => setRowDetailIdx(null)}
|
||||
onCopy={(value) =>
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } 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();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
ChevronRight,
|
||||
FunctionSquare,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
Puzzle,
|
||||
Search,
|
||||
@@ -19,6 +22,8 @@ import type {
|
||||
SequenceInfo,
|
||||
EnumInfo,
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
} from "../../lib/types";
|
||||
|
||||
export type ObjectType =
|
||||
@@ -26,7 +31,10 @@ export type ObjectType =
|
||||
| "triggers"
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions";
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
|
||||
interface ObjectExplorerPageProps {
|
||||
connectionId: string;
|
||||
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
};
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
};
|
||||
|
||||
/** 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`;
|
||||
}
|
||||
|
||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
functions: (
|
||||
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
||||
@@ -60,6 +84,9 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
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" />,
|
||||
};
|
||||
|
||||
type AnyObject =
|
||||
@@ -67,7 +94,9 @@ type AnyObject =
|
||||
| TriggerInfo
|
||||
| SequenceInfo
|
||||
| EnumInfo
|
||||
| ExtensionInfo;
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo;
|
||||
|
||||
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
||||
function itemKey(item: AnyObject): string {
|
||||
@@ -487,105 +516,287 @@ function SyntaxCode({
|
||||
);
|
||||
}
|
||||
|
||||
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(type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
case "functions": {
|
||||
const f = item as FunctionInfo;
|
||||
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">
|
||||
Signature
|
||||
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">
|
||||
Returns
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{f.return_type || "void"}
|
||||
<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">
|
||||
Language
|
||||
Method
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.language}
|
||||
<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">
|
||||
Kind
|
||||
Unique
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.kind === "f" ? "Function" : "Procedure"}
|
||||
<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">
|
||||
Schema
|
||||
Size
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{f.schema}
|
||||
{idx.size_bytes ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{f.argument_names.length > 0 && (
|
||||
{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">
|
||||
Arguments
|
||||
Columns
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.argument_names.length} total
|
||||
{idx.columns.length}
|
||||
</span>
|
||||
</div>
|
||||
{f.argument_names.map((name, i) => (
|
||||
{idx.columns.map((col, 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 className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="mx-2 text-border">:</span>
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{f.argument_types?.[i] || "unknown"}
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{f.source && (
|
||||
{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">
|
||||
Source
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.language}
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode
|
||||
source={f.source}
|
||||
language={f.language}
|
||||
/>
|
||||
<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>
|
||||
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
if (type === "extensions") {
|
||||
result = await cmd.getExtensions(connectionId);
|
||||
} else if (type === "functions") {
|
||||
result = await cmd.getFunctions(
|
||||
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,
|
||||
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
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 = [];
|
||||
}
|
||||
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
{items === null
|
||||
? `No ${label.toLowerCase()} found`
|
||||
: searchQuery
|
||||
? `No ${label.toLowerCase()} matching "${searchQuery}"`
|
||||
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
|
||||
{searchQuery
|
||||
? `No ${emptyPlural(type)} matching "${searchQuery}"`
|
||||
: `No ${emptyPlural(type)} found`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
|
||||
export function TabBar() {
|
||||
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
@@ -129,7 +129,7 @@ export function TabBar() {
|
||||
</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 />
|
||||
<ChangesQueuePanel onCommitted={onCommitted} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ const columns = [
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -314,4 +316,19 @@ describe("TableControls", () => {
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
|
||||
{ label: "Markdown", ext: "md" },
|
||||
] as const;
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
order: "asc" | "desc";
|
||||
};
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -136,6 +124,7 @@ function FilterModal({
|
||||
<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
|
||||
@@ -414,6 +403,8 @@ interface TableControlsProps {
|
||||
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";
|
||||
}
|
||||
@@ -435,6 +426,7 @@ export function TableControls({
|
||||
selectedRows,
|
||||
onClearSelection,
|
||||
defaultRefreshRate = 0,
|
||||
isMatview = false,
|
||||
variant = "table",
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
@@ -631,17 +623,21 @@ export function TableControls({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 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>
|
||||
{!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}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
|
||||
|
||||
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 }];
|
||||
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));
|
||||
|
||||
@@ -23,6 +23,23 @@ describe("TableTree", () => {
|
||||
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("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
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 TypeIcon = isMatView ? Layers : Table2;
|
||||
const typeLabel = isMatView ? "Materialized View" : null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
<Table2 size={14} className="text-text-muted" />
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CellContextMenu } from "./CellContextMenu";
|
||||
|
||||
describe("CellContextMenu", () => {
|
||||
const base = {
|
||||
anchorRect: { top: 0, left: 0, width: 10, height: 10 } as DOMRect,
|
||||
onClose: vi.fn(),
|
||||
onCopy: vi.fn(), onCopyJson: vi.fn(), onEdit: vi.fn(), onSetNull: vi.fn(), onOpenFk: vi.fn(),
|
||||
onViewRow: vi.fn(), onSelectRow: vi.fn(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it("renders Copy, Edit, Set NULL for an editable scalar cell", () => {
|
||||
render(<CellContextMenu {...base} editable isJson={false} isFk={false} nullable />);
|
||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Set NULL")).toBeInTheDocument();
|
||||
});
|
||||
it("renders Copy JSON for a json cell and hides Edit when not editable", () => {
|
||||
render(<CellContextMenu {...base} editable={false} isJson isFk={false} nullable={false} />);
|
||||
expect(screen.getByText("Copy JSON")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Edit")).toBeNull();
|
||||
});
|
||||
it("renders Open FK reference for an FK cell", () => {
|
||||
render(<CellContextMenu {...base} editable={false} isJson={false} isFk nullable={false} />);
|
||||
expect(screen.getByText("Open FK reference")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("Open FK reference"));
|
||||
expect(base.onOpenFk).toHaveBeenCalled();
|
||||
});
|
||||
it("always renders View Row and Select Row", () => {
|
||||
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||
expect(screen.getByText("View Row")).toBeInTheDocument();
|
||||
expect(screen.getByText("Select Row")).toBeInTheDocument();
|
||||
});
|
||||
it("calls onViewRow when View Row is clicked", () => {
|
||||
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||
fireEvent.click(screen.getByText("View Row"));
|
||||
expect(base.onViewRow).toHaveBeenCalledTimes(1);
|
||||
expect(base.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("calls onSelectRow when Select Row is clicked", () => {
|
||||
render(<CellContextMenu {...base} editable={false} isJson={false} isFk={false} nullable={false} />);
|
||||
fireEvent.click(screen.getByText("Select Row"));
|
||||
expect(base.onSelectRow).toHaveBeenCalledTimes(1);
|
||||
expect(base.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Copy, Braces, Pencil, CircleSlash, Link2, Eye, MousePointerClick } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
anchorRect: DOMRect;
|
||||
editable: boolean;
|
||||
isJson: boolean;
|
||||
isFk: boolean;
|
||||
nullable: boolean;
|
||||
onCopy: () => void; onCopyJson: () => void; onEdit: () => void;
|
||||
onSetNull: () => void; onOpenFk: () => void; onClose: () => void;
|
||||
onViewRow: () => void; onSelectRow: () => void;
|
||||
}
|
||||
export function CellContextMenu({ anchorRect, editable, isJson, isFk, nullable, onCopy, onCopyJson, onEdit, onSetNull, onOpenFk, onClose, onViewRow, onSelectRow }: Props) {
|
||||
const items: { label: string; icon: React.ReactNode; onClick: () => void; show: boolean }[] = [
|
||||
{ label: "Copy", icon: <Copy size={12} />, onClick: () => { onCopy(); onClose(); }, show: true },
|
||||
{ label: "Copy JSON", icon: <Braces size={12} />, onClick: () => { onCopyJson(); onClose(); }, show: isJson },
|
||||
{ label: "View Row", icon: <Eye size={12} />, onClick: () => { onViewRow(); onClose(); }, show: true },
|
||||
{ label: "Select Row", icon: <MousePointerClick size={12} />, onClick: () => { onSelectRow(); onClose(); }, show: true },
|
||||
{ label: "Edit", icon: <Pencil size={12} />, onClick: () => { onEdit(); onClose(); }, show: editable },
|
||||
{ label: "Set NULL", icon: <CircleSlash size={12} />, onClick: () => { onSetNull(); onClose(); }, show: editable && nullable },
|
||||
{ label: "Open FK reference", icon: <Link2 size={12} />, onClick: () => { onOpenFk(); onClose(); }, show: isFk },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 min-w-[160px] bg-canvas border border-border rounded-md shadow-lg py-1 text-xs"
|
||||
style={{ top: anchorRect.bottom, left: anchorRect.left }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{items.filter((i) => i.show).map((i) => (
|
||||
<button key={i.label} onClick={i.onClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-surface text-left text-text">
|
||||
{i.icon} {i.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CellEditor } from "./CellEditor";
|
||||
|
||||
describe("CellEditor", () => {
|
||||
it("renders the initial value and commits on Enter", () => {
|
||||
const onCommit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={onCancel} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toHaveValue("Alice");
|
||||
fireEvent.change(input, { target: { value: "Alicia" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith("Alicia");
|
||||
});
|
||||
it("commits null when the setNull flag is toggled", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={onCommit} onCancel={vi.fn()} nullable />);
|
||||
const nullCheckbox = screen.getByLabelText(/set null/i);
|
||||
fireEvent.click(nullCheckbox);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("cancels on Escape", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(<CellEditor initialValue="Alice" dataType="text" onCommit={vi.fn()} onCancel={onCancel} />);
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
it("uses textarea for large/JSON columns", () => {
|
||||
render(<CellEditor initialValue="{}" dataType="jsonb" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
|
||||
});
|
||||
it("renders a combobox with enum values and commits on change", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="active"
|
||||
dataType="text"
|
||||
enumValues={["active", "inactive", "pending"]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toBeInTheDocument();
|
||||
const labels = screen.getAllByRole("option").map((o) => o.textContent);
|
||||
expect(labels).toEqual(expect.arrayContaining(["active", "inactive", "pending"]));
|
||||
fireEvent.change(select, { target: { value: "pending" } });
|
||||
expect(onCommit).toHaveBeenCalledWith("pending");
|
||||
});
|
||||
it("commits null via Set NULL in enum mode", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="active"
|
||||
dataType="text"
|
||||
enumValues={["active", "inactive", "pending"]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
nullable
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/set null/i));
|
||||
expect(onCommit).toHaveBeenCalledWith(null);
|
||||
});
|
||||
it("filters FK options by query and commits the clicked value", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="1"
|
||||
dataType="integer"
|
||||
fkOptions={[
|
||||
{ value: "1", label: "1 — Alice" },
|
||||
{ value: "2", label: "2 — Bob" },
|
||||
]}
|
||||
onCommit={onCommit}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const search = screen.getByLabelText(/search foreign key/i);
|
||||
fireEvent.change(search, { target: { value: "bo" } });
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toHaveTextContent("2 — Bob");
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(onCommit).toHaveBeenCalledWith("2");
|
||||
});
|
||||
it("shows all FK options when the query is empty", () => {
|
||||
render(
|
||||
<CellEditor
|
||||
initialValue="1"
|
||||
dataType="integer"
|
||||
fkOptions={[
|
||||
{ value: "1", label: "1 — Alice" },
|
||||
{ value: "2", label: "2 — Bob" },
|
||||
]}
|
||||
onCommit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
it("renders text columns as a single-line scrolling textarea", () => {
|
||||
render(<CellEditor initialValue="long text" dataType="text" onCommit={vi.fn()} onCancel={vi.fn()} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input.tagName).toBe("TEXTAREA");
|
||||
expect(input.className).toContain("h-6");
|
||||
});
|
||||
|
||||
it("renders the FK placeholder and a No matches empty state", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="" dataType="integer"
|
||||
fkOptions={[{ value: "1", label: "1 — Alice" }]} fkPlaceholder="Search users…"
|
||||
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const search = screen.getByLabelText(/search foreign key/i);
|
||||
expect(search).toHaveAttribute("placeholder", "Search users…");
|
||||
fireEvent.change(search, { target: { value: "zzz" } });
|
||||
expect(screen.getByText("No matches")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the FK option list as a fixed-position overlay so it is never clipped", () => {
|
||||
const onCommit = vi.fn();
|
||||
render(<CellEditor initialValue="" dataType="integer"
|
||||
fkOptions={[{ value: "1", label: "1 — Alice" }, { value: "2", label: "2 — Bob" }]}
|
||||
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const list = screen.getByTestId("fk-options");
|
||||
expect(list.style.position).toBe("fixed");
|
||||
expect(document.body.contains(list)).toBe(true);
|
||||
expect(list).toHaveTextContent("1 — Alice");
|
||||
expect(list).toHaveTextContent("2 — Bob");
|
||||
});
|
||||
|
||||
it("renders FK options as one-row values only (FK-reference style, cap 4, fixed width)", () => {
|
||||
const onCommit = vi.fn();
|
||||
const cells = Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `col${i}`,
|
||||
value: `v${i}`,
|
||||
}));
|
||||
render(<CellEditor initialValue="" dataType="integer"
|
||||
fkOptions={[{ value: "1", label: "1", cells }]}
|
||||
onCommit={onCommit} onCancel={vi.fn()} />);
|
||||
const list = screen.getByTestId("fk-options");
|
||||
// first 3 values shown, column names NOT shown, 4th+ capped
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(list).toHaveTextContent(`v${i}`);
|
||||
}
|
||||
expect(list).not.toHaveTextContent("col0");
|
||||
expect(list).not.toHaveTextContent("v4");
|
||||
// fixed 360px width, FK-viewer surface styling
|
||||
expect(list.style.width).toBe("360px");
|
||||
expect(list.className).toContain("bg-surface");
|
||||
expect(list.className).toContain("rounded-lg");
|
||||
expect(list.className).toContain("shadow-xl");
|
||||
// clicking the row commits the value
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onCommit).toHaveBeenCalledWith("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface FkOption {
|
||||
value: string; // the referenced column's value (what gets committed)
|
||||
label: string; // fallback display text (e.g. "42 — Alice")
|
||||
/** Referenced row cells shown in one row (≤5 columns), FK-reference style. */
|
||||
cells?: { name: string; value: string }[];
|
||||
}
|
||||
|
||||
const MAX_FK_CELLS = 4;
|
||||
const FK_DROPDOWN_WIDTH = 360;
|
||||
|
||||
interface CellEditorProps {
|
||||
initialValue: string;
|
||||
dataType: string;
|
||||
nullable?: boolean;
|
||||
enumValues?: string[]; // when present → render <select> of these values
|
||||
fkOptions?: FkOption[]; // when present → render searchable dropdown
|
||||
fkPlaceholder?: string; // placeholder for the FK search input
|
||||
onCommit: (value: string | null) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
"w-full px-1 py-0.5 text-xs bg-surface border border-border rounded font-mono";
|
||||
|
||||
export function CellEditor({
|
||||
initialValue,
|
||||
dataType,
|
||||
nullable,
|
||||
enumValues,
|
||||
fkOptions,
|
||||
fkPlaceholder,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: CellEditorProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [setNull, setSetNull] = useState(initialValue === "" && nullable);
|
||||
const [query, setQuery] = useState("");
|
||||
const ref = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
|
||||
const enumRef = useRef<HTMLSelectElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [fkDropdownPos, setFkDropdownPos] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (fkOptions && fkOptions.length > 0 && searchRef.current) {
|
||||
const r = searchRef.current.getBoundingClientRect();
|
||||
let left = r.left;
|
||||
if (left + FK_DROPDOWN_WIDTH > window.innerWidth - 16) {
|
||||
left = Math.max(16, window.innerWidth - FK_DROPDOWN_WIDTH - 16);
|
||||
}
|
||||
setFkDropdownPos({
|
||||
top: r.bottom + 4,
|
||||
left,
|
||||
width: FK_DROPDOWN_WIDTH,
|
||||
});
|
||||
}
|
||||
}, [fkOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
enumRef.current?.focus();
|
||||
} else if (fkOptions && fkOptions.length > 0) {
|
||||
searchRef.current?.focus();
|
||||
searchRef.current?.select();
|
||||
} else {
|
||||
ref.current?.focus();
|
||||
ref.current?.select();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const large = ["json", "jsonb", "text"].some((t) =>
|
||||
dataType.toLowerCase().includes(t)
|
||||
);
|
||||
const Tag = large ? "textarea" : "input";
|
||||
|
||||
const commit = () => onCommit(setNull ? null : value);
|
||||
|
||||
const handleSetNull = (checked: boolean) => {
|
||||
setSetNull(checked);
|
||||
if (checked) onCommit(null);
|
||||
};
|
||||
|
||||
// Priority: enum > FK > default input/textarea
|
||||
if (enumValues && enumValues.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<select
|
||||
ref={enumRef}
|
||||
className={inputClass}
|
||||
value={initialValue}
|
||||
onChange={(e) => onCommit(setNull ? null : e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{nullable ? "NULL" : "—"}</option>
|
||||
{enumValues.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fkOptions && fkOptions.length > 0) {
|
||||
const q = query.trim().toLowerCase();
|
||||
const fkSearchText = (o: FkOption) =>
|
||||
[
|
||||
o.label,
|
||||
...(o.cells ?? []).map((c) => `${c.name}:${c.value}`),
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const filtered =
|
||||
q === ""
|
||||
? fkOptions
|
||||
: fkOptions.filter((o) => fkSearchText(o).includes(q));
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<input
|
||||
ref={searchRef}
|
||||
aria-label="Search foreign key options"
|
||||
placeholder={fkPlaceholder ?? "Search…"}
|
||||
className={inputClass}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (filtered.length > 0) onCommit(filtered[0].value);
|
||||
else onCommit(query);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fkDropdownPos &&
|
||||
createPortal(
|
||||
<div
|
||||
data-testid="fk-options"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: fkDropdownPos.top,
|
||||
left: fkDropdownPos.left,
|
||||
width: fkDropdownPos.width,
|
||||
minWidth: fkDropdownPos.width,
|
||||
zIndex: 50,
|
||||
}}
|
||||
className="max-h-28 overflow-y-auto bg-surface border border-border rounded-lg shadow-xl"
|
||||
>
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-2 py-1 text-xs text-text-muted">No matches</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
className="block w-full px-3 py-1.5 hover:bg-surface-raised text-xs text-left"
|
||||
onClick={() => onCommit(o.value)}
|
||||
>
|
||||
{o.cells && o.cells.length > 0 ? (
|
||||
<span className="flex items-center gap-0 min-w-0">
|
||||
{o.cells.slice(0, MAX_FK_CELLS).map((c, ci) => (
|
||||
<span key={ci} className="flex items-center min-w-0">
|
||||
{ci > 0 && (
|
||||
<span className="mx-1.5 h-3 w-px bg-border shrink-0" />
|
||||
)}
|
||||
{c.value === "" ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : (
|
||||
<span className="truncate text-text">{c.value}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
o.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cls = large ? `${inputClass} h-6 resize-none overflow-y-auto leading-none` : inputClass;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 p-1 bg-canvas border border-accent rounded">
|
||||
<Tag
|
||||
ref={ref as any}
|
||||
className={cls}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setSetNull(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{nullable && (
|
||||
<label className="flex items-center gap-1 text-[10px] text-text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setNull}
|
||||
onChange={(e) => handleSetNull(e.target.checked)}
|
||||
/>
|
||||
Set NULL
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { RowDetailDrawer } from "./RowDetailDrawer";
|
||||
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: "data", data_type: "jsonb", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("RowDetailDrawer", () => {
|
||||
it("renders every column value including hidden ones", () => {
|
||||
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={vi.fn()} />);
|
||||
expect(screen.getByText("id")).toBeInTheDocument();
|
||||
expect(screen.getByText("data")).toBeInTheDocument();
|
||||
expect(screen.getByText(/"a": 2/)).toBeInTheDocument();
|
||||
});
|
||||
it("copy button calls onCopy with the raw value", () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<RowDetailDrawer columns={cols} row={[1, { a: 2 }]} onClose={vi.fn()} onCopy={onCopy} />);
|
||||
const copyBtns = screen.getAllByRole("button", { name: /copy/i });
|
||||
fireEvent.click(copyBtns[1]); // copy the 'data' value
|
||||
expect(onCopy).toHaveBeenCalledWith(JSON.stringify({ a: 2 }, null, 2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { X, Copy } from "lucide-react";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInfo[];
|
||||
row: unknown[];
|
||||
onClose: () => void;
|
||||
onCopy: (value: string) => void;
|
||||
}
|
||||
function fmt(v: unknown): string {
|
||||
if (v === null || v === undefined) return "NULL";
|
||||
if (typeof v === "object") return JSON.stringify(v, null, 2);
|
||||
return String(v);
|
||||
}
|
||||
export function RowDetailDrawer({ columns, row, onClose, onCopy }: Props) {
|
||||
return (
|
||||
<div className="fixed top-0 right-0 h-full w-96 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">
|
||||
<span className="text-sm font-semibold text-text">Row detail</span>
|
||||
<button onClick={onClose} aria-label="Close" className="text-text-muted hover:text-text"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3 space-y-2">
|
||||
{columns.map((c, i) => {
|
||||
const v = row[i];
|
||||
const isJson = c.data_type === "json" || c.data_type === "jsonb";
|
||||
const text = fmt(v);
|
||||
return (
|
||||
<div key={c.name} className="border border-border rounded p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-text">{c.name}</span>
|
||||
<button onClick={() => onCopy(text)} aria-label={`Copy ${c.name}`} className="text-text-muted hover:text-text"><Copy size={12} /></button>
|
||||
</div>
|
||||
<pre className={`text-xs mt-1 whitespace-pre-wrap break-all ${isJson ? "font-mono text-accent/80" : "text-text-muted"}`}>{text}</pre>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { VirtualDataGrid } from "./VirtualDataGrid";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const mockColumns: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const mockRows: unknown[][] = [
|
||||
@@ -36,6 +36,78 @@ describe("VirtualDataGrid", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("focuses a cell on click and opens the editor on Enter for an editable cell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
const nameCell = screen.getAllByText("Alice")[0];
|
||||
fireEvent.click(nameCell);
|
||||
fireEvent.keyDown(nameCell, { key: "Enter" });
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows staged values passed from the parent + a pending dot", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{ "0:name": true }} />);
|
||||
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pending dot requires pendingKeys even when a staged value exists (committed → no dot)", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} pendingKeys={{}} />);
|
||||
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears staged values when the stagedValues prop empties (Clear All)", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
const { rerender } = render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{ "0:name": "Alicia" }} />);
|
||||
expect(screen.getByText("Alicia")).toBeInTheDocument();
|
||||
rerender(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" stagedValues={{}} />);
|
||||
expect(screen.getAllByText("Alice")[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText("Alicia")).toBeNull();
|
||||
});
|
||||
|
||||
it("Ctrl+C copies the focused cell value to the clipboard", async () => {
|
||||
const writeText = vi.fn();
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
const cell = screen.getAllByText("Alice")[0];
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "c", ctrlKey: true });
|
||||
expect(writeText).toHaveBeenCalledWith("Alice");
|
||||
});
|
||||
|
||||
it("does not open an editor for a PK cell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
const idCell = screen.getByText("1");
|
||||
fireEvent.click(idCell);
|
||||
fireEvent.keyDown(idCell, { key: "Enter" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders all rows when row count is small", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(
|
||||
@@ -51,12 +123,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={mockRows}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -79,12 +154,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={mockRows}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -109,12 +187,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={rows}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -130,12 +211,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={[]}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -147,41 +231,43 @@ describe("VirtualDataGrid", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}} />);
|
||||
onToggleRow={(i) => { toggled = i; }} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
fireEvent.click(checkboxes[1]); // first row checkbox
|
||||
expect(toggled).toBe(0);
|
||||
});
|
||||
|
||||
it("renders FK cells with clickable underline styling", () => {
|
||||
it("renders FK cells with an FK reference icon button", () => {
|
||||
const fkCols: ColumnInfo[] = [
|
||||
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null },
|
||||
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[42]]} columns={fkCols}
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
const fkCell = screen.getByText("42");
|
||||
expect(fkCell.className).toContain("cursor-pointer");
|
||||
expect(fkCell.className).toContain("underline");
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Open FK reference")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders JSON cells with preview label", () => {
|
||||
const jsonCols: ColumnInfo[] = [
|
||||
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ name: "metadata", data_type: "jsonb", is_nullable: false, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[[JSON.stringify({ key: "val", count: 3 })]]} columns={jsonCols}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
expect(screen.getByText(/2 keys/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -194,12 +280,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={[]}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -225,12 +314,15 @@ describe("VirtualDataGrid", () => {
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={[]}
|
||||
columns={[]}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -244,9 +336,10 @@ describe("VirtualDataGrid", () => {
|
||||
mockGetTotalSize.mockReturnValue(0);
|
||||
mockGetVirtualItems.mockReturnValue([]);
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" rows={[]} columns={mockColumns}
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={[]} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />);
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
const handles = document.querySelectorAll('[class*="cursor-col-resize"]');
|
||||
expect(handles.length).toBe(2); // one per visible column
|
||||
@@ -259,9 +352,10 @@ describe("VirtualDataGrid", () => {
|
||||
Array.from({ length: 20 }, (_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||
);
|
||||
render(
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={bigRows} columns={mockColumns}
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={bigRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />,
|
||||
);
|
||||
const checkboxes = screen.getAllByRole("checkbox");
|
||||
expect(checkboxes.length).toBeLessThan(50); // virtualized: only visible rows + select all
|
||||
@@ -274,9 +368,10 @@ describe("VirtualDataGrid", () => {
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||
);
|
||||
render(
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={allSelected}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />,
|
||||
);
|
||||
const selectAll = screen.getAllByRole("checkbox")[0] as HTMLInputElement;
|
||||
expect(selectAll.checked).toBe(true);
|
||||
@@ -289,11 +384,234 @@ describe("VirtualDataGrid", () => {
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 }))
|
||||
);
|
||||
render(
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" rows={mockRows} columns={mockColumns}
|
||||
<VirtualDataGrid connectionId="conn-1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={hidden} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}} />,
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />,
|
||||
);
|
||||
expect(screen.queryByText("name")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pending-edit dot on the pending cell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
|
||||
);
|
||||
|
||||
render(
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={mockRows}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
pendingCell={{ row: 0, col: 1 }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render a pending-edit dot without pendingCell", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(
|
||||
mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })),
|
||||
);
|
||||
|
||||
render(
|
||||
<VirtualDataGrid
|
||||
connectionId="conn-1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={mockRows}
|
||||
columns={mockColumns}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={() => {}}
|
||||
onToggleAll={() => {}}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
});
|
||||
|
||||
// ── GRID-A: context menu + editing behavior ─────────────────────────
|
||||
|
||||
it("opens the context menu on right-click and View Row calls onOpenRowDetail", () => {
|
||||
let opened = -1;
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" onOpenRowDetail={(i) => { opened = i; }} />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Alice"));
|
||||
expect(screen.getByText("View Row")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("View Row"));
|
||||
expect(opened).toBe(0);
|
||||
});
|
||||
|
||||
it("context menu Select Row calls onToggleRow with the row index", () => {
|
||||
let toggled = -1;
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={(i) => { toggled = i; }} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Bob"));
|
||||
fireEvent.click(screen.getByText("Select Row"));
|
||||
expect(toggled).toBe(1);
|
||||
});
|
||||
|
||||
it("closes the context menu when clicking the backdrop", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Alice"));
|
||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("ctx-backdrop"));
|
||||
expect(screen.queryByText("Copy")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels in-cell editing with Escape even when the editor input is unfocused", () => {
|
||||
mockGetTotalSize.mockReturnValue(mockRows.length * 36);
|
||||
mockGetVirtualItems.mockReturnValue(mockRows.map((_, i) => ({ key: i, index: i, start: i * 36, size: 36 })));
|
||||
render(<VirtualDataGrid connectionId="c1" schema="public" table="users" rows={mockRows} columns={mockColumns}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()} onToggleRow={vi.fn()} onToggleAll={vi.fn()}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
const cell = screen.getAllByText("Alice")[0];
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "Enter" });
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
|
||||
// Editor input is not the event target — the document-level listener must cancel.
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the FK preview popover from the context menu", () => {
|
||||
const fkCols: ColumnInfo[] = [
|
||||
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("42"));
|
||||
fireEvent.click(screen.getByText("Open FK reference"));
|
||||
|
||||
// Popover header renders the referenced table synchronously.
|
||||
expect(screen.getByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking an FK cell does NOT open the FK preview (only the icon does)", () => {
|
||||
const fkCols: ColumnInfo[] = [
|
||||
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(<VirtualDataGrid connectionId="conn-1" schema="public" table="orders" rows={[[42]]} columns={fkCols}
|
||||
hiddenColumns={new Set()} selectedRows={new Set()}
|
||||
onToggleRow={() => {}} onToggleAll={() => {}}
|
||||
dbType="postgresql" tabType="table" />);
|
||||
|
||||
fireEvent.click(screen.getByText("42"));
|
||||
expect(screen.queryByText("public.users")).toBeNull();
|
||||
|
||||
// The FK icon button opens the preview popover.
|
||||
fireEvent.click(screen.getByLabelText("Open FK reference"));
|
||||
expect(screen.getByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── GRID-C: enum + FK options fed into CellEditor ────────────────
|
||||
|
||||
it("renders an enum <select> with the column's labels when editing", () => {
|
||||
const enumCols: ColumnInfo[] = [
|
||||
{ name: "status", data_type: "user_role", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(
|
||||
<VirtualDataGrid
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="users"
|
||||
rows={[["active"]]}
|
||||
columns={enumCols}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={vi.fn()}
|
||||
onToggleAll={vi.fn()}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
enumValues={{ status: ["active", "inactive"] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cell = screen.getByText("active");
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "Enter" });
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "active" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "inactive" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a searchable FK dropdown with the referenced rows when editing", () => {
|
||||
const fkCols: ColumnInfo[] = [
|
||||
{ name: "user_id", data_type: "integer", is_nullable: false, is_pk: false, is_fk: true, fk_ref: ["users", "id"], default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
mockGetTotalSize.mockReturnValue(36);
|
||||
mockGetVirtualItems.mockReturnValue([{ key: 0, index: 0, start: 0, size: 36 }]);
|
||||
|
||||
render(
|
||||
<VirtualDataGrid
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="orders"
|
||||
rows={[[42]]}
|
||||
columns={fkCols}
|
||||
hiddenColumns={new Set()}
|
||||
selectedRows={new Set()}
|
||||
onToggleRow={vi.fn()}
|
||||
onToggleAll={vi.fn()}
|
||||
dbType="postgresql"
|
||||
tabType="table"
|
||||
fkOptions={{
|
||||
user_id: [
|
||||
{ value: "1", label: "1 — Alice" },
|
||||
{ value: "2", label: "2 — Bob" },
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cell = screen.getByText("42");
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "Enter" });
|
||||
|
||||
expect(screen.getByLabelText(/search foreign key/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("1 — Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 — Bob")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,50 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Key, Braces } from "lucide-react";
|
||||
import { Key, Braces, ArrowUpRight } from "lucide-react";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import { abbreviateType } from "../../lib/utils";
|
||||
import { FkPreviewPopover } from "../db-viewer/FkPreviewPopover";
|
||||
import { JsonCellPopover, jsonPreview } from "../db-viewer/JsonCellPopover";
|
||||
import { CellEditor, type FkOption } from "./CellEditor";
|
||||
import { CellContextMenu } from "./CellContextMenu";
|
||||
import { cellToUpdateChange, isCellEditable } from "./gridEditability";
|
||||
import { nextCell, type CellPos } from "./keyboardNav";
|
||||
|
||||
interface VirtualDataGridProps {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table?: string;
|
||||
rows: unknown[][];
|
||||
columns: ColumnInfo[];
|
||||
hiddenColumns: Set<string>;
|
||||
selectedRows: Set<number>;
|
||||
onToggleRow: (rowIndex: number) => void;
|
||||
onToggleAll: () => void;
|
||||
dbType?: string;
|
||||
tabType?: "table" | "query";
|
||||
onStageEdit?: (payload: {
|
||||
type: "update";
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
}) => void;
|
||||
onOpenRowDetail?: (rowIndex: number) => void;
|
||||
getLocator?: (row: unknown[]) => Record<string, unknown>;
|
||||
readOnly?: boolean;
|
||||
/** When set, renders a pending-edit indicator on the staged cell at (row, col). */
|
||||
pendingCell?: { row: number; col: number } | null;
|
||||
/** Enum labels keyed by column NAME → renders a <select> in the CellEditor. */
|
||||
enumValues?: Record<string, string[]>;
|
||||
/** Foreign-key reference rows keyed by column NAME → renders a searchable dropdown in the CellEditor. */
|
||||
fkOptions?: Record<string, FkOption[]>;
|
||||
/** Placeholder text for the FK search input, keyed by column NAME. */
|
||||
fkPlaceholders?: Record<string, string>;
|
||||
/** Optimistic staged cell values keyed `${rowIndex}:${colName}` → value (null = NULL), from the changes queue. */
|
||||
stagedValues?: Record<string, string | null>;
|
||||
/** Keys of cells with a PENDING (not yet committed) update → drives the amber dot. */
|
||||
pendingKeys?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -25,12 +55,25 @@ const MAX_COL_WIDTH = 800;
|
||||
export function VirtualDataGrid({
|
||||
connectionId,
|
||||
schema,
|
||||
table = "",
|
||||
rows,
|
||||
columns,
|
||||
hiddenColumns,
|
||||
selectedRows,
|
||||
onToggleRow,
|
||||
onToggleAll,
|
||||
dbType = "postgresql",
|
||||
tabType = "table",
|
||||
onStageEdit,
|
||||
onOpenRowDetail,
|
||||
getLocator,
|
||||
readOnly = false,
|
||||
pendingCell = null,
|
||||
enumValues,
|
||||
fkOptions,
|
||||
fkPlaceholders,
|
||||
stagedValues,
|
||||
pendingKeys,
|
||||
}: VirtualDataGridProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -53,6 +96,45 @@ export function VirtualDataGrid({
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// ── focus / editing / context menu / row detail state ──
|
||||
|
||||
const [activeCell, setActiveCell] = useState<CellPos | null>(null);
|
||||
const [editingCell, setEditingCell] = useState<CellPos | null>(null);
|
||||
|
||||
// Optimistic staged cell values come from the parent via `stagedValues`
|
||||
// (derived from the changes queue), so clearing the queue clears them.
|
||||
const [pendingCellKey, setPendingCellKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingCellKey(null);
|
||||
}, [stagedValues]);
|
||||
const [ctxMenu, setCtxMenu] = useState<{ pos: DOMRect; row: number; col: number } | null>(null);
|
||||
|
||||
// Reset transient focus state when the data shape changes.
|
||||
useEffect(() => {
|
||||
setActiveCell(null);
|
||||
setEditingCell(null);
|
||||
setCtxMenu(null);
|
||||
}, [rows.length, columns.length, hiddenColumns.size]);
|
||||
|
||||
// Document-level Escape: cancels in-cell editing even when the editor input
|
||||
// has lost focus, and closes the context menu when open.
|
||||
useEffect(() => {
|
||||
const editing = editingCell != null;
|
||||
const menuOpen = ctxMenu != null;
|
||||
if (!editing && !menuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (editing) {
|
||||
setEditingCell(null);
|
||||
setActiveCell(null);
|
||||
}
|
||||
if (menuOpen) setCtxMenu(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [editingCell != null, ctxMenu != null]);
|
||||
|
||||
// ── column widths ─────────────────────────────────────
|
||||
|
||||
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||
@@ -138,42 +220,126 @@ export function VirtualDataGrid({
|
||||
anchorRect: DOMRect | null;
|
||||
} | null>(null);
|
||||
|
||||
// ── keyboard navigation ───────────────────────────────
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (editingCell) return;
|
||||
if (!activeCell) return;
|
||||
|
||||
const keyLabel = e.key === "Tab" ? (e.shiftKey ? "Shift+Tab" : "Tab") : e.key;
|
||||
|
||||
if (keyLabel.startsWith("Arrow") || keyLabel === "Tab" || keyLabel === "Shift+Tab") {
|
||||
e.preventDefault();
|
||||
const next = nextCell(activeCell, keyLabel, rows.length, visibleColumns.length);
|
||||
setActiveCell(next);
|
||||
virtualizer.scrollToIndex(next.row);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
const col = visibleColumns[activeCell.col];
|
||||
if (col && isCellEditable(col, tabType, dbType, readOnly)) {
|
||||
e.preventDefault();
|
||||
setEditingCell(activeCell);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setActiveCell(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "c" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
const col = visibleColumns[activeCell.col];
|
||||
if (!col) return;
|
||||
const ci = columns.findIndex((c) => c.name === col.name);
|
||||
const value = rows[activeCell.row]?.[ci];
|
||||
navigator.clipboard.writeText(String(value));
|
||||
}
|
||||
},
|
||||
[activeCell, columns, dbType, editingCell, rows, tabType, visibleColumns, virtualizer],
|
||||
);
|
||||
|
||||
// ── cell renderer (shared between header sizing and body) ──
|
||||
|
||||
const renderCell = useCallback(
|
||||
(col: ColumnInfo, row: unknown[], _rowIndex: number) => {
|
||||
(col: ColumnInfo, row: unknown[], rowIndex: number, colIndex: number) => {
|
||||
const ci = columns.findIndex((c) => c.name === col.name);
|
||||
const cell = ci >= 0 ? row[ci] : undefined;
|
||||
const isNull = cell === null || cell === undefined;
|
||||
const cellKey = `${rowIndex}:${col.name}`;
|
||||
const stagedDefined = stagedValues ? cellKey in stagedValues : false;
|
||||
const displayCell = stagedDefined
|
||||
? stagedValues![cellKey]
|
||||
: cell;
|
||||
const displayIsNull =
|
||||
displayCell === null || displayCell === undefined;
|
||||
const isNull = displayIsNull;
|
||||
const isFk = col.is_fk && col.fk_ref && !isNull;
|
||||
const isJson = !isNull && (col.data_type === "jsonb" || col.data_type === "json");
|
||||
const jp = isJson ? jsonPreview(cell) : { label: "", isJson: false };
|
||||
const jp = isJson ? jsonPreview(displayCell) : { label: "", isJson: false };
|
||||
const editable = isCellEditable(col, tabType, dbType, readOnly);
|
||||
const isActive = activeCell?.row === rowIndex && activeCell?.col === colIndex;
|
||||
const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex;
|
||||
const isPending =
|
||||
(pendingCell?.row === rowIndex && pendingCell?.col === colIndex) ||
|
||||
pendingCellKey === cellKey ||
|
||||
(pendingKeys ? cellKey in pendingKeys : false);
|
||||
|
||||
const handleJsonClick = (e: React.MouseEvent) => {
|
||||
if (isJson) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setJsonPopover({ value: cell, anchorRect: rect });
|
||||
setJsonPopover({ value: displayCell, anchorRect: rect });
|
||||
}
|
||||
};
|
||||
|
||||
const commitEdit = (committed: string | null) => {
|
||||
// oldData must be the DB value (the un-staged cell), so the queue's
|
||||
// revert/display stays correct even after repeated edits of the same cell.
|
||||
const dbValue = ci >= 0 ? row[ci] : undefined;
|
||||
if (committed !== (dbValue === null || dbValue === undefined ? null : dbValue)) {
|
||||
const locator = getLocator?.(row) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
schema,
|
||||
table,
|
||||
primaryKey: locator,
|
||||
oldData: { [col.name]: dbValue },
|
||||
newData: { [col.name]: committed },
|
||||
}) as {
|
||||
type: "update";
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
}
|
||||
setPendingCellKey(cellKey);
|
||||
setEditingCell(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.name}
|
||||
className={`px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
|
||||
className={`relative px-3 py-2 font-heading text-xs truncate select-text border-r border-border self-stretch ${
|
||||
isFk ? "cursor-pointer underline decoration-dotted underline-offset-2 hover:text-accent" : ""
|
||||
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""}`}
|
||||
role={isFk || isJson ? "button" : undefined}
|
||||
tabIndex={isFk || isJson ? 0 : undefined}
|
||||
} ${isJson ? "cursor-pointer text-accent/80 hover:text-accent" : ""} ${
|
||||
isActive ? "bg-accent/10 ring-1 ring-inset ring-accent outline-none" : ""
|
||||
}`}
|
||||
role={isJson ? "button" : undefined}
|
||||
tabIndex={isJson ? 0 : -1}
|
||||
onKeyDown={
|
||||
isFk || isJson
|
||||
isJson
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (isFk) handleFkClick(col, cell, e as any);
|
||||
else if (isJson) {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setJsonPopover({ value: cell, anchorRect: rect });
|
||||
}
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setJsonPopover({ value: cell, anchorRect: rect });
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
@@ -183,37 +349,128 @@ export function VirtualDataGrid({
|
||||
isNull
|
||||
? "NULL"
|
||||
: isFk
|
||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(cell)}`
|
||||
? `FK → ${col.fk_ref![0]}.${col.fk_ref![1]}: ${String(displayCell)}`
|
||||
: isJson
|
||||
? "Click to view JSON"
|
||||
: String(cell)
|
||||
}
|
||||
onClick={
|
||||
isFk
|
||||
? (e) => handleFkClick(col, cell, e)
|
||||
: isJson
|
||||
? handleJsonClick
|
||||
: undefined
|
||||
: String(displayCell)
|
||||
}
|
||||
onClick={(e) => {
|
||||
setActiveCell({ row: rowIndex, col: colIndex });
|
||||
if (isJson) handleJsonClick(e);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (editable) setEditingCell({ row: rowIndex, col: colIndex });
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setCtxMenu({ pos: rect, row: rowIndex, col: colIndex });
|
||||
setActiveCell({ row: rowIndex, col: colIndex });
|
||||
}}
|
||||
>
|
||||
{isNull ? (
|
||||
{isEditing ? (
|
||||
<div className="absolute inset-0 z-20" onClick={(e) => e.stopPropagation()}>
|
||||
<CellEditor
|
||||
initialValue={isNull ? "" : String(displayCell)}
|
||||
dataType={col.data_type}
|
||||
nullable={col.is_nullable}
|
||||
enumValues={enumValues?.[col.name]}
|
||||
fkOptions={fkOptions?.[col.name]}
|
||||
fkPlaceholder={fkPlaceholders?.[col.name]}
|
||||
onCommit={commitEdit}
|
||||
onCancel={() => setEditingCell(null)}
|
||||
/>
|
||||
</div>
|
||||
) : isNull ? (
|
||||
<span className="italic text-text-muted">NULL</span>
|
||||
) : isJson ? (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Braces size={10} className="shrink-0" />
|
||||
{jp.label}
|
||||
</span>
|
||||
) : isFk && displayCell !== null && displayCell !== undefined ? (
|
||||
<span className="inline-flex items-center gap-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open FK reference"
|
||||
title={`FK → ${col.fk_ref![0]}.${col.fk_ref![1]}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleFkClick(col, displayCell, e);
|
||||
}}
|
||||
className="shrink-0 text-text-muted hover:text-accent"
|
||||
>
|
||||
<ArrowUpRight size={11} />
|
||||
</button>
|
||||
<span className="truncate">{String(displayCell)}</span>
|
||||
</span>
|
||||
) : (
|
||||
String(cell)
|
||||
String(displayCell)
|
||||
)}
|
||||
{isPending && (
|
||||
<span
|
||||
className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400"
|
||||
data-testid="pending-edit-dot"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[columns, getWidth, handleFkClick],
|
||||
[activeCell, columns, dbType, editingCell, enumValues, fkOptions, fkPlaceholders, getLocator, handleFkClick, onStageEdit, schema, table, tabType, getWidth, pendingCell, stagedValues, pendingKeys, pendingCellKey],
|
||||
);
|
||||
|
||||
// ── context menu helpers ──────────────────────────────
|
||||
|
||||
const ctxCol = ctxMenu ? visibleColumns[ctxMenu.col] : null;
|
||||
const copyCellValue = useCallback(
|
||||
async (row: number, col: number) => {
|
||||
const column = visibleColumns[col];
|
||||
if (!column) return;
|
||||
const ci = columns.findIndex((c) => c.name === column.name);
|
||||
const value = rows[row]?.[ci];
|
||||
await navigator.clipboard.writeText(String(value));
|
||||
},
|
||||
[columns, rows, visibleColumns],
|
||||
);
|
||||
|
||||
const stageNull = useCallback(
|
||||
(row: number, col: number) => {
|
||||
const column = visibleColumns[col];
|
||||
if (!column || !isCellEditable(column, tabType, dbType, readOnly)) return;
|
||||
const ci = columns.findIndex((c) => c.name === column.name);
|
||||
const value = rows[row]?.[ci];
|
||||
if (value === null || value === undefined) return;
|
||||
const locator = getLocator?.(rows[row]) ?? {};
|
||||
onStageEdit?.(
|
||||
cellToUpdateChange({
|
||||
schema,
|
||||
table,
|
||||
primaryKey: locator,
|
||||
oldData: { [column.name]: value },
|
||||
newData: { [column.name]: null },
|
||||
}) as {
|
||||
type: "update";
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
},
|
||||
[columns, dbType, getLocator, onStageEdit, readOnly, rows, schema, table, tabType, visibleColumns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="overflow-auto h-full" style={{ overscrollBehavior: "none" }}>
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="overflow-auto h-full outline-none"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
tabIndex={-1}
|
||||
role="grid"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* ── sticky header (hidden when no columns/table open) ── */}
|
||||
{hasColumns && (
|
||||
<div className="sticky top-0 z-10">
|
||||
@@ -285,7 +542,10 @@ export function VirtualDataGrid({
|
||||
}}
|
||||
>
|
||||
{hasColumns && (
|
||||
<div style={{ width: 40, minWidth: 40 }} className="flex items-center justify-center border-r border-border self-stretch">
|
||||
<div
|
||||
style={{ width: 40, minWidth: 40 }}
|
||||
className="flex items-center justify-center border-r border-border self-stretch"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -294,7 +554,7 @@ export function VirtualDataGrid({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{visibleColumns.map((col) => renderCell(col, row, virtualRow.index))}
|
||||
{visibleColumns.map((col, i) => renderCell(col, row, virtualRow.index, i))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -321,6 +581,69 @@ export function VirtualDataGrid({
|
||||
onClose={() => setJsonPopover(null)}
|
||||
/>
|
||||
)}
|
||||
{/* Cell context menu */}
|
||||
{ctxMenu && ctxCol && (
|
||||
<>
|
||||
{/* Click-outside-to-close backdrop (below the z-50 menu) */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
data-testid="ctx-backdrop"
|
||||
onClick={() => setCtxMenu(null)}
|
||||
/>
|
||||
<CellContextMenu
|
||||
anchorRect={ctxMenu.pos}
|
||||
editable={isCellEditable(ctxCol, tabType, dbType, readOnly)}
|
||||
isJson={ctxCol.data_type === "jsonb" || ctxCol.data_type === "json"}
|
||||
isFk={ctxCol.is_fk && ctxCol.fk_ref != null}
|
||||
nullable={ctxCol.is_nullable}
|
||||
onCopy={() => {
|
||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onCopyJson={() => {
|
||||
void copyCellValue(ctxMenu.row, ctxMenu.col);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onViewRow={() => {
|
||||
onOpenRowDetail?.(ctxMenu.row);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onSelectRow={() => {
|
||||
onToggleRow(ctxMenu.row);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onEdit={() => {
|
||||
setActiveCell({ row: ctxMenu.row, col: ctxMenu.col });
|
||||
setEditingCell({ row: ctxMenu.row, col: ctxMenu.col });
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onSetNull={() => {
|
||||
stageNull(ctxMenu.row, ctxMenu.col);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onOpenFk={() => {
|
||||
if (ctxCol?.is_fk && ctxCol.fk_ref) {
|
||||
// Resolve the column index into `rows` (ctxMenu.col indexes visibleColumns,
|
||||
// which can differ when columns are hidden).
|
||||
const ci = columns.findIndex((c) => c.name === ctxCol.name);
|
||||
const cellValue = rows[ctxMenu.row]?.[ci];
|
||||
if (cellValue !== null && cellValue !== undefined) {
|
||||
setFkPreview({
|
||||
connectionId,
|
||||
schema,
|
||||
table: ctxCol.fk_ref[0],
|
||||
column: ctxCol.fk_ref[1],
|
||||
value: String(cellValue),
|
||||
anchorRect: ctxMenu.pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isCellEditable, defaultFilterOperator, cellToUpdateChange } from "./gridEditability";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const col = (over: Partial<ColumnInfo> = {}): ColumnInfo => ({
|
||||
name: "c", data_type: "text", is_nullable: true, is_pk: false, is_fk: false,
|
||||
fk_ref: null, default_value: null, editable: true, is_generated: false, ...over,
|
||||
});
|
||||
|
||||
describe("gridEditability", () => {
|
||||
it("isCellEditable is false for PK, generated, non-editable flag, or view/tabType!=table", () => {
|
||||
expect(isCellEditable(col({ is_pk: true }), "table", "postgresql")).toBe(false);
|
||||
expect(isCellEditable(col({ is_generated: true }), "table", "postgresql")).toBe(false);
|
||||
expect(isCellEditable(col({ editable: false }), "table", "postgresql")).toBe(false);
|
||||
expect(isCellEditable(col(), "query", "postgresql")).toBe(false);
|
||||
expect(isCellEditable(col(), "table", "mysql")).toBe(false);
|
||||
expect(isCellEditable(col(), "table", "postgresql")).toBe(true);
|
||||
expect(isCellEditable(col(), "table", "sqlite")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaultFilterOperator picks by data type", () => {
|
||||
expect(defaultFilterOperator("text")).toBe("contains");
|
||||
expect(defaultFilterOperator("uuid")).toBe("contains");
|
||||
expect(defaultFilterOperator("integer")).toBe("eq");
|
||||
expect(defaultFilterOperator("timestamptz")).toBe("eq");
|
||||
expect(defaultFilterOperator("boolean")).toBe("eq");
|
||||
expect(defaultFilterOperator("USER-DEFINED")).toBe("eq");
|
||||
});
|
||||
|
||||
it("cellToUpdateChange builds the update payload using a locator when no PK", () => {
|
||||
const out = cellToUpdateChange({
|
||||
schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" },
|
||||
});
|
||||
expect(out).toEqual({ type: "update", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "A" }, newData: { name: "B" } });
|
||||
});
|
||||
it("cellToUpdateChange uses row locator as primaryKey when PK absent", () => {
|
||||
const out = cellToUpdateChange({
|
||||
schema: "public", table: "no_pk",
|
||||
primaryKey: { ctid: "(0,1)" }, oldData: { name: "A" }, newData: { name: "B" },
|
||||
});
|
||||
expect(out.primaryKey).toEqual({ ctid: "(0,1)" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
|
||||
|
||||
export type TabKind = "table" | "query";
|
||||
export type EditableDbType = "postgresql" | "sqlite";
|
||||
|
||||
/** A cell is editable iff: table tab, PG/SQLite, column flagged editable, not PK, not generated, and not read-only. */
|
||||
export function isCellEditable(col: ColumnInfo, tabType: TabKind, dbType: string, readOnly?: boolean): boolean {
|
||||
if (readOnly) return false;
|
||||
if (tabType !== "table") return false;
|
||||
if (dbType !== "postgresql" && dbType !== "sqlite") return false;
|
||||
if (!col.editable) return false;
|
||||
if (col.is_pk) return false;
|
||||
if (col.is_generated) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Default filter operator inferred from column data type. */
|
||||
export function defaultFilterOperator(dataType: string): "contains" | "eq" {
|
||||
const t = dataType.toLowerCase();
|
||||
const textish = ["text", "varchar", "char", "bpchar", "name", "uuid"];
|
||||
if (textish.some((x) => t.includes(x))) return "contains";
|
||||
return "eq";
|
||||
}
|
||||
|
||||
/** Build the update change payload for a single edited cell. */
|
||||
export function cellToUpdateChange(input: {
|
||||
schema: string; table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
}): { type: ChangeItemType; schema: string; table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown> } {
|
||||
return { type: "update", ...input };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nextCell } from "./keyboardNav";
|
||||
|
||||
describe("keyboardNav", () => {
|
||||
it("ArrowRight moves right, clamps at last col", () => {
|
||||
expect(nextCell({ row: 0, col: 0 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 1 });
|
||||
expect(nextCell({ row: 0, col: 2 }, "ArrowRight", 5, 3)).toEqual({ row: 0, col: 2 });
|
||||
});
|
||||
it("ArrowLeft moves left, clamps at 0", () => {
|
||||
expect(nextCell({ row: 1, col: 1 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
|
||||
expect(nextCell({ row: 1, col: 0 }, "ArrowLeft", 5, 3)).toEqual({ row: 1, col: 0 });
|
||||
});
|
||||
it("ArrowDown/ArrowUp move row, clamp", () => {
|
||||
expect(nextCell({ row: 0, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 1, col: 1 });
|
||||
expect(nextCell({ row: 4, col: 1 }, "ArrowDown", 5, 3)).toEqual({ row: 4, col: 1 });
|
||||
expect(nextCell({ row: 4, col: 1 }, "ArrowUp", 5, 3)).toEqual({ row: 3, col: 1 });
|
||||
});
|
||||
it("Tab wraps to next row; Shift+Tab wraps back", () => {
|
||||
expect(nextCell({ row: 0, col: 2 }, "Tab", 5, 3)).toEqual({ row: 1, col: 0 });
|
||||
expect(nextCell({ row: 1, col: 0 }, "Shift+Tab", 5, 3)).toEqual({ row: 0, col: 2 });
|
||||
});
|
||||
it("unknown key returns same cell", () => {
|
||||
expect(nextCell({ row: 1, col: 1 }, "x", 5, 3)).toEqual({ row: 1, col: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface CellPos { row: number; col: number; }
|
||||
|
||||
export function nextCell(pos: CellPos, key: string, rowCount: number, colCount: number): CellPos {
|
||||
let { row, col } = pos;
|
||||
switch (key) {
|
||||
case "ArrowRight":
|
||||
col = Math.min(col + 1, colCount - 1); break;
|
||||
case "ArrowLeft":
|
||||
col = Math.max(col - 1, 0); break;
|
||||
case "ArrowDown":
|
||||
row = Math.min(row + 1, rowCount - 1); break;
|
||||
case "ArrowUp":
|
||||
row = Math.max(row - 1, 0); break;
|
||||
case "Tab":
|
||||
if (col + 1 < colCount) col += 1;
|
||||
else { col = 0; row = Math.min(row + 1, rowCount - 1); }
|
||||
break;
|
||||
case "Shift+Tab":
|
||||
if (col - 1 >= 0) col -= 1;
|
||||
else { col = colCount - 1; row = Math.max(row - 1, 0); }
|
||||
break;
|
||||
default:
|
||||
return pos;
|
||||
}
|
||||
return { row, col };
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ActionRow } from "./ActionRow";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
|
||||
beforeEach(() => useUiStore.setState({ activeView: "home" }));
|
||||
beforeEach(() => useUiStore.setState({ activeView: "home", selectedItemIds: [] }));
|
||||
|
||||
describe("ActionRow", () => {
|
||||
it("renders Saved Connections title", () => {
|
||||
@@ -21,4 +21,11 @@ describe("ActionRow", () => {
|
||||
await userEvent.click(screen.getByText(/settings/i));
|
||||
expect(useUiStore.getState().activeView).toBe("settings");
|
||||
});
|
||||
it("shows a Move to folder button when selection exists and calls onMoveToFolder", async () => {
|
||||
useUiStore.setState({ selectedItemIds: ["c1"] });
|
||||
const onMoveToFolder = vi.fn();
|
||||
render(<ActionRow onMoveToFolder={onMoveToFolder} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /move to folder/i }));
|
||||
expect(onMoveToFolder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Plus, Settings as SettingsIcon, FolderPlus, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Plus, Settings as SettingsIcon, FolderPlus, FolderInput, Trash2, Check, X, ChevronDown } from "lucide-react";
|
||||
import { Button } from "../ui/Button";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { ImportExportMenu } from "./ImportExportMenu";
|
||||
@@ -12,10 +12,11 @@ interface ActionRowProps {
|
||||
onNewFolder?: () => void;
|
||||
onFilters?: () => void;
|
||||
onDeleteSelected?: () => void;
|
||||
onMoveToFolder?: () => void;
|
||||
visibleItemIds?: string[];
|
||||
}
|
||||
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, visibleItemIds = [] }: ActionRowProps) {
|
||||
export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilters, onDeleteSelected, onMoveToFolder, visibleItemIds = [] }: ActionRowProps) {
|
||||
const setActiveView = useUiStore((s) => s.setActiveView);
|
||||
const openSettings = useUiStore((s) => s.openSettings);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
@@ -52,6 +53,11 @@ export function ActionRow({ onImport, onExport, onNewFolder, onFilters: _onFilte
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onNewFolder ?? (() => {})}>
|
||||
<FolderPlus size={14} /> New Folder
|
||||
</Button>
|
||||
{hasSelection && (
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={onMoveToFolder}>
|
||||
<FolderInput size={14} /> Move to folder
|
||||
</Button>
|
||||
)}
|
||||
{hasSelection && (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Button variant="ghost" className="text-xs border-0" onClick={() => setMenuOpen((o) => !o)}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HomeScreen } from "./HomeScreen";
|
||||
import { useConnectionStore } from "../../stores/connectionStore";
|
||||
@@ -12,6 +12,8 @@ vi.mock("../../lib/commands", () => ({
|
||||
getFolders: vi.fn().mockResolvedValue([]),
|
||||
getTags: vi.fn().mockResolvedValue([]),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
getRecentConnections: vi.fn().mockResolvedValue([{ connection_id: "recent-1", opened_at: "" }]),
|
||||
recordRecentConnection: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConnection: vi.fn().mockResolvedValue(undefined),
|
||||
deleteConnectionPassword: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFolder: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -25,7 +27,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
|
||||
|
||||
describe("HomeScreen", () => {
|
||||
beforeEach(() => {
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], loading: false, error: null });
|
||||
useConnectionStore.setState({ connections: [], folders: [], tags: [], recent: [], loading: false, error: null });
|
||||
useUiStore.setState({ searchQuery: "", activeFolderId: null, activeTagIds: [], activeDbTypes: [], activeView: "home", selectedItemIds: [] });
|
||||
useSettingsStore.setState({ settings: null, loading: false, error: null });
|
||||
// SearchBar stores its debounce timer on window.__sb; clear any timer leaked
|
||||
@@ -125,6 +127,83 @@ describe("HomeScreen", () => {
|
||||
expect(screen.queryByText(/are you sure you want to delete/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("animated-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("records a recent connection when opening a connection", async () => {
|
||||
const user = userEvent.setup();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
await user.click(screen.getByText("Local DB"));
|
||||
const { recordRecentConnection } = await import("../../lib/commands");
|
||||
await waitFor(() =>
|
||||
expect(recordRecentConnection).toHaveBeenCalledWith("conn-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the recent connections strip at the root when recents exist", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConnection("recent-1")] });
|
||||
useUiStore.setState({ activeFolderId: null, searchQuery: "" });
|
||||
render(<HomeScreen />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /local db/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("opens EditConnectionModal from the connection card kebab menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Edit…"));
|
||||
|
||||
expect(await screen.findByText("Edit Connection")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Local DB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the confirmation dialog before deleting a connection from the kebab menu by default", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Delete…"));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/are you sure you want to delete \"Local DB\"/i),
|
||||
).toBeInTheDocument();
|
||||
const { deleteConnection } = await import("../../lib/commands");
|
||||
expect(deleteConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes a connection from the kebab menu without confirmation when confirm_before_delete is false", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...baseSettings(),
|
||||
confirm_before_delete: false,
|
||||
},
|
||||
});
|
||||
useConnectionStore.setState({ connections: [makeConnection("conn-1")] });
|
||||
render(<HomeScreen />);
|
||||
|
||||
await user.click(screen.getByLabelText("Connection actions"));
|
||||
await user.click(screen.getByText("Manage"));
|
||||
await user.click(screen.getByText("Delete…"));
|
||||
|
||||
const { deleteConnection } = await import("../../lib/commands");
|
||||
await waitFor(() =>
|
||||
expect(deleteConnection).toHaveBeenCalledWith("conn-1"),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/are you sure you want to delete/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function makeConnection(id: string): Connection {
|
||||
@@ -138,6 +217,7 @@ function makeConnection(id: string): Connection {
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
@@ -13,10 +13,13 @@ import { ConnectionCard } from "../connections/ConnectionCard";
|
||||
import { CreateFolderDialog } from "../folders/CreateFolderDialog";
|
||||
import { EditFolderDialog } from "../folders/EditFolderDialog";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { EditConnectionModal } from "../db-viewer/EditConnectionModal";
|
||||
import { MoveToFolderDialog } from "../connections/MoveToFolderDialog";
|
||||
import { RecentConnectionsStrip } from "../connections/RecentConnectionsStrip";
|
||||
import { handleImport, handleExport } from "../../lib/importExport";
|
||||
import { getChildFolders } from "../../lib/utils";
|
||||
import { useShortcut } from "../../hooks/useShortcut";
|
||||
import type { Folder } from "../../lib/types";
|
||||
import type { Connection, Folder } from "../../lib/types";
|
||||
|
||||
export function HomeScreen() {
|
||||
const connections = useFilteredConnections();
|
||||
@@ -31,15 +34,22 @@ export function HomeScreen() {
|
||||
const deleteFolder = useConnectionStore((s) => s.deleteFolder);
|
||||
const deleteConnection = useConnectionStore((s) => s.deleteConnection);
|
||||
const loadAll = useConnectionStore((s) => s.loadAll);
|
||||
const moveSelectionToFolder = useConnectionStore((s) => s.moveSelectionToFolder);
|
||||
const recordRecent = useConnectionStore((s) => s.recordRecent);
|
||||
const loadRecent = useConnectionStore((s) => s.loadRecent);
|
||||
const recent = useConnectionStore((s) => s.recent);
|
||||
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
|
||||
const clearSelection = useUiStore((s) => s.clearSelection);
|
||||
const confirmBeforeDelete =
|
||||
useSettingsStore((s) => s.settings?.confirm_before_delete ?? true);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [editFolder, setEditFolder] = useState<Folder | null>(null);
|
||||
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<{
|
||||
type: "folder" | "selected";
|
||||
type: "folder" | "selected" | "connection";
|
||||
folder?: Folder;
|
||||
connection?: Connection;
|
||||
} | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const searchRef = useRef<SearchBarHandle>(null);
|
||||
@@ -51,6 +61,7 @@ export function HomeScreen() {
|
||||
const setActiveConnectionId = useUiStore((s) => s.setActiveConnectionId);
|
||||
|
||||
const handleOpenDbViewer = (connectionId: string) => {
|
||||
recordRecent(connectionId);
|
||||
setActiveConnectionId(connectionId);
|
||||
setActiveView("db-viewer");
|
||||
};
|
||||
@@ -119,6 +130,11 @@ export function HomeScreen() {
|
||||
}
|
||||
}, [folders, activeFolderId, setActiveFolderId]);
|
||||
|
||||
// Load recent connections on mount for the root strip
|
||||
useEffect(() => {
|
||||
loadRecent();
|
||||
}, [loadRecent]);
|
||||
|
||||
const executeDeleteSelected = async () => {
|
||||
const folderIds = new Set(folders.map((f) => f.id));
|
||||
for (const id of selectedItemIds) {
|
||||
@@ -148,6 +164,24 @@ export function HomeScreen() {
|
||||
setConfirmDelete(null);
|
||||
};
|
||||
|
||||
const handleDuplicateConnection = async (conn: Connection) => {
|
||||
try {
|
||||
await useConnectionStore.getState().duplicateConnection(conn.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to duplicate connection:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConnection = (conn: Connection) => {
|
||||
if (confirmBeforeDelete) {
|
||||
setConfirmDelete({ type: "connection", connection: conn });
|
||||
} else {
|
||||
void deleteConnection(conn.id).catch((e) => {
|
||||
console.error("Failed to delete connection:", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-full p-6 bg-canvas select-none max-w-7xl mx-auto">
|
||||
<div className="mb-6">
|
||||
@@ -168,9 +202,13 @@ export function HomeScreen() {
|
||||
? setConfirmDelete({ type: "selected" })
|
||||
: executeDeleteSelected()
|
||||
}
|
||||
onMoveToFolder={() => setMoveToFolderOpen(true)}
|
||||
visibleItemIds={visibleItemIds}
|
||||
/>
|
||||
</div>
|
||||
{activeFolderId === null && !searchQuery && (
|
||||
<RecentConnectionsStrip recents={recent} onOpen={handleOpenDbViewer} />
|
||||
)}
|
||||
<DndContext
|
||||
onDragStart={(event) => setActiveDragId(event.active.id as string)}
|
||||
onDragEnd={async (event) => {
|
||||
@@ -194,6 +232,9 @@ export function HomeScreen() {
|
||||
? setConfirmDelete({ type: "folder", folder: f })
|
||||
: executeDeleteFolder(f)
|
||||
}
|
||||
onEditConnection={setEditingConnection}
|
||||
onDuplicateConnection={handleDuplicateConnection}
|
||||
onDeleteConnection={handleDeleteConnection}
|
||||
/>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragId && connections.find((c) => c.id === activeDragId) ? (
|
||||
@@ -242,6 +283,29 @@ export function HomeScreen() {
|
||||
}}
|
||||
onClose={() => setEditFolder(null)}
|
||||
/>
|
||||
<MoveToFolderDialog
|
||||
open={moveToFolderOpen}
|
||||
folders={folders}
|
||||
selectedCount={selectedItemIds.length}
|
||||
onConfirm={async (target) => {
|
||||
try {
|
||||
await moveSelectionToFolder(selectedItemIds, target);
|
||||
} catch (e) {
|
||||
console.error("Failed to move selection:", e);
|
||||
}
|
||||
clearSelection();
|
||||
setMoveToFolderOpen(false);
|
||||
}}
|
||||
onClose={() => setMoveToFolderOpen(false)}
|
||||
/>
|
||||
{editingConnection && (
|
||||
<EditConnectionModal
|
||||
connection={editingConnection}
|
||||
open
|
||||
onClose={() => setEditingConnection(null)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete?.type === "selected" && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
@@ -264,6 +328,30 @@ export function HomeScreen() {
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete?.type === "connection" &&
|
||||
confirmDelete.connection && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete Connection"
|
||||
message={`Are you sure you want to delete "${confirmDelete.connection.name}"?`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="ghost"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteConnection(
|
||||
confirmDelete.connection!.id,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Failed to delete connection:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,4 +28,14 @@ describe("SearchBar", () => {
|
||||
fireEvent.change(input, { target: { value: "postgresql://user@host/db" } });
|
||||
expect(onDetectUrl).toHaveBeenCalledWith("postgresql://user@host/db");
|
||||
});
|
||||
|
||||
it("clears the search and exits search mode on Escape", () => {
|
||||
useUiStore.setState({ searchQuery: "prod" });
|
||||
render(<SearchBar />);
|
||||
const input = screen.getByPlaceholderText(/search/i);
|
||||
// input mirrors the store value
|
||||
expect((input as HTMLInputElement).value).toBe("prod");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(useUiStore.getState().searchQuery).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,15 @@ export const SearchBar = forwardRef<SearchBarHandle, SearchBarProps>(function Se
|
||||
window.clearTimeout((window as any).__sb);
|
||||
(window as any).__sb = window.setTimeout(() => setSearchQuery(v), 150);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
window.clearTimeout((window as any).__sb);
|
||||
setValue("");
|
||||
setSearchQuery("");
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
className="pl-10 pr-14"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-0.5 px-1.5 py-0.5 rounded border border-border bg-surface-raised text-text-muted text-xs pointer-events-none">
|
||||
|
||||
@@ -30,6 +30,7 @@ const makeConn = (overrides: Partial<Connection> = {}): Connection => ({
|
||||
ssl_ca_path: null,
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
favorite: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
getSavedQueries,
|
||||
updateSavedQuery,
|
||||
deleteSavedQuery,
|
||||
setConnectionFavorite,
|
||||
recordRecentConnection,
|
||||
getRecentConnections,
|
||||
clearRecentConnections,
|
||||
getIndexes,
|
||||
getConstraints,
|
||||
} from "./commands";
|
||||
import type { SchemaGraph } from "./types";
|
||||
import type { QueryHistoryEntry } from "./commands";
|
||||
@@ -235,4 +241,54 @@ describe("Query History — v6", () => {
|
||||
id: "q-id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.5.0 command wrappers", () => {
|
||||
it("setConnectionFavorite invokes set_connection_favorite with camelCase", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await setConnectionFavorite("c1", true);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_connection_favorite", { connectionId: "c1", favorite: true });
|
||||
});
|
||||
|
||||
it("recordRecentConnection invokes record_recent_connection", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await recordRecentConnection("c1");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("record_recent_connection", { connectionId: "c1" });
|
||||
});
|
||||
|
||||
it("getRecentConnections invokes get_recent_connections with limit", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getRecentConnections(8);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_recent_connections", { limit: 8 });
|
||||
});
|
||||
|
||||
it("clearRecentConnections invokes clear_recent_connections", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await clearRecentConnections();
|
||||
expect(mockInvoke).toHaveBeenCalledWith("clear_recent_connections", {});
|
||||
});
|
||||
|
||||
it("getIndexes invokes get_indexes with connectionId + schema", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getIndexes("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_indexes", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
|
||||
it("getConstraints invokes get_constraints with connectionId + schema", async () => {
|
||||
const mockInvoke = vi.fn().mockResolvedValue([]);
|
||||
vi.mocked(invoke).mockImplementation(mockInvoke);
|
||||
|
||||
await getConstraints("c1", "public");
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_constraints", { connectionId: "c1", schema: "public" });
|
||||
});
|
||||
});
|
||||
+27
-1
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
|
||||
@@ -268,4 +268,30 @@ export async function updateSavedQuery(
|
||||
|
||||
export async function deleteSavedQuery(id: string): Promise<void> {
|
||||
return invoke<void>("delete_saved_query", { id });
|
||||
}
|
||||
|
||||
// ─── v0.5.0: Favorites / Recents / Indexes / Constraints ──────────
|
||||
|
||||
export async function setConnectionFavorite(connectionId: string, favorite: boolean): Promise<void> {
|
||||
return invoke<void>("set_connection_favorite", { connectionId, favorite });
|
||||
}
|
||||
|
||||
export async function recordRecentConnection(connectionId: string): Promise<void> {
|
||||
return invoke<void>("record_recent_connection", { connectionId });
|
||||
}
|
||||
|
||||
export async function getRecentConnections(limit: number): Promise<RecentConnection[]> {
|
||||
return invoke<RecentConnection[]>("get_recent_connections", { limit });
|
||||
}
|
||||
|
||||
export async function clearRecentConnections(): Promise<void> {
|
||||
return invoke<void>("clear_recent_connections", {});
|
||||
}
|
||||
|
||||
export async function getIndexes(connectionId: string, schema?: string): Promise<IndexInfo[]> {
|
||||
return invoke<IndexInfo[]>("get_indexes", { connectionId, schema });
|
||||
}
|
||||
|
||||
export async function getConstraints(connectionId: string, schema?: string): Promise<ConstraintInfo[]> {
|
||||
return invoke<ConstraintInfo[]>("get_constraints", { connectionId, schema });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
// Import the docs as raw strings (vite/client declares `*?raw`); this keeps the
|
||||
// test free of a `node:fs` dependency so `tsc` (bun run build) stays clean.
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.5.0 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks indexes + constraints complete", () => {
|
||||
expect(agents).toMatch(/Indexes \(per table\) \| ✅/);
|
||||
expect(agents).toMatch(/Constraints \(CHECK, UNIQUE beyond PK\/FK\) \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks materialized views complete", () => {
|
||||
expect(agents).toMatch(/Materialized views \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks stored procedures complete", () => {
|
||||
expect(agents).toMatch(/Stored procedures \| ✅/);
|
||||
});
|
||||
it("AGENTS.md marks favorites + recents + status indicator complete", () => {
|
||||
expect(agents).toMatch(/Favorites \/ Recent connections \| ✅/);
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.5.0", () => {
|
||||
expect(readme).toContain("0.5.0");
|
||||
});
|
||||
it("README marks inline editing complete (not Upcoming)", () => {
|
||||
// Gridline's comparison-table cell carries the ✅ marker
|
||||
expect(readme).toMatch(/Inline cell editing \| ✅ \| ✅ \| \*\*✅/);
|
||||
expect(readme).not.toMatch(/Inline cell editing.*Upcoming/);
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@ import { exportData } from "./exportData";
|
||||
import type { ColumnInfo } from "./types";
|
||||
|
||||
const columns: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null },
|
||||
{ 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 },
|
||||
{ name: "v", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("exportData", () => {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { pruneRecent, dedupeRecent } from "./recentConnections";
|
||||
import type { RecentConnection } from "./types";
|
||||
|
||||
const rc = (id: string, at: string): RecentConnection => ({ connection_id: id, opened_at: at });
|
||||
|
||||
describe("recentConnections helpers", () => {
|
||||
it("pruneRecent keeps the newest N", () => {
|
||||
const list = [rc("a", "1"), rc("b", "3"), rc("c", "2")];
|
||||
expect(pruneRecent(list, 2)).toEqual([rc("b", "3"), rc("c", "2")]);
|
||||
});
|
||||
it("dedupeRecent moves the latest occurrence of an id to the front", () => {
|
||||
const list = [rc("a", "1"), rc("b", "2"), rc("a", "3")];
|
||||
const out = dedupeRecent(list);
|
||||
expect(out[0].connection_id).toBe("a");
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RecentConnection } from "./types";
|
||||
|
||||
/** Keep the newest N entries (sorted by opened_at DESC). */
|
||||
export function pruneRecent(list: RecentConnection[], n: number): RecentConnection[] {
|
||||
return [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1)).slice(0, n);
|
||||
}
|
||||
|
||||
/** Remove duplicates, keeping the most recent occurrence per connection_id at the front. */
|
||||
export function dedupeRecent(list: RecentConnection[]): RecentConnection[] {
|
||||
const seen = new Set<string>();
|
||||
const out: RecentConnection[] = [];
|
||||
for (const item of [...list].sort((a, b) => (a.opened_at < b.opened_at ? 1 : -1))) {
|
||||
if (!seen.has(item.connection_id)) {
|
||||
seen.add(item.connection_id);
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
+47
-4
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||
import type {
|
||||
Connection,
|
||||
ConnectionInput,
|
||||
@@ -16,6 +16,9 @@ import type {
|
||||
GraphColumn,
|
||||
Relationship,
|
||||
Settings,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RecentConnection,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
@@ -37,6 +40,7 @@ describe("Connection", () => {
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
// new SSH/SSL fields
|
||||
@@ -68,6 +72,7 @@ describe("Connection", () => {
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
@@ -89,6 +94,7 @@ describe("Connection", () => {
|
||||
folder_id: null,
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
@@ -184,6 +190,8 @@ describe("ColumnInfo", () => {
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
};
|
||||
expect(col.name).toBe("id");
|
||||
expect(col.is_pk).toBe(true);
|
||||
@@ -198,6 +206,8 @@ describe("ColumnInfo", () => {
|
||||
is_fk: true,
|
||||
fk_ref: ["users", "id"],
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
};
|
||||
expect(col.fk_ref?.[0]).toBe("users");
|
||||
});
|
||||
@@ -206,7 +216,7 @@ describe("ColumnInfo", () => {
|
||||
describe("QueryResult", () => {
|
||||
it("is well-typed with columns and rows", () => {
|
||||
const result: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"email",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [
|
||||
[1, "Alice"],
|
||||
[2, "Bob"],
|
||||
@@ -231,7 +241,7 @@ describe("QueryResult", () => {
|
||||
|
||||
it("can have null execution_time", () => {
|
||||
const result: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [],
|
||||
total_rows: 0, page: 1, page_size: 50,
|
||||
execution_time_ms: null,
|
||||
@@ -324,7 +334,7 @@ describe("DbViewerTab", () => {
|
||||
title: "SELECT * FROM users",
|
||||
query: "SELECT * FROM users",
|
||||
result: {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [],
|
||||
total_rows: 0, page: 1, page_size: 50,
|
||||
},
|
||||
@@ -452,4 +462,37 @@ describe("Settings", () => {
|
||||
expect(s.editor_font_size).toBe(13);
|
||||
expect(s.editor_word_wrap).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.5.0 types", () => {
|
||||
it("ColumnInfo carries editability metadata", () => {
|
||||
const c: 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,
|
||||
};
|
||||
expectTypeOf(c.editable).toEqualTypeOf<boolean>();
|
||||
expectTypeOf(c.is_generated).toEqualTypeOf<boolean>();
|
||||
});
|
||||
it("IndexInfo has the documented fields", () => {
|
||||
const i: IndexInfo = {
|
||||
name: "idx", schema: "public", table: "users", definition: "CREATE INDEX ...",
|
||||
is_unique: true, method: "btree", columns: ["id"], size_bytes: 4096, tablespace: null,
|
||||
};
|
||||
expectTypeOf(i).toMatchTypeOf<IndexInfo>();
|
||||
});
|
||||
it("ConstraintInfo has the documented fields", () => {
|
||||
const c: ConstraintInfo = {
|
||||
name: "ck", schema: "public", table: "users", contype: "CHECK",
|
||||
definition: "CHECK (x > 0)", deferrable: false, validated: true, columns: ["x"],
|
||||
};
|
||||
expectTypeOf(c).toMatchTypeOf<ConstraintInfo>();
|
||||
});
|
||||
it("RecentConnection pairs a connection id with an opened_at timestamp", () => {
|
||||
const r: RecentConnection = { connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" };
|
||||
expectTypeOf(r.connection_id).toEqualTypeOf<string>();
|
||||
});
|
||||
it("Connection carries favorite", () => {
|
||||
const c = { favorite: true } as Connection;
|
||||
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -44,6 +44,8 @@ export interface Connection {
|
||||
ssl_key_path?: string | null;
|
||||
// Environment label (production, staging, development, etc.)
|
||||
environment?: string | null;
|
||||
// Favorite flag (v0.5.0 — pinned connection)
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export type NewConnectionMode = "simple" | "detailed";
|
||||
@@ -131,7 +133,7 @@ export interface ImportResult {
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table_type: "TABLE" | "VIEW";
|
||||
table_type: "TABLE" | "VIEW" | "MATERIALIZED VIEW";
|
||||
columns?: ColumnInfo[];
|
||||
}
|
||||
|
||||
@@ -143,6 +145,36 @@ export interface ColumnInfo {
|
||||
is_fk: boolean;
|
||||
fk_ref: [string, string] | null;
|
||||
default_value: string | null;
|
||||
editable: boolean;
|
||||
is_generated: boolean;
|
||||
}
|
||||
|
||||
export interface IndexInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
definition: string;
|
||||
is_unique: boolean;
|
||||
method: string;
|
||||
columns: string[];
|
||||
size_bytes: number | null;
|
||||
tablespace: string | null;
|
||||
}
|
||||
|
||||
export interface ConstraintInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
contype: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
definition: string;
|
||||
deferrable: boolean;
|
||||
validated: boolean;
|
||||
columns: string[];
|
||||
}
|
||||
|
||||
export interface RecentConnection {
|
||||
connection_id: string;
|
||||
opened_at: string;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
||||
@@ -23,6 +23,7 @@ const makeConnection = (over: Partial<Connection> = {}): Connection => ({
|
||||
keychain_ref: null,
|
||||
tag_ids: [],
|
||||
environment: null,
|
||||
favorite: false,
|
||||
created_at: "2026-07-26T00:00:00Z",
|
||||
updated_at: "2026-07-26T00:00:00Z",
|
||||
...over,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.5.0 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.5.0");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() }));
|
||||
const makeConn = (over: Partial<Connection> = {}): Connection => ({
|
||||
id: "c1", name: "P", db_type: "postgresql", host: "h", port: 5432,
|
||||
username: null, folder_id: null, keychain_ref: null, tag_ids: [],
|
||||
created_at: "", updated_at: "", ...over,
|
||||
favorite: false, created_at: "", updated_at: "", ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -82,6 +82,7 @@ describe("moveConnection", () => {
|
||||
ssl_cert_path: null,
|
||||
ssl_key_path: null,
|
||||
tag_ids: [],
|
||||
favorite: false,
|
||||
created_at: "2024-01-01",
|
||||
updated_at: "2024-01-01",
|
||||
};
|
||||
@@ -188,4 +189,78 @@ describe("moveConnection", () => {
|
||||
expect(state.connections.find((c) => c.id === "c2")?.folder_id).toBe("f1");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("favorites / recents / move-selection", () => {
|
||||
it("toggleFavorite optimistically flips favorite and persists", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
|
||||
vi.spyOn(commands, "setConnectionFavorite").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().toggleFavorite("c1");
|
||||
expect(useConnectionStore.getState().connections[0].favorite).toBe(true);
|
||||
expect(commands.setConnectionFavorite).toHaveBeenCalledWith("c1", true);
|
||||
});
|
||||
|
||||
it("toggleFavorite rolls back on failure", async () => {
|
||||
useConnectionStore.setState({ connections: [makeConn({ id: "c1", favorite: false })] });
|
||||
vi.spyOn(commands, "setConnectionFavorite").mockRejectedValue(new Error("boom"));
|
||||
await expect(useConnectionStore.getState().toggleFavorite("c1")).rejects.toThrow("boom");
|
||||
expect(useConnectionStore.getState().connections[0].favorite).toBe(false);
|
||||
});
|
||||
|
||||
it("loadAll sorts favorites first within the returned list", async () => {
|
||||
const fav = makeConn({ id: "a", name: "A", favorite: true });
|
||||
const norm = makeConn({ id: "b", name: "B", favorite: false });
|
||||
vi.spyOn(commands, "getConnections").mockResolvedValue([norm, fav]);
|
||||
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getTags").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getSettings").mockResolvedValue({} as any);
|
||||
await useConnectionStore.getState().loadAll();
|
||||
const ids = useConnectionStore.getState().connections.map((c) => c.id);
|
||||
expect(ids[0]).toBe("a");
|
||||
});
|
||||
|
||||
it("recordRecent calls the command once", async () => {
|
||||
vi.spyOn(commands, "recordRecentConnection").mockResolvedValue(undefined);
|
||||
await useConnectionStore.getState().recordRecent("c1");
|
||||
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
describe("duplicateConnection", () => {
|
||||
it("copies fields with a '(copy)' name and favorite false", async () => {
|
||||
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
|
||||
useConnectionStore.setState({ connections: [src] });
|
||||
const created = { ...src, id: "c2", name: "Prod (copy)", favorite: false, keychain_ref: null };
|
||||
vi.spyOn(commands, "createConnection").mockResolvedValue(created as any);
|
||||
const out = await useConnectionStore.getState().duplicateConnection("c1");
|
||||
expect(out.name).toBe("Prod (copy)");
|
||||
expect(out.favorite).toBe(false);
|
||||
expect(commands.createConnection).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "Prod (copy)",
|
||||
host: src.host,
|
||||
folder_id: "f1",
|
||||
tag_ids: ["t1"],
|
||||
password: null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("throws when the connection is missing", async () => {
|
||||
useConnectionStore.setState({ connections: [] });
|
||||
await expect(useConnectionStore.getState().duplicateConnection("nope")).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
it("moveSelectionToFolder moves connections and reparents folders", async () => {
|
||||
useConnectionStore.setState({
|
||||
connections: [makeConn({ id: "c1", folder_id: null })],
|
||||
folders: [{ id: "f1", name: "f", parent_id: null, tag_ids: [], created_at: "", updated_at: "" }],
|
||||
});
|
||||
const originalMoveConnection = useConnectionStore.getState().moveConnection;
|
||||
vi.spyOn(useConnectionStore.getState(), "moveConnection").mockResolvedValue(undefined);
|
||||
vi.spyOn(commands, "updateFolder").mockResolvedValue({} as any);
|
||||
await useConnectionStore.getState().moveSelectionToFolder(["c1", "f1"], "target");
|
||||
expect(useConnectionStore.getState().connections[0].folder_id).toBe("target");
|
||||
expect(useConnectionStore.getState().folders[0].parent_id).toBe("target");
|
||||
// Restore the real action so the mock doesn't linger on future state objects
|
||||
useConnectionStore.setState({ moveConnection: originalMoveConnection });
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,17 @@ import * as cmd from "../lib/commands";
|
||||
interface ConnectionState {
|
||||
connections: Connection[]; folders: Folder[]; tags: Tag[];
|
||||
tagOrder: string[];
|
||||
recent: Connection[];
|
||||
loading: boolean; error: string | null;
|
||||
loadAll: () => Promise<void>;
|
||||
loadRecent: () => Promise<void>;
|
||||
recordRecent: (id: string) => Promise<void>;
|
||||
toggleFavorite: (id: string) => Promise<void>;
|
||||
loadTagOrder: () => Promise<void>;
|
||||
setTagOrder: (order: string[]) => Promise<void>;
|
||||
createConnection: (input: ConnectionInput) => Promise<void>;
|
||||
createConnection: (input: ConnectionInput) => Promise<Connection>;
|
||||
deleteConnection: (id: string) => Promise<void>;
|
||||
duplicateConnection: (id: string) => Promise<Connection>;
|
||||
createFolder: (input: FolderInput) => Promise<void>;
|
||||
updateFolder: (id: string, input: FolderInput) => Promise<void>;
|
||||
deleteFolder: (id: string) => Promise<void>;
|
||||
@@ -19,23 +24,48 @@ interface ConnectionState {
|
||||
deleteTag: (id: string) => Promise<void>;
|
||||
addTagToItems: (tagId: string, folderIds: string[], connectionIds: string[]) => Promise<void>;
|
||||
moveConnection: (connectionId: string, newFolderId: string | null) => Promise<void>;
|
||||
moveSelectionToFolder: (selectedIds: string[], targetFolderId: string | null) => Promise<void>;
|
||||
cachePassword: (connectionId: string, password: string) => Promise<void>;
|
||||
getConnectionPassword: (connectionId: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
connections: [], folders: [], tags: [], tagOrder: [], loading: false, error: null,
|
||||
connections: [], folders: [], tags: [], tagOrder: [], recent: [], loading: false, error: null,
|
||||
loadAll: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const [connections, folders, tags] = await Promise.all([cmd.getConnections(), cmd.getFolders(), cmd.getTags()]);
|
||||
set({ connections, folders, tags, loading: false });
|
||||
// Sort favorites first (stable sort preserves name order within groups)
|
||||
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
|
||||
// Also load tag order
|
||||
get().loadTagOrder();
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
loadRecent: async () => {
|
||||
try {
|
||||
const recent = await cmd.getRecentConnections(8);
|
||||
const map = new Map(get().connections.map((c) => [c.id, c]));
|
||||
set({ recent: recent.map((r) => map.get(r.connection_id)).filter(Boolean) as Connection[] });
|
||||
} catch { /* best-effort: recent list is non-critical */ }
|
||||
},
|
||||
recordRecent: async (id) => {
|
||||
try { await cmd.recordRecentConnection(id); } catch { /* best-effort */ }
|
||||
},
|
||||
toggleFavorite: async (id) => {
|
||||
const prev = get().connections;
|
||||
const next = prev.map((c) => c.id === id ? { ...c, favorite: !c.favorite } : c);
|
||||
set({ connections: next });
|
||||
const conn = next.find((c) => c.id === id);
|
||||
try {
|
||||
await cmd.setConnectionFavorite(id, conn?.favorite ?? false);
|
||||
} catch (e) {
|
||||
set({ connections: prev }); // rollback
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
loadTagOrder: async () => {
|
||||
try {
|
||||
const settings = await cmd.getSettings();
|
||||
@@ -66,6 +96,36 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
await cmd.saveConnectionSshPassphrase(conn.id, input.ssh_passphrase);
|
||||
}
|
||||
set((s) => ({ connections: [...s.connections, conn] }));
|
||||
return conn;
|
||||
},
|
||||
duplicateConnection: async (id) => {
|
||||
const source = get().connections.find((c) => c.id === id);
|
||||
if (!source) throw new Error("Connection not found");
|
||||
// Passwords live in the OS keychain and are NEVER copied; the duplicate
|
||||
// starts unkeyed and with no favorite flag.
|
||||
const input: ConnectionInput = {
|
||||
name: `${source.name} (copy)`,
|
||||
db_type: source.db_type,
|
||||
host: source.host,
|
||||
port: source.port,
|
||||
username: source.username ?? null,
|
||||
database: source.database ?? null,
|
||||
folder_id: source.folder_id,
|
||||
tag_ids: source.tag_ids ?? [],
|
||||
environment: source.environment ?? null,
|
||||
ssh_host: source.ssh_host ?? null,
|
||||
ssh_port: source.ssh_port ?? null,
|
||||
ssh_user: source.ssh_user ?? null,
|
||||
ssh_auth_method: (source.ssh_auth_method as ConnectionInput["ssh_auth_method"]) ?? null,
|
||||
ssh_private_key_path: source.ssh_private_key_path ?? null,
|
||||
ssl_mode: (source.ssl_mode as ConnectionInput["ssl_mode"]) ?? null,
|
||||
ssl_ca_path: source.ssl_ca_path ?? null,
|
||||
ssl_cert_path: source.ssl_cert_path ?? null,
|
||||
ssl_key_path: source.ssl_key_path ?? null,
|
||||
password: null,
|
||||
use_keychain: false,
|
||||
};
|
||||
return get().createConnection(input);
|
||||
},
|
||||
deleteConnection: async (id) => {
|
||||
await cmd.deleteConnection(id);
|
||||
@@ -173,4 +233,32 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
moveSelectionToFolder: async (selectedIds, targetFolderId) => {
|
||||
const prev = { connections: [...get().connections], folders: [...get().folders] };
|
||||
const connIds = new Set<string>();
|
||||
const folderIds = new Set<string>();
|
||||
for (const id of selectedIds) {
|
||||
if (prev.connections.some((c) => c.id === id)) connIds.add(id);
|
||||
else if (prev.folders.some((f) => f.id === id)) folderIds.add(id);
|
||||
}
|
||||
try {
|
||||
// Persist first: moveConnection has its own optimistic logic + rollback and
|
||||
// would no-op (early-return on same folder_id) if we pre-set the target.
|
||||
for (const id of connIds) {
|
||||
await get().moveConnection(id, targetFolderId);
|
||||
}
|
||||
for (const id of folderIds) {
|
||||
const f = prev.folders.find((x) => x.id === id);
|
||||
if (f) await cmd.updateFolder(id, { name: f.name, parent_id: targetFolderId, tag_ids: f.tag_ids });
|
||||
}
|
||||
} catch (e) {
|
||||
set({ connections: prev.connections, folders: prev.folders });
|
||||
throw e;
|
||||
}
|
||||
// Apply the move optimistically at the end so state always ends moved
|
||||
set((s) => ({
|
||||
connections: s.connections.map((c) => connIds.has(c.id) ? { ...c, folder_id: targetFolderId } : c),
|
||||
folders: s.folders.map((f) => folderIds.has(f.id) ? { ...f, parent_id: targetFolderId } : f),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -109,7 +109,7 @@ describe("dbViewerStore", () => {
|
||||
store.setTabLoading(tabId, true);
|
||||
|
||||
const mockData: QueryResult = {
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null}],
|
||||
columns: [{name:"id",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false},{name:"name",data_type:"text",is_pk:false,is_fk:false,is_nullable:false,default_value:null,fk_ref:null,editable:true,is_generated:false}],
|
||||
rows: [[1, "Alice"]],
|
||||
total_rows: 1, page: 1, page_size: 50,
|
||||
};
|
||||
@@ -231,6 +231,76 @@ describe("dbViewerStore", () => {
|
||||
expect(state.schemas).toEqual(["public", "private"]);
|
||||
expect(state.tables).toEqual(tables);
|
||||
});
|
||||
|
||||
it("stageCellEdit appends an update QueueItem with primaryKey + old/new data", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||
store.stageCellEdit({
|
||||
tabId,
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Alice" },
|
||||
newData: { name: "Alicia" },
|
||||
description: "Edit users.name",
|
||||
});
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("update");
|
||||
expect(q[0].primaryKey).toEqual({ id: 1 });
|
||||
expect(q[0].oldData).toEqual({ name: "Alice" });
|
||||
expect(q[0].newData).toEqual({ name: "Alicia" });
|
||||
});
|
||||
|
||||
it("re-staging the same cell replaces the pending entry (keeps original oldData)", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||
store.stageCellEdit({
|
||||
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
description: "Edit users.name",
|
||||
});
|
||||
store.stageCellEdit({
|
||||
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||
oldData: { name: "Alice" }, newData: { name: "Alicia 2" },
|
||||
description: "Edit users.name",
|
||||
});
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].newData).toEqual({ name: "Alicia 2" });
|
||||
expect(q[0].oldData).toEqual({ name: "Alice" }); // original DB value preserved
|
||||
});
|
||||
|
||||
it("staging a different cell appends a second entry", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||
store.stageCellEdit({
|
||||
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||
oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
});
|
||||
store.stageCellEdit({
|
||||
tabId, schema: "public", table: "users", primaryKey: { id: 1 },
|
||||
oldData: { age: 30 }, newData: { age: 31 },
|
||||
});
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("setIndexes / setConstraints update store slices", () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.setIndexes([{ name: "idx", schema: "public", table: "t", definition: "", is_unique: true, method: "btree", columns: ["id"], size_bytes: 1, tablespace: null }]);
|
||||
store.setConstraints([{ name: "ck", schema: "public", table: "t", contype: "CHECK", definition: "", deferrable: false, validated: true, columns: ["x"] }]);
|
||||
expect(useDbViewerStore.getState().indexes).toHaveLength(1);
|
||||
expect(useDbViewerStore.getState().constraints).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reset clears indexes and constraints", () => {
|
||||
useDbViewerStore.getState().setIndexes([{ name: "x", schema: "s", table: "t", definition: "", is_unique: false, method: "btree", columns: [], size_bytes: null, tablespace: null }]);
|
||||
useDbViewerStore.getState().reset();
|
||||
expect(useDbViewerStore.getState().indexes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTree", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────
|
||||
@@ -96,6 +96,8 @@ interface DbViewerState {
|
||||
sequences: SequenceInfo[] | null;
|
||||
enums: EnumInfo[] | null;
|
||||
extensions: ExtensionInfo[] | null;
|
||||
indexes: IndexInfo[] | null;
|
||||
constraints: ConstraintInfo[] | null;
|
||||
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
@@ -141,6 +143,17 @@ interface DbViewerState {
|
||||
setSequences: (sequences: SequenceInfo[]) => void;
|
||||
setEnums: (enums: EnumInfo[]) => void;
|
||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||
setIndexes: (indexes: IndexInfo[]) => void;
|
||||
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||
stageCellEdit: (input: {
|
||||
tabId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
description?: string;
|
||||
}) => void;
|
||||
populate: (
|
||||
databases: string[],
|
||||
schemas: string[],
|
||||
@@ -168,6 +181,8 @@ const initialState = {
|
||||
sequences: null as SequenceInfo[] | null,
|
||||
enums: null as EnumInfo[] | null,
|
||||
extensions: null as ExtensionInfo[] | null,
|
||||
indexes: null as IndexInfo[] | null,
|
||||
constraints: null as ConstraintInfo[] | null,
|
||||
};
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────────
|
||||
@@ -400,6 +415,48 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setSequences: (sequences) => set({ sequences }),
|
||||
setEnums: (enums) => set({ enums }),
|
||||
setExtensions: (extensions) => set({ extensions }),
|
||||
setIndexes: (indexes) => set({ indexes }),
|
||||
setConstraints: (constraints) => set({ constraints }),
|
||||
|
||||
stageCellEdit: (input) => {
|
||||
// Re-staging the same cell replaces the existing pending entry (keeps the
|
||||
// original oldData so revert restores the DB value) instead of stacking
|
||||
// a second queue item.
|
||||
const colName = Object.keys(input.newData)[0];
|
||||
const existing = get().changesQueue.find(
|
||||
(c) =>
|
||||
c.status === "pending" &&
|
||||
c.type === "update" &&
|
||||
c.schema === input.schema &&
|
||||
c.table === input.table &&
|
||||
c.primaryKey &&
|
||||
JSON.stringify(c.primaryKey) === JSON.stringify(input.primaryKey) &&
|
||||
Object.keys(c.newData ?? {})[0] === colName,
|
||||
);
|
||||
if (existing) {
|
||||
set((state) => ({
|
||||
changesQueue: state.changesQueue.map((c) =>
|
||||
c.id === existing.id
|
||||
? {
|
||||
...c,
|
||||
newData: input.newData,
|
||||
description: input.description ?? c.description,
|
||||
}
|
||||
: c,
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
get().addChange({
|
||||
type: "update",
|
||||
schema: input.schema,
|
||||
table: input.table,
|
||||
primaryKey: input.primaryKey,
|
||||
oldData: input.oldData,
|
||||
newData: input.newData,
|
||||
description: input.description ?? `Edit ${input.table}`,
|
||||
});
|
||||
},
|
||||
|
||||
populate: (databases, schemas, tables) =>
|
||||
set({ databases, schemas, tables }),
|
||||
|
||||
Reference in New Issue
Block a user