v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)

* [P1-T1] Change::Ddl Rust variant + execute_change arm

* [P1-T2] Frontend ddl change type + objectCrud capability

* [P1-T3] use_keychain data model + migration v8

* [P2-T1] object_crud skeleton + validators + build_ddl dispatch

* [P2-T2] Sequence builders

* [P2-T3] Enum builders (no value removal)

* [P2-T4] View / matview / extension builders

* [P2-T5] Index + constraint builders

* [P2-T6] Function / procedure + trigger builders

* [P2-T7] build_object_ddl + get_available_extensions commands + wrappers

* [P3-T1] conditional keychain + session passwords

* [P3-T2] keychain-off password prompt on connect

* [P3-T3] default-ON keychain opt-out + tooltip + modal conditional

* [P3-T4] ddl queue card + after-commit refetch

* [P4-T1] ObjectCrudDialog shell

* [P4-T2] SequenceForm

* [P4-T3] EnumForm with no-removal note

* [P4-T4] ExtensionForm with available-extensions picker

* [P4-T5] ViewForm (view + materialized view)

* [P5-T1] IndexForm with column picker

* [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker

* [P5-T3] FunctionForm (function + procedure)

* [P5-T4] TriggerForm with trigger-function picker

* [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring

* [P6-T1] object tab type + openObjectTab dedup

* [P6-T2] extract ObjectDetail for object tabs

* [P6-T3] Objects view two-pane sidebar + workspace

* [P6-T4] object-tab content + per-type tab icons

* [P7-T1] Version bump 0.7.5 -> 0.7.6

* [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6

* [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6

* fix(ui): object tab icon stacks above name (preflight svg block)

* fix(ui): optically center object tab icon with name

* fix(ui): object tab icon matches query/table icon handling

* [UI-POLISH-1] objectForm tab type + openFormTab store action

* [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle

* [UI-POLISH-3] route create/edit through form tabs; remove modal

* docs: create/edit now open as form tabs (AGENTS sync)

* [FB-1] follow app styling patterns + schema dropdown in forms

* [FB-2] Monaco editor for function body + view definition

* [FB-3] form tabs styled like viewers + in-cell editing

* [FB-5] focus outline scoped to input area (label excluded)

* [FB-6] no amber focus outline on Monaco body/definition rows

* [FB-7] header dedupe + schema default + full edit prefill

* [FB-8] SQL view in read-only Monaco editor

* docs: roadmap — table create/edit + relationships (next)

* docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
This commit is contained in:
2026-08-06 22:56:49 +08:00
committed by GitHub
parent 8d8ed78202
commit 0c74d77e75
86 changed files with 7094 additions and 1165 deletions
@@ -8,7 +8,7 @@ import type { ConnectionFormData } from "./connectionFormData";
const BASE_FORM: ConnectionFormData = {
name: "", environment: null, folder_id: null, tag_ids: [],
connection_string: "", db_type: "postgresql", host: "", port: 5432,
username: null, password: null, database: null, use_keychain: false, ssh_password: null,
username: null, password: null, database: null, use_keychain: true, ssh_password: null,
};
describe("ConnectionMetadataRow", () => {
@@ -19,7 +19,7 @@ const BASE_FORM: ConnectionFormData = {
username: null,
password: null,
database: null,
use_keychain: false,
use_keychain: true,
ssh_password: null,
};
+12 -1
View File
@@ -7,7 +7,7 @@ const BASE_FORM: ConnectionFormData = {
name: "My DB", environment: null, folder_id: null, tag_ids: [],
connection_string: "postgresql://u:p@localhost:5432/db", db_type: "postgresql",
host: "localhost", port: 5432, username: "u", password: "p", database: "db",
use_keychain: false, ssh_password: null,
use_keychain: true, ssh_password: null,
};
describe("GeneralTab", () => {
@@ -54,4 +54,15 @@ describe("GeneralTab", () => {
render(<GeneralTab form={{ ...BASE_FORM, db_type: "postgresql" }} managedPreset="supabase" onChange={() => {}} />);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("defaults the keychain toggle to ON (opt-out)", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("shows the DB-password-only tooltip text", () => {
render(<GeneralTab form={{ ...BASE_FORM, use_keychain: true }} onChange={() => {}} />);
expect(screen.getByText(/DB password only/i)).toBeInTheDocument();
});
});
@@ -84,6 +84,9 @@ export function GeneralTab({ form, onChange, managedPreset }: GeneralTabProps) {
<input type="checkbox" checked={form.use_keychain} onChange={(e) => onChange({ use_keychain: e.target.checked })} aria-label="Enable keychain" className="rounded border-border bg-surface text-accent focus:ring-accent" />
Enable keychain
</label>
<p className="text-xs text-text-muted -mt-1 mb-2">
Saves the DB password to the OS keychain (DB password only; SSH secrets always use the keychain). Uncheck to never persist the password you'll re-enter it each session.
</p>
</div>
);
}
@@ -50,7 +50,7 @@ function createEmptyForm(
username: null,
password: null,
database: null,
use_keychain: false,
use_keychain: true,
ssh_password: null,
};
}
@@ -93,6 +93,20 @@ describe("ChangesQueuePanel", () => {
expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument();
});
it("renders a ddl change with a DDL badge + description (visual) and SQL preview (sql view)", async () => {
const user = userEvent.setup();
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
render(<ChangesQueuePanel />);
expect(screen.getByText("DDL")).toBeInTheDocument();
expect(screen.getByText("Drop index i")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /sql/i }));
expect(screen.getByText(/DROP INDEX public\.i/)).toBeInTheDocument();
});
it("commit calls executeChange with buildChangePayload output for insert", async () => {
const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
useDbViewerStore.getState().addChange({
@@ -25,6 +25,8 @@ function formatChangeLabel(change: QueueItem): string {
return `Empty Table: ${fullName}`;
case "drop_table":
return `Drop Table: ${fullName}`;
case "ddl":
return change.description ?? "DDL";
default:
return change.table ?? "-";
}
@@ -50,6 +52,11 @@ function capitalizeType(type: string) {
return type.charAt(0).toUpperCase() + type.slice(1);
}
/** Badge label for a queue-item type — ddl renders uppercase to match its acronym. */
function badgeLabel(type: string): string {
return type === "ddl" ? "DDL" : capitalizeType(type);
}
function tableRef(change: QueueItem): string {
if (change.schema && change.table) return `${change.schema}.${change.table}`;
return change.table ?? "-";
@@ -171,7 +178,7 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-xs font-medium text-text-muted">
{capitalizeType(change.type)}
{badgeLabel(change.type)}
</span>
<span className="text-sm text-text truncate">
{tableRef(change)}
@@ -16,6 +16,9 @@ vi.mock("../../hooks/useDbConnection", () => ({
useDbConnection: (_connectionId: string) => ({
connectionError: null,
connect: vi.fn(),
passwordPromptOpen: false,
submitPassword: vi.fn(),
cancelPassword: vi.fn(),
}),
}));
@@ -896,7 +899,66 @@ describe("DbViewerScreen", () => {
).toBeInTheDocument();
});
it("guards the Objects view for MySQL (capability false)", () => {
it("objects view renders the sidebar + the tabbed workspace (New query + Changes)", () => {
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
expect(
screen.getByRole("button", { name: /new query/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Changes queue" }),
).toBeInTheDocument();
});
it("objects view renders ObjectDetail for an open object tab", () => {
useDbViewerStore.getState().openObjectTab("enums", "public", "role", { name: "role", schema: "public", labels: ["admin"] });
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
expect(screen.getByText("admin")).toBeInTheDocument();
});
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "",
title: "Create sequence",
description: "Create sequence",
mode: "create",
params: { schema: "public", name: "", action: { op: "create" } },
});
render(
<DbViewerScreen
connectionId="c1"
onHome={() => {}}
onSettings={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /objects/i }));
await waitFor(() => {
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Visual" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "SQL" })).toBeInTheDocument();
});
it("guards the Objects view for MySQL (capability false)", () => {
useConnectionStore.setState({
connections: [
{
+41 -3
View File
@@ -15,11 +15,14 @@ const DestructiveQueryDialog = lazy(() =>
);
import { TableTree } from "./TableTree";
import { ObjectExplorerPage } from "./ObjectExplorerPage";
import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
import { ObjectFormTab } from "./objects/ObjectFormTab";
import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
import { PasswordPromptDialog } from "./PasswordPromptDialog";
import { useDbConnection } from "../../hooks/useDbConnection";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useConnectionStore } from "../../stores/connectionStore";
@@ -162,7 +165,8 @@ export function DbViewerScreen({
onHome,
onSettings,
}: DbViewerScreenProps) {
const { connectionError, connect } = useDbConnection(connectionId);
const { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword } =
useDbConnection(connectionId);
const [dismissedError, setDismissedError] = useState<string | null>(null);
const [currentView, setCurrentView] = useState<string>("db-viewer");
const [tablePanelWidth, setTablePanelWidth] = useState(280);
@@ -984,15 +988,30 @@ const onQueriesPanelResizeStart = useCallback(
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
{currentView === "queries" ? (
<Terminal size={32} />
) : currentView === "objects" ? (
<Database size={32} />
) : (
<Table2 size={32} />
)}
<span>
{currentView === "queries"
? "Open a new query tab or run a query from the history"
: "Select a table from the tree to browse its data, or open a new query tab"}
: currentView === "objects"
? "Open an object from the list, or open a new query tab"
: "Select a table from the tree to browse its data, or open a new query tab"}
</span>
</div>
) : activeTab?.tabType === "objectForm" ? (
<ObjectFormTab
connectionId={connectionId}
tab={activeTab}
/>
) : activeTab?.tabType === "object" ? (
<ObjectDetail
connectionId={connectionId}
type={activeTab.objectType!}
item={activeTab.objectItem as AnyObject}
/>
) : activeTab?.tabType === "query" ? (
<Suspense
fallback={
@@ -1439,7 +1458,20 @@ const onQueriesPanelResizeStart = useCallback(
{renderQueryWorkspace()}
</div>
) : currentView === "objects" ? (
<ObjectExplorerPage connectionId={connectionId} />
<div className="flex flex-1 min-h-0 overflow-hidden">
<div
className="border-r border-border flex flex-col shrink-0"
style={{ width: tablePanelWidth }}
>
<ObjectExplorerPage connectionId={connectionId} sidebarMode />
</div>
<div
className="w-1 cursor-col-resize bg-border/20 hover:bg-accent/30 active:bg-accent/50 shrink-0 border-r border-border"
onMouseDown={onPanelResizeStart}
onDoubleClick={() => setTablePanelWidth(280)}
/>
{renderQueryWorkspace()}
</div>
) : currentView === "tools" ? (
<ToolsPage connectionId={connectionId} />
) : currentView === "queries" ? (
@@ -1473,6 +1505,12 @@ const onQueriesPanelResizeStart = useCallback(
onSaved={() => {}}
/>
)}
<PasswordPromptDialog
open={passwordPromptOpen}
connectionName={currentConnection?.name ?? ""}
onConnect={submitPassword}
onCancel={cancelPassword}
/>
{capabilities.objects && (
<ObjectSearchPalette connectionId={connectionId} />
)}
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { EditConnectionModal } from "./EditConnectionModal";
import * as commands from "../../lib/commands";
import type { Connection } from "../../lib/types";
const { updateConnection, loadAll } = vi.hoisted(() => ({
@@ -12,13 +13,18 @@ vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
}));
vi.mock("../../lib/commands", () => ({
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
}));
vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>();
return {
...actual,
updateConnection: vi.fn(),
testConnection: vi.fn(),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
deleteConnectionPassword: vi.fn(),
};
});
vi.mock("../../stores/notificationStore", () => ({
useNotificationStore: (sel: (s: any) => any) => sel({ notify: vi.fn() }),
}));
@@ -68,4 +74,51 @@ describe("EditConnectionModal", () => {
);
expect(screen.getByText(/requires ssl/i)).toBeInTheDocument();
});
it("prefills the keychain toggle from the connection (use_keychain=true)", () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
const cb = screen.getByLabelText("Enable keychain") as HTMLInputElement;
expect(cb.checked).toBe(true);
});
it("saves to keychain when use_keychain=true", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: true }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.saveConnectionPassword)).toHaveBeenCalledWith("c1", "secret")
);
expect(vi.mocked(commands.deleteConnectionPassword)).not.toHaveBeenCalled();
});
it("purges keychain when use_keychain=false", async () => {
render(
<EditConnectionModal
connection={{ ...baseConn, use_keychain: false }}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "secret" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() =>
expect(vi.mocked(commands.deleteConnectionPassword)).toHaveBeenCalledWith("c1")
);
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
});
});
@@ -4,7 +4,8 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionPassword, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -36,7 +37,7 @@ export function EditConnectionModal({
username: connection.username,
password: null,
database: connection.database ?? null,
use_keychain: false,
use_keychain: connection.use_keychain ?? true,
ssh_host: connection.ssh_host ?? null,
ssh_port: connection.ssh_port ?? null,
ssh_user: connection.ssh_user ?? null,
@@ -76,9 +77,7 @@ export function EditConnectionModal({
ssh_passphrase: form.ssh_passphrase ?? null,
};
const updated = await updateConnection(connection.id, input);
if (form.password) {
await saveConnectionPassword(connection.id, form.password).catch(() => {});
}
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
// Persist SSH secrets to the OS keychain (not SQLite)
if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) {
await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {});
@@ -204,7 +204,7 @@ describe("ObjectExplorerPage", () => {
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
fireEvent.click(screen.getByText(/copy ddl/i));
await waitFor(() =>
expect(commands.getObjectDdl).toHaveBeenCalledWith("c1", "public", "enum", "role"),
@@ -231,12 +231,126 @@ describe("ObjectExplorerPage", () => {
]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => screen.getByText("add_one(int)"));
fireEvent.click(screen.getAllByLabelText(/options/i)[0]);
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
fireEvent.click(screen.getByText(/dependencies/i));
await waitFor(() => expect(commands.getObjectDependencies).toHaveBeenCalled());
await waitFor(() => expect(screen.getByText("v")).toBeTruthy());
});
it("per-item menu offers Create…/Edit…/Drop… and Edit opens an objectForm tab", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getAllByLabelText(/actions/i)[0]);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
expect(screen.getByText("Drop…")).toBeInTheDocument();
fireEvent.click(screen.getByText("Edit…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("edit");
});
it("right-click on a list row opens the context menu", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
const row = await screen.findByText("role");
fireEvent.contextMenu(row);
expect(screen.getByText("Create…")).toBeInTheDocument();
expect(screen.getByText("Edit…")).toBeInTheDocument();
});
it("header + create button opens an objectForm create tab for the current type", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() => screen.getByText("role"));
fireEvent.click(screen.getByRole("button", { name: /create enum/i }));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("create");
expect(st.tabs[0].form?.kind).toBe("enum");
expect(st.tabs[0].form?.params?.schema).toBe("public");
});
it("refetches the current object list after a ddl commit succeeds", async () => {
const getFunctions = vi
.spyOn(commands, "getFunctions")
.mockResolvedValue([]);
render(<ObjectExplorerPage connectionId="c1" />);
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(1));
useDbViewerStore.getState().addChange({
type: "ddl",
sql: "DROP INDEX public.i",
description: "Drop index i",
} as any);
useDbViewerStore.getState().markChangeCommitted("ch-1");
await waitFor(() => expect(getFunctions).toHaveBeenCalledTimes(2));
});
it("sidebarMode: clicking a list row opens an object tab instead of an inline detail", async () => {
vi.spyOn(commands, "getFunctions").mockResolvedValue([
{
name: "add",
schema: "public",
return_type: "int",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: "BEGIN RETURN 1; END",
kind: "f",
},
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
await waitFor(() =>
expect(screen.getByText("add")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("add"));
const st = useDbViewerStore.getState();
expect(
st.tabs.some(
(t) => t.tabType === "object" && t.table === "add",
),
).toBe(true);
});
it("sidebarMode: does not render the inline detail pane", async () => {
vi.spyOn(commands, "getEnums").mockResolvedValue([
{ name: "role", schema: "public", labels: ["admin"] },
]);
render(<ObjectExplorerPage connectionId="c1" sidebarMode />);
fireEvent.click(screen.getByLabelText("Object type"));
fireEvent.click(screen.getByText("Enums"));
await waitFor(() =>
expect(screen.getByText("role")).toBeInTheDocument(),
);
fireEvent.click(screen.getByText("role"));
// clicking opened an object tab (no inline detail selected)
expect(
useDbViewerStore
.getState()
.tabs.some((t) => t.tabType === "object"),
).toBe(true);
// detail pane is gone; only the list remains
expect(
screen.queryByText("Select an enum to view details"),
).not.toBeInTheDocument();
});
it("preselects type from store on mount", () => {
useDbViewerStore.setState({ selectedObjectType: "sequences" });
render(<ObjectExplorerPage connectionId="c1" />);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PasswordPromptDialog } from "./PasswordPromptDialog";
describe("PasswordPromptDialog", () => {
it("renders nothing when closed", () => {
const { container } = render(
<PasswordPromptDialog
open={false}
connectionName="n"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("shows the connection name and a password field when open", () => {
render(
<PasswordPromptDialog
open={true}
connectionName="Prod DB"
onConnect={() => {}}
onCancel={() => {}}
/>,
);
expect(screen.getByText(/Prod DB/)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
it("calls onConnect with the typed value", () => {
const onConnect = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={onConnect}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/password/i), {
target: { value: "p@ss" },
});
fireEvent.click(screen.getByRole("button", { name: /connect/i }));
expect(onConnect).toHaveBeenCalledWith("p@ss");
});
it("calls onCancel on cancel", () => {
const onCancel = vi.fn();
render(
<PasswordPromptDialog
open={true}
connectionName="X"
onConnect={() => {}}
onCancel={onCancel}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
@@ -0,0 +1,58 @@
import { useState } from "react";
import { AnimatedModal } from "../ui/AnimatedModal";
interface Props {
open: boolean;
connectionName: string;
onConnect: (password: string) => void;
onCancel: () => void;
}
export function PasswordPromptDialog({
open,
connectionName,
onConnect,
onCancel,
}: Props) {
const [pw, setPw] = useState("");
if (!open) return null;
return (
<AnimatedModal open={open} onClose={onCancel}>
<div className="p-5 w-80">
<h2 className="text-sm font-medium text-text mb-1">Enter password</h2>
<p className="text-xs text-text-muted mb-3">
{connectionName} has keychain disabled. Enter the password for this
session (it will not be saved).
</p>
<input
type="password"
autoFocus
aria-label="Password"
placeholder="Password"
value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && pw) onConnect(pw);
}}
className="w-full rounded-lg border-border bg-surface px-3 py-2 text-sm text-text mb-3"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="text-xs px-3 py-1.5 rounded-lg text-text-muted hover:bg-surface"
>
Cancel
</button>
<button
type="button"
onClick={() => pw && onConnect(pw)}
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white"
>
Connect
</button>
</div>
</div>
</AnimatedModal>
);
}
+45
View File
@@ -83,6 +83,22 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders the per-type icon on an object tab", () => {
useDbViewerStore.getState().openObjectTab("functions", "public", "add", { name: "add", schema: "public" });
render(<TabBar />);
const icon = screen.getByLabelText(/object icon: functions/i);
const svg = icon.querySelector("svg");
expect(svg).toBeTruthy();
// Regression: the icon must use the SAME handling as the query/table icons —
// the svg itself is display:inline with the shared optical-centering classes.
// That defeats preflight svg{display:block} (no stacking) and lets
// vertical-align:middle center it with the tab name.
const cls = svg!.getAttribute("class") ?? "";
expect(cls).toContain("inline");
expect(cls).toContain("-mt-0.5");
expect(screen.getByText("add")).toBeInTheDocument();
});
it("renders a view icon on view tabs", () => {
useDbViewerStore.getState().openTab("main", "order_summary");
useDbViewerStore.setState({
@@ -95,6 +111,35 @@ describe("TabBar", () => {
expect(screen.queryByTestId("tab-icon-table")).not.toBeInTheDocument();
});
it("renders a create icon and title on an objectForm create tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "",
title: "Create sequence",
description: "Create sequence",
mode: "create",
params: { schema: "public", name: "", action: { op: "create" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-create")).toBeInTheDocument();
expect(screen.getByText("Create sequence")).toBeInTheDocument();
});
it("renders an edit icon on an objectForm edit tab", () => {
useDbViewerStore.getState().openFormTab({
kind: "sequence",
schema: "public",
name: "s",
title: "Edit sequence",
description: "Edit sequence",
mode: "edit",
params: { schema: "public", name: "s", action: { op: "alter" } },
});
render(<TabBar />);
expect(screen.getByTestId("tab-icon-form-edit")).toBeInTheDocument();
});
it("renders a layers icon on materialized view tabs", () => {
useDbViewerStore.getState().openTab("public", "mv_products");
useDbViewerStore.setState({
+36 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, type ReactNode } from "react";
import { cloneElement, useEffect, useRef, type ReactElement, type ReactNode } from "react";
import {
DndContext,
closestCenter,
@@ -16,9 +16,10 @@ import {
sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X } from "lucide-react";
import { ListChecks, Play, Table2, Layers, Eye, Terminal, X, Plus, Pencil } from "lucide-react";
import { useDbViewerStore, type ViewerTab } from "../../stores/dbViewerStore";
import { ChangesQueuePanel } from "./ChangesQueuePanel";
import { OBJECT_ICONS } from "./objects/ObjectDetail";
function SortableTab({
tab,
@@ -181,6 +182,39 @@ export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
data-testid="tab-icon-query"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : tab.tabType === "object" ? (
<span
aria-label={`object icon: ${tab.objectType}`}
className="contents"
>
{cloneElement(
OBJECT_ICONS[tab.objectType!] as ReactElement<{
className?: string;
}>,
{
// Same handling as the query/table icons: the svg
// itself is display:inline (preflight vertical-align:
// middle centers it with the text) with the same
// optical-centering nudge. `display: contents` on the
// labelled span renders no box, so the geometry is
// identical to the bare Terminal/Table2 icons.
className:
"mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current",
},
)}
</span>
) : tab.tabType === "objectForm" ? (
tab.form?.mode === "create" ? (
<Plus
data-testid="tab-icon-form-create"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
) : (
<Pencil
data-testid="tab-icon-form-edit"
className="mr-1.5 inline h-3.5 w-3.5 -mt-0.5 text-current"
/>
)
) : objectType === "VIEW" ? (
<Eye
data-testid="tab-icon-view"
@@ -19,6 +19,31 @@ describe("TableOverflowMenu", () => {
vi.resetAllMocks();
});
it("offers Create Index… and Create Constraint…", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
expect(screen.getByText("Create Index…")).toBeInTheDocument();
expect(screen.getByText("Create Constraint…")).toBeInTheDocument();
});
it("opens objectForm create tabs for Create Index… and Create Constraint…", async () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getByLabelText(/table options/i));
fireEvent.click(screen.getByText("Create Index…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.kind).toBe("index");
expect(st.tabs[0].form?.mode).toBe("create");
useDbViewerStore.getState().reset();
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} connectionId="c1" />);
fireEvent.click(screen.getAllByLabelText(/table options/i)[1]);
fireEvent.click(screen.getByText("Create Constraint…"));
expect(useDbViewerStore.getState().tabs[0].tabType).toBe("objectForm");
expect(useDbViewerStore.getState().tabs[0].form?.kind).toBe("constraint");
});
it("renders menu trigger button", () => {
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => "tab-1"} />);
expect(screen.getByLabelText(/table options/i)).toBeInTheDocument();
@@ -7,6 +7,7 @@ import { useUiStore } from "../../stores/uiStore";
import { exportData } from "../../lib/exportData";
import * as cmd from "../../lib/commands";
import { DependencyDialog } from "./DependencyDialog";
import { initialCrudParams } from "../../lib/objectCrud";
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
interface TableOverflowMenuProps {
@@ -96,6 +97,32 @@ export function TableOverflowMenu({
setImportOpen(true);
setOpen(false);
break;
case "create_index":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "index",
schema,
name: "",
title: "Create Index",
description: `Create index on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("index", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "create_constraint":
if (!connectionId) break;
useDbViewerStore.getState().openFormTab({
kind: "constraint",
schema,
name: "",
title: "Create Constraint",
description: `Create constraint on ${schema}.${table}`,
mode: "create",
params: initialCrudParams("constraint", { schema, table, name: "" }, "create"),
});
setOpen(false);
break;
case "empty":
setConfirmAction("empty");
setOpen(false);
@@ -125,6 +152,8 @@ export function TableOverflowMenu({
{ id: "export-sql", label: "Export data (SQL)" },
{ id: "export-md", label: "Export data (Markdown)" },
{ id: "import", label: "Import data (CSV/JSON)" },
{ id: "create_index", label: "Create Index…" },
{ id: "create_constraint", label: "Create Constraint…" },
{ id: "empty", label: "Empty Table", danger: true },
{ id: "delete", label: "Delete Table", danger: true },
];
@@ -0,0 +1,26 @@
interface Props {
cols: string[];
selected: string[];
onToggle: (col: string) => void;
}
export function ColumnPicker({ cols, selected, onToggle }: Props) {
return (
<div className="flex flex-wrap gap-1" data-testid="column-picker">
{cols.map((c) => (
<button
key={c}
type="button"
onClick={() => onToggle(c)}
className={`text-xs px-2 py-1 rounded border transition-colors ${
selected.includes(c)
? "bg-accent text-white border-accent"
: "border-border text-text hover:border-text-muted"
}`}
>
{c}
</button>
))}
</div>
);
}
@@ -0,0 +1,111 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ConstraintForm } from "./ConstraintForm";
import * as cmd from "../../../lib/commands";
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
const graph = {
tables: [
{
name: "orders",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "user_id",
data_type: "int",
is_pk: false,
is_fk: true,
is_unique: false,
is_nullable: true,
fk_ref: ["public", "users", "id"],
},
],
},
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "id",
data_type: "int",
is_pk: true,
is_fk: false,
is_unique: true,
is_nullable: false,
fk_ref: null,
},
],
},
],
relationships: [],
};
describe("ConstraintForm", () => {
afterEach(() => {
vi.mocked(cmd.getSchemaGraph).mockReset();
});
it("check: emits the expression", () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "ck",
action: { op: "check", expression: "" },
}}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("CHECK expression"), {
target: { value: "amount > 0" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ expression: "amount > 0" }),
}),
);
});
it("foreign_key: picks referenced table + column", async () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<ConstraintForm
connectionId="c1"
params={{
schema: "public",
table: "orders",
name: "fk",
action: {
op: "foreign_key",
columns: ["user_id"],
ref_schema: "",
ref_table: "",
ref_columns: [],
},
}}
onChange={onChange}
/>,
);
const kindSelect = screen.getByLabelText("Kind");
fireEvent.change(kindSelect, { target: { value: "foreign_key" } });
await screen.findByText("user_id");
const refTable = await screen.findByPlaceholderText("Referenced table");
fireEvent.change(refTable, { target: { value: "users" } });
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({
ref_schema: "public",
ref_table: "users",
}),
}),
);
});
});
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import * as cmd from "../../../lib/commands";
import type { SchemaGraph } from "../../../lib/types";
import { ColumnPicker } from "./ColumnPicker";
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
const KINDS = ["check", "unique", "primary_key", "foreign_key"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const kind = (action.op as string) ?? "check";
const [graph, setGraph] = useState<SchemaGraph>({ tables: [], relationships: [] });
useEffect(() => {
cmd
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
.then((g: SchemaGraph) => setGraph(g))
.catch(() => setGraph({ tables: [], relationships: [] }));
}, [connectionId, p.schema]);
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
const tableCols =
graph.tables
.find((t) => t.name === (p.table as string) && t.schema === (p.schema as string))
?.columns.map((c) => c.name) ?? [];
const refCols =
graph.tables
.find(
(t) =>
t.name === (action.ref_table as string) &&
t.schema === (action.ref_schema as string),
)
?.columns.map((c) => c.name) ?? [];
const selected: string[] = (action.columns as string[]) ?? [];
const refSelected: string[] = (action.ref_columns as string[]) ?? [];
const pick = (list: string[], c: string) =>
list.includes(c) ? list.filter((x) => x !== c) : [...list, c];
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(p.table as string) ?? ""}
onChange={(e) => onChange({ ...p, table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Constraint name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Kind">
<select
aria-label="Kind"
value={kind}
onChange={(e) => onChange({ ...p, action: { op: e.target.value } })}
className={controlClass}
>
{KINDS.map((k) => (
<option key={k} value={k}>
{k.replace(/_/g, " ")}
</option>
))}
</select>
</FormRow>
{(kind === "unique" || kind === "primary_key") && (
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
)}
{kind === "check" && (
<FormRow label="Expression">
<input
type="text"
placeholder="CHECK expression"
value={(action.expression as string) ?? ""}
onChange={(e) => setAction({ expression: e.target.value })}
className={monoInputClass}
/>
</FormRow>
)}
{kind === "foreign_key" && (
<>
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={tableCols}
selected={selected}
onToggle={(c) => setAction({ columns: pick(selected, c) })}
/>
</div>
</FormRow>
<FormRow label="Referenced table">
<input
type="text"
placeholder="Referenced table"
value={(action.ref_table as string) ?? ""}
onChange={(e) => {
const t = graph.tables.find((t) => t.name === e.target.value);
setAction({
ref_table: e.target.value,
ref_schema: t?.schema ?? "",
ref_columns: [],
});
}}
className={inputClass}
/>
</FormRow>
<FormRow label="Referenced columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={refCols}
selected={refSelected}
onToggle={(c) => setAction({ ref_columns: pick(refSelected, c) })}
/>
</div>
</FormRow>
</>
)}
</div>
);
}
@@ -0,0 +1,54 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { EnumForm } from "./EnumForm";
describe("EnumForm", () => {
it("create: add/remove labels", () => {
const onChange = vi.fn();
render(
<EnumForm
params={{ schema: "public", name: "role", action: { op: "create", labels: ["admin"] } }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /add value/i }));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ labels: ["admin", ""] }),
}),
);
});
it("add_value op shows value + position + the no-removal note", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "add_value", value: "orange", if_not_exists: false, before: null, after: null },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("New value")).toHaveValue("orange");
expect(screen.getByText(/no ALTER TYPE … DROP VALUE/i)).toBeInTheDocument();
});
it("rename_value op shows from + to", () => {
render(
<EnumForm
params={{
schema: "public",
name: "color",
action: { op: "rename_value", from: "purple", to: "mauve" },
}}
onChange={() => {}}
/>,
);
expect(screen.getByPlaceholderText("From")).toHaveValue("purple");
expect(screen.getByPlaceholderText("To")).toHaveValue("mauve");
});
});
@@ -0,0 +1,226 @@
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass, monoInputClass } from "./formRow";
interface Props {
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type EnumOp = "create" | "rename_type" | "add_value" | "rename_value";
const OP_LABELS: Record<EnumOp, string> = {
create: "Create",
rename_type: "Rename type",
add_value: "Add value",
rename_value: "Rename value",
};
function getOp(params: DdlParams): EnumOp {
const action = (params.action ?? {}) as Record<string, unknown>;
const op = action.op;
if (op === "rename_type" || op === "add_value" || op === "rename_value") return op;
return "create";
}
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
function patchActionResetOp(params: DdlParams, op: EnumOp): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
const labels = (action.labels as string[]) ?? [];
return { ...params, action: { op, labels } };
}
function NoRemovalNote() {
return (
<p className="text-xs text-text-muted">
PostgreSQL has no ALTER TYPE DROP VALUE. To remove a value, drop and recreate the type.
</p>
);
}
export function EnumForm({ params, schemas, onChange }: Props) {
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
const labels = (action.labels as string[]) ?? [];
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Enum name"
value={(params.name as string) ?? ""}
onChange={(e) => onChange({ ...params, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) => onChange(patchActionResetOp(params, e.target.value as EnumOp))}
className={controlClass}
>
{(Object.keys(OP_LABELS) as EnumOp[]).map((key) => (
<option key={key} value={key}>
{OP_LABELS[key]}
</option>
))}
</select>
</FormRow>
{op === "create" && (
<FormRow label="Values" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
{labels.map((l, i) => (
<div key={i} className="flex gap-1 items-center">
<input
type="text"
placeholder={`Value ${i + 1}`}
value={l}
onChange={(e) =>
onChange(
patchAction(params, {
labels: labels.map((x, j) => (j === i ? e.target.value : x)),
}),
)
}
className={monoInputClass}
/>
<button
type="button"
onClick={() =>
onChange(patchAction(params, { labels: labels.filter((_, j) => j !== i) }))
}
className="text-text-muted hover:text-red-400 px-2"
aria-label={`Remove value ${i + 1}`}
>
×
</button>
</div>
))}
<button
type="button"
onClick={() => onChange(patchAction(params, { labels: [...labels, ""] }))}
className="self-start text-xs text-accent hover:text-accent/80"
>
+ Add value
</button>
<NoRemovalNote />
</div>
</FormRow>
)}
{op === "rename_type" && (
<FormRow label="New name">
<input
type="text"
placeholder="New name"
value={(action.new_name as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { new_name: e.target.value }))}
className={inputClass}
/>
</FormRow>
)}
{op === "add_value" && (
<>
<FormRow label="New value">
<input
type="text"
placeholder="New value"
value={(action.value as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { value: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
<FormRow label="If not exists">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.if_not_exists}
onChange={(e) => onChange(patchAction(params, { if_not_exists: e.target.checked }))}
aria-label="IF NOT EXISTS"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>IF NOT EXISTS</span>
</label>
</FormRow>
<FormRow label="Before">
<input
type="text"
placeholder="BEFORE (optional)"
value={(action.before as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { before: e.target.value || null, after: null }))
}
className={monoInputClass}
/>
</FormRow>
<FormRow label="After">
<input
type="text"
placeholder="AFTER (optional)"
value={(action.after as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { after: e.target.value || null, before: null }))
}
className={monoInputClass}
/>
</FormRow>
<div className="border-b border-border px-4 py-2">
<NoRemovalNote />
</div>
</>
)}
{op === "rename_value" && (
<>
<FormRow label="From">
<input
type="text"
placeholder="From"
value={(action.from as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { from: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
<FormRow label="To">
<input
type="text"
placeholder="To"
value={(action.to as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { to: e.target.value }))}
className={monoInputClass}
/>
</FormRow>
</>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ExtensionForm } from "./ExtensionForm";
import * as objectCrud from "../../../lib/objectCrud";
vi.mock("../../../lib/objectCrud", () => ({
getAvailableExtensions: vi.fn(),
buildObjectDdl: vi.fn(),
}));
describe("ExtensionForm", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("create: lists available extensions and emits name on pick", async () => {
vi.mocked(objectCrud.getAvailableExtensions).mockResolvedValue([
{ name: "pgcrypto", version: "1.3", comment: null },
]);
const onChange = vi.fn();
render(
<ExtensionForm
connectionId="c1"
params={{
schema: "public",
name: "",
action: { op: "create", version: null },
}}
onChange={onChange}
/>,
);
expect(await screen.findByText("pgcrypto")).toBeInTheDocument();
fireEvent.click(screen.getByText("pgcrypto"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
name: "pgcrypto",
action: expect.objectContaining({ version: "1.3" }),
}),
);
});
it("set_schema op emits new_schema", () => {
const onChange = vi.fn();
render(
<ExtensionForm
connectionId="c1"
params={{
schema: "public",
name: "pgcrypto",
action: { op: "set_schema", new_schema: "" },
}}
onChange={onChange}
/>,
);
fireEvent.change(screen.getByPlaceholderText("New schema"), {
target: { value: "utils" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ new_schema: "utils" }),
}),
);
});
});
@@ -0,0 +1,146 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import {
getAvailableExtensions,
type AvailableExtension,
} from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type ExtensionOp = "create" | "set_schema";
const OP_LABELS: Record<ExtensionOp, string> = {
create: "Install",
set_schema: "Set schema",
};
export function ExtensionForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const op = (action.op as ExtensionOp) ?? "create";
const [available, setAvailable] = useState<AvailableExtension[]>([]);
useEffect(() => {
getAvailableExtensions(connectionId)
.then(setAvailable)
.catch(() => setAvailable([]));
}, [connectionId]);
const setAction = (patch: Record<string, unknown>) => {
onChange({ ...p, action: { ...action, ...patch } });
};
const pickExtension = (ext: AvailableExtension) => {
onChange({
...p,
name: ext.name,
action: { op: "create", version: ext.version || null },
});
};
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) =>
onChange({ ...p, action: { op: e.target.value as ExtensionOp } })
}
className={controlClass}
>
{(Object.keys(OP_LABELS) as ExtensionOp[]).map((key) => (
<option key={key} value={key}>
{OP_LABELS[key]}
</option>
))}
</select>
</FormRow>
{op === "create" && (
<>
<FormRow label="Extension">
<input
type="text"
placeholder="Extension name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
{available.length > 0 && (
<FormRow label="Available" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-0.5 px-4 py-2">
{available.map((ext) => (
<button
key={ext.name}
type="button"
onClick={() => pickExtension(ext)}
className="text-left text-xs font-medium text-accent hover:text-accent-hover hover:underline"
>
<span>{ext.name}</span>
{ext.version ? (
<span className="text-text-muted"> ({ext.version})</span>
) : null}
</button>
))}
</div>
</FormRow>
)}
<FormRow label="Version">
<input
type="text"
placeholder="Version (optional)"
value={(action.version as string) ?? ""}
onChange={(e) => setAction({ version: e.target.value || null })}
className={inputClass}
/>
</FormRow>
</>
)}
{op === "set_schema" && (
<FormRow label="New schema">
<input
type="text"
placeholder="New schema"
value={(action.new_schema as string) ?? ""}
onChange={(e) => setAction({ new_schema: e.target.value })}
className={inputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,143 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { FunctionForm } from "./FunctionForm";
import type { DdlParams } from "../../../lib/objectCrud";
vi.mock("../../editor/SqlEditorField", () => ({
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
describe("FunctionForm", () => {
it("regression: Body row opts out of focus-within outline; normal rows keep it", async () => {
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(<FunctionForm kind="function" params={params} onChange={() => {}} />);
// Monaco rows must NOT get the amber in-cell-editing outline.
const editor = await screen.findByTestId("sql-editor");
const bodyRow = editor.closest("div.flex.flex-row");
expect(bodyRow?.className ?? "").not.toContain("focus-within:outline");
// Normal rows still carry the outline — the opt-out must be scoped.
const operationRow = screen
.getByLabelText("Operation")
.closest("div.flex.flex-row");
expect(operationRow?.className ?? "").toContain("focus-within:outline");
});
it("function: renders args grid + return type; emits body", async () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [{ mode: "in", name: "a", type: "int" }],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
fireEvent.change(await screen.findByTestId("sql-editor"), {
target: { value: "BEGIN RETURN a; END" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ body: "BEGIN RETURN a; END" }),
}),
);
});
it("renders a schema dropdown when schemas are provided", () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: {
op: "create_or_replace",
args: [],
return_type: "int",
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(
<FunctionForm
kind="function"
params={params}
schemas={["public", "utils"]}
onChange={onChange}
/>,
);
const select = screen.getByLabelText("Schema");
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
fireEvent.change(select, { target: { value: "utils" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ schema: "utils" }),
);
});
it("procedure: hides return type", () => {
const params: DdlParams = {
schema: "public",
name: "p",
is_procedure: true,
action: {
op: "create_or_replace",
args: [],
return_type: null,
language: "plpgsql",
body: "",
volatility: null,
strict: false,
},
};
render(
<FunctionForm kind="procedure" params={params} onChange={() => {}} />,
);
expect(screen.queryByPlaceholderText("Return type")).not.toBeInTheDocument();
});
it("drop op: renders arg_types list", () => {
const onChange = vi.fn();
const params: DdlParams = {
schema: "public",
name: "add",
is_procedure: false,
action: { op: "drop", arg_types: ["int"] },
};
render(<FunctionForm kind="function" params={params} onChange={onChange} />);
fireEvent.change(screen.getByPlaceholderText("Arg types (comma-separated)"), {
target: { value: "int, int" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ arg_types: ["int", "int"] }),
}),
);
});
});
@@ -0,0 +1,275 @@
import { lazy, Suspense } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, FormSectionHeader, inputClass, controlClass, monoInputClass } from "./formRow";
const SqlEditorField = lazy(() =>
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
);
interface Props {
kind: "function" | "procedure";
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
interface Arg {
mode: string;
name: string;
type: string;
}
type FunctionOp = "create_or_replace" | "drop";
const MODES = ["in", "out", "inout", "variadic"];
const LANGS = ["plpgsql", "sql", "c"];
const VOL = ["", "IMMUTABLE", "STABLE", "VOLATILE"];
function getOp(params: DdlParams): FunctionOp {
const action = (params.action ?? {}) as Record<string, unknown>;
return action.op === "drop" ? "drop" : "create_or_replace";
}
function patchAction(
params: DdlParams,
patch: Record<string, unknown>,
): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
function patchTopLevel(
params: DdlParams,
kind: "function" | "procedure",
patch: Record<string, unknown>,
): DdlParams {
return {
...params,
...patch,
is_procedure: kind === "procedure" ? true : params.is_procedure,
};
}
export function FunctionForm({ kind, params, schemas, onChange }: Props) {
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
const args = (action.args as Arg[]) ?? [];
const setAction = (patch: Record<string, unknown>) =>
onChange(patchAction(params, patch));
const setArg = (i: number, patch: Partial<Arg>) =>
setAction({
args: args.map((a, j) => (j === i ? { ...a, ...patch } : a)),
});
const placeholder = kind === "procedure" ? "Procedure name" : "Function name";
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { schema: e.target.value }))
}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder={placeholder}
value={(params.name as string) ?? ""}
onChange={(e) =>
onChange(patchTopLevel(params, kind, { name: e.target.value }))
}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) => onChange({ ...params, action: { op: e.target.value } })}
className={controlClass}
>
<option value="create_or_replace">Create / replace</option>
<option value="drop">Drop by signature</option>
</select>
</FormRow>
{op === "create_or_replace" && (
<>
<FormSectionHeader label="Arguments" count={args.length} />
{args.map((a, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center gap-2"
>
<select
value={a.mode}
onChange={(e) => setArg(i, { mode: e.target.value })}
aria-label={`Argument ${i + 1} mode`}
className="w-24 shrink-0 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none"
>
{MODES.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<span className="text-xs text-text-muted w-8 shrink-0 font-mono">
#{i + 1}
</span>
<input
type="text"
placeholder="name"
value={a.name}
onChange={(e) => setArg(i, { name: e.target.value })}
className={`${monoInputClass} flex-1`}
/>
<span className="text-border">:</span>
<input
type="text"
placeholder="type"
value={a.type}
onChange={(e) => setArg(i, { type: e.target.value })}
className={`${monoInputClass} flex-1`}
/>
<button
type="button"
onClick={() => setAction({ args: args.filter((_, j) => j !== i) })}
className="text-text-muted px-2"
>
×
</button>
</div>
))}
<div className="border-b border-border px-4 py-2">
<button
type="button"
onClick={() =>
setAction({
args: [...args, { mode: "in", name: "", type: "" }],
})
}
className="text-xs text-accent hover:text-accent-hover"
>
+ Add argument
</button>
</div>
{kind === "function" && (
<FormRow label="Return type">
<input
type="text"
placeholder="Return type"
value={(action.return_type as string | null) ?? ""}
onChange={(e) => setAction({ return_type: e.target.value })}
className={monoInputClass}
/>
</FormRow>
)}
<FormRow label="Language">
<select
aria-label="Language"
value={(action.language as string) ?? "plpgsql"}
onChange={(e) => setAction({ language: e.target.value })}
className={controlClass}
>
{LANGS.map((l) => (
<option key={l} value={l}>
{l}
</option>
))}
</select>
</FormRow>
<FormRow label="Volatility">
<select
aria-label="Volatility"
value={(action.volatility as string) ?? ""}
onChange={(e) => setAction({ volatility: e.target.value || null })}
className={controlClass}
>
{VOL.map((v) => (
<option key={v} value={v}>
{v || "(default VOLATILE)"}
</option>
))}
</select>
</FormRow>
<FormRow label="Strict">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.strict}
onChange={(e) => setAction({ strict: e.target.checked })}
aria-label="STRICT"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>STRICT (RETURNS NULL ON NULL INPUT)</span>
</label>
</FormRow>
<FormRow label="Body" className="items-stretch" outline={false}>
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
<Suspense
fallback={
<textarea
rows={6}
value={(action.body as string) ?? ""}
onChange={(e) => setAction({ body: e.target.value })}
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
/>
}
>
<div className="h-full w-full font-mono">
<SqlEditorField
value={(action.body as string) ?? ""}
onChange={(v) => setAction({ body: v })}
height={140}
/>
</div>
</Suspense>
</div>
</FormRow>
</>
)}
{op === "drop" && (
<FormRow label="Arg types">
<input
type="text"
placeholder="Arg types (comma-separated)"
value={(action.arg_types as string[] | undefined)?.join(", ") ?? ""}
onChange={(e) =>
setAction({
arg_types: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
className={monoInputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { IndexForm } from "./IndexForm";
import * as cmd from "../../../lib/commands";
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
const graph = {
tables: [
{
name: "users",
schema: "public",
table_type: "BASE TABLE",
columns: [
{
name: "id",
data_type: "int",
is_pk: true,
is_fk: false,
is_unique: false,
is_nullable: false,
fk_ref: null,
},
{
name: "email",
data_type: "text",
is_pk: false,
is_fk: false,
is_unique: false,
is_nullable: true,
fk_ref: null,
},
],
},
],
relationships: [],
};
describe("IndexForm", () => {
afterEach(() => {
vi.mocked(cmd.getSchemaGraph).mockReset();
});
it("loads table columns and toggles a column into the index", async () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
const onChange = vi.fn();
render(
<IndexForm
connectionId="c1"
params={{
schema: "public",
table: "users",
name: "i",
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
}}
onChange={onChange}
/>,
);
expect(await screen.findByText("email")).toBeInTheDocument();
fireEvent.click(screen.getByText("email"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ action: expect.objectContaining({ columns: ["email"] }) }),
);
});
it("toggles unique", () => {
vi.mocked(cmd.getSchemaGraph).mockResolvedValue({
tables: [{ name: "users", schema: "public", table_type: "BASE TABLE", columns: [] }],
relationships: [],
} as any);
const onChange = vi.fn();
render(
<IndexForm
connectionId="c1"
params={{
schema: "public",
table: "users",
name: "i",
action: { op: "create", unique: false, method: "btree", columns: [], predicate: null },
}}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByLabelText("Unique"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ action: expect.objectContaining({ unique: true }) }),
);
});
});
@@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import * as cmd from "../../../lib/commands";
import type { SchemaGraph } from "../../../lib/types";
import { ColumnPicker } from "./ColumnPicker";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
const METHODS = ["", "btree", "hash", "gist", "gin", "brin"];
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function IndexForm({ connectionId, params, schemas, onChange }: Props) {
const p = params as Record<string, unknown>;
const action = (p.action ?? {}) as Record<string, unknown>;
const [cols, setCols] = useState<string[]>([]);
useEffect(() => {
cmd
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
.then((g: SchemaGraph) => {
const t = g.tables.find(
(t) => t.name === (p.table as string) && t.schema === (p.schema as string),
);
setCols(t ? t.columns.map((c) => c.name) : []);
})
.catch(() => setCols([]));
}, [connectionId, p.schema, p.table]);
const selected: string[] = (action.columns as string[]) ?? [];
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(p.schema as string) ?? ""}
onChange={(e) => onChange({ ...p, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(p.table as string) ?? ""}
onChange={(e) => onChange({ ...p, table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Index name"
value={(p.name as string) ?? ""}
onChange={(e) => onChange({ ...p, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Unique">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.unique}
onChange={(e) => setAction({ unique: e.target.checked })}
aria-label="Unique"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>Unique</span>
</label>
</FormRow>
<FormRow label="Method">
<select
aria-label="Method"
value={(action.method as string) ?? ""}
onChange={(e) => setAction({ method: e.target.value })}
className={controlClass}
>
{METHODS.map((m) => (
<option key={m} value={m}>
{m || "(default btree)"}
</option>
))}
</select>
</FormRow>
<FormRow label="Columns" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
<ColumnPicker
cols={cols}
selected={selected}
onToggle={(c) =>
setAction({
columns: selected.includes(c)
? selected.filter((x) => x !== c)
: [...selected, c],
})
}
/>
</div>
</FormRow>
<FormRow label="Predicate">
<input
type="text"
placeholder="WHERE predicate (optional)"
value={(action.predicate as string) ?? ""}
onChange={(e) => setAction({ predicate: e.target.value || null })}
className={inputClass}
/>
</FormRow>
</div>
);
}
@@ -0,0 +1,68 @@
import { SequenceForm } from "./SequenceForm";
import { EnumForm } from "./EnumForm";
import { ExtensionForm } from "./ExtensionForm";
import { ViewForm } from "./ViewForm";
import { IndexForm } from "./IndexForm";
import { ConstraintForm } from "./ConstraintForm";
import { FunctionForm } from "./FunctionForm";
import { TriggerForm } from "./TriggerForm";
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
interface Props {
connectionId: string;
kind: ObjectKind;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
/** Renders the matching CRUD form for a kind (shared by the context menu and the form tab). */
export function KindForm({ connectionId, kind, params, schemas, onChange }: Props) {
switch (kind) {
case "sequence":
return <SequenceForm params={params} schemas={schemas} onChange={onChange} />;
case "enum":
return <EnumForm params={params} schemas={schemas} onChange={onChange} />;
case "extension":
return (
<ExtensionForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "view":
return <ViewForm params={params} schemas={schemas} onChange={onChange} />;
case "index":
return (
<IndexForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "constraint":
return (
<ConstraintForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
case "function":
case "procedure":
return <FunctionForm kind={kind} params={params} schemas={schemas} onChange={onChange} />;
case "trigger":
return (
<TriggerForm
connectionId={connectionId}
params={params}
schemas={schemas}
onChange={onChange}
/>
);
}
}
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ObjectContextMenu } from "./ObjectContextMenu";
import * as objectCrud from "../../../lib/objectCrud";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
vi.mock("../../../lib/objectCrud", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../lib/objectCrud")>();
return {
...actual,
buildObjectDdl: vi.fn(),
getAvailableExtensions: vi.fn(),
};
});
describe("ObjectContextMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
useDbViewerStore.getState().reset();
});
it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => {
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Edit…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("edit");
expect(st.tabs[0].form?.kind).toBe("sequence");
expect(st.tabs[0].form?.params?.name).toBe("s");
expect(st.tabs[0].form?.params?.schema).toBe("public");
});
it("Create on a sequence opens an objectForm tab in create mode", async () => {
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Create…"));
const st = useDbViewerStore.getState();
expect(st.tabs).toHaveLength(1);
expect(st.tabs[0].tabType).toBe("objectForm");
expect(st.tabs[0].form?.mode).toBe("create");
expect(st.tabs[0].form?.kind).toBe("sequence");
expect(st.tabs[0].form?.params?.name).toBe("");
});
it("Drop fetches dependencies then stages the drop", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
'DROP SEQUENCE "public"."s"',
]);
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(
<ObjectContextMenu
connectionId="c1"
objectType="sequence"
item={{ schema: "public", name: "s" }}
onRefresh={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /actions/i }));
fireEvent.click(screen.getByText("Drop…"));
// DependencyDialog with no deps → Proceed is enabled
expect(
await screen.findByRole("button", { name: /proceed/i }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /proceed/i }));
await waitFor(() =>
expect(addChange).toHaveBeenCalledWith(
expect.objectContaining({
type: "ddl",
sql: 'DROP SEQUENCE "public"."s"',
}),
),
);
});
});
@@ -0,0 +1,188 @@
import { useEffect, useRef, useState } from "react";
import { DependencyDialog } from "../DependencyDialog";
import {
buildObjectDdl,
dropCrudParams,
initialCrudParams,
type CrudItem,
type ObjectKind,
} from "../../../lib/objectCrud";
import { getObjectDependencies } from "../../../lib/commands";
import type { DependencyInfo } from "../../../lib/types";
import { useDbViewerStore } from "../../../stores/dbViewerStore";
interface Props {
connectionId: string;
objectType: ObjectKind;
item: CrudItem;
onRefresh: () => void;
/** Controlled open state (e.g. driven by a row's right-click). */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Extra non-CRUD actions appended below the divider (Copy DDL, Dependencies…). */
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
}
const DROP_TITLE: Record<ObjectKind, string> = {
sequence: "sequence",
enum: "type",
view: "view",
extension: "extension",
index: "index",
constraint: "constraint",
function: "function",
procedure: "procedure",
trigger: "trigger",
};
export function ObjectContextMenu({
connectionId,
objectType,
item,
onRefresh,
open,
onOpenChange,
extraItems,
}: Props) {
const [internalOpen, setInternalOpen] = useState(false);
const [deps, setDeps] = useState<DependencyInfo[] | null>(null);
const addChange = useDbViewerStore((s) => s.addChange);
const menuRef = useRef<HTMLDivElement>(null);
const isOpen = open ?? internalOpen;
const setOpen = (v: boolean) => {
if (onOpenChange) onOpenChange(v);
else setInternalOpen(v);
};
// Track the current open state in a ref so the document-level outside-click
// handler (registered once) only closes a menu that is actually open. Without
// this, closed instances would fire onOpenChange(false) on every mousedown
// and clobber the shared open key in the controlled (row right-click) case.
const openRef = useRef(isOpen);
openRef.current = isOpen;
useEffect(() => {
const h = (e: MouseEvent) => {
if (
openRef.current &&
menuRef.current &&
!menuRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const kind = objectType;
const openFormTab = (mode: "create" | "edit") => {
const title = `${mode === "create" ? "Create" : "Edit"} ${kind}`;
const description = `${mode === "create" ? "Create" : "Edit"} ${
item.name || kind
}`;
useDbViewerStore.getState().openFormTab({
kind,
schema:
mode === "create"
? useDbViewerStore.getState().currentSchema ?? item.schema ?? "public"
: item.schema,
name: mode === "edit" ? item.name : "",
title,
description,
mode,
params: initialCrudParams(kind, item, mode),
});
setOpen(false);
};
const startDrop = async () => {
setOpen(false);
let d: DependencyInfo[] = [];
try {
d = await getObjectDependencies(connectionId, item.schema, kind, item.name);
} catch {
d = [];
}
setDeps(d);
};
const confirmDrop = async () => {
const sqls = await buildObjectDdl(
connectionId,
kind,
dropCrudParams(kind, item),
);
sqls.forEach((sql) =>
addChange({
type: "ddl",
sql,
description: `Drop ${DROP_TITLE[kind]} ${item.name}`,
}),
);
setDeps(null);
onRefresh();
};
return (
<div ref={menuRef} className="relative">
<button
onClick={() => setOpen(!isOpen)}
aria-label="actions"
className="text-text-muted hover:text-text cursor-pointer"
>
</button>
{isOpen && (
<div className="absolute right-0 top-6 z-20 w-40 rounded-lg border border-border bg-surface py-1 text-sm text-text shadow-lg">
<button
onClick={() => openFormTab("create")}
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
>
Create
</button>
<button
onClick={() => openFormTab("edit")}
className="block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer"
>
Edit
</button>
<button
onClick={startDrop}
className="block w-full text-left px-3 py-1.5 text-red-400 hover:bg-border/30 cursor-pointer"
>
Drop
</button>
{extraItems && extraItems.length > 0 && (
<>
<div className="my-1 border-t border-border" />
{extraItems.map((it) => (
<button
key={it.id}
type="button"
onClick={() => {
setOpen(false);
it.onClick();
}}
className={`block w-full text-left px-3 py-1.5 hover:bg-border/30 cursor-pointer ${
it.danger ? "text-red-400" : ""
}`}
>
{it.label}
</button>
))}
</>
)}
</div>
)}
<DependencyDialog
open={!!deps}
deps={deps ?? []}
onProceed={confirmDrop}
onCancel={() => setDeps(null)}
/>
</div>
);
}
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ObjectDetail } from "./ObjectDetail";
describe("ObjectDetail", () => {
it("renders an enum's labels as list items", () => {
render(
<ObjectDetail
connectionId="c1"
type="enums"
item={{ name: "role", schema: "public", labels: ["admin", "user"] }}
/>,
);
expect(screen.getByText("admin")).toBeInTheDocument();
expect(screen.getByText("user")).toBeInTheDocument();
});
it("renders a function's return type and language", () => {
render(
<ObjectDetail
connectionId="c1"
type="functions"
item={{
name: "add",
schema: "public",
return_type: "int",
argument_types: ["integer", "text"],
argument_names: ["a", "b"],
argument_modes: ["IN", "IN"],
language: "plpgsql",
source: "BEGIN RETURN a+b; END",
kind: "f",
}}
/>,
);
expect(screen.getByText("int")).toBeInTheDocument();
expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,974 @@
import { useMemo, useState } from "react";
import {
BookMarked,
FunctionSquare,
GitBranch,
ListChecks,
ListOrdered,
Puzzle,
SquareFunction,
Tag,
} from "lucide-react";
import type {
ObjectType,
FunctionInfo,
TriggerInfo,
SequenceInfo,
EnumInfo,
ExtensionInfo,
IndexInfo,
ConstraintInfo,
} from "../../../lib/types";
export const TYPE_LABELS: Record<ObjectType, string> = {
functions: "Functions",
triggers: "Triggers",
sequences: "Sequences",
enums: "Enums",
extensions: "Extensions",
indexes: "Indexes",
constraints: "Constraints",
procedures: "Procedures",
};
export const SINGULAR_LABELS: Record<ObjectType, string> = {
functions: "function",
triggers: "trigger",
sequences: "sequence",
enums: "enum",
extensions: "extension",
indexes: "index",
constraints: "constraint",
procedures: "procedure",
};
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
functions: (
<FunctionSquare size={14} className="text-text-muted shrink-0" />
),
triggers: <GitBranch size={14} className="text-text-muted shrink-0" />,
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
enums: <Tag size={14} className="text-text-muted shrink-0" />,
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
};
export type AnyObject =
| FunctionInfo
| TriggerInfo
| SequenceInfo
| EnumInfo
| ExtensionInfo
| IndexInfo
| ConstraintInfo;
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
const SQL_KEYWORDS = new Set([
"ADD",
"ALL",
"ALTER",
"AND",
"ANY",
"AS",
"ASC",
"BEGIN",
"BETWEEN",
"BY",
"CALL",
"CASCADE",
"CASE",
"CAST",
"CHECK",
"CLOSE",
"COLLATE",
"COLUMN",
"COMMIT",
"CONSTRAINT",
"CONTINUE",
"CREATE",
"CROSS",
"CURRENT",
"CURSOR",
"DECLARE",
"DEFAULT",
"DELETE",
"DESC",
"DISTINCT",
"DO",
"DROP",
"ELSE",
"ELSIF",
"END",
"EXCEPTION",
"EXECUTE",
"EXISTS",
"EXIT",
"FETCH",
"FOR",
"FOREIGN",
"FROM",
"FULL",
"FUNCTION",
"GRANT",
"GROUP",
"HAVING",
"IF",
"IN",
"INDEX",
"INNER",
"INSERT",
"INTO",
"IS",
"JOIN",
"KEY",
"LANGUAGE",
"LEFT",
"LIMIT",
"LOOP",
"NOT",
"NULL",
"OF",
"OFFSET",
"ON",
"OPEN",
"OR",
"ORDER",
"OUTER",
"OVER",
"PERFORM",
"PLPGSQL",
"PRIMARY",
"PROCEDURE",
"QUERY",
"RAISE",
"REFERENCES",
"REPLACE",
"RETURN",
"RETURNS",
"REVOKE",
"RIGHT",
"ROLLBACK",
"ROW",
"ROWS",
"SCHEMA",
"SELECT",
"SET",
"STRICT",
"TABLE",
"THEN",
"TO",
"TRIGGER",
"UNION",
"UPDATE",
"USING",
"VALUES",
"VIEW",
"WHEN",
"WHERE",
"WHILE",
"WITH",
]);
const SQL_TYPES = new Set([
"BIGINT",
"BIGSERIAL",
"BIT",
"BOOL",
"BOOLEAN",
"BPCHAR",
"BYTEA",
"CHAR",
"CHARACTER",
"DATE",
"DECIMAL",
"DOUBLE",
"FLOAT",
"FLOAT4",
"FLOAT8",
"INT",
"INT2",
"INT4",
"INT8",
"INTEGER",
"INTERVAL",
"JSON",
"JSONB",
"MONEY",
"NAME",
"NUMERIC",
"OID",
"REAL",
"SERIAL",
"SMALLINT",
"TEXT",
"TIME",
"TIMESTAMP",
"TIMESTAMPTZ",
"UUID",
"VARBIT",
"VARCHAR",
"VOID",
"XML",
]);
interface Token {
text: string;
kind:
| "keyword"
| "type"
| "string"
| "comment"
| "number"
| "operator"
| "plain";
}
function tokenizeLine(line: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < line.length) {
if (/\s/.test(line[i])) {
let ws = "";
while (i < line.length && /\s/.test(line[i])) {
ws += line[i];
i++;
}
tokens.push({ text: ws, kind: "plain" });
continue;
}
if (line[i] === "-" && line[i + 1] === "-") {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
if (line[i] === "/" && line[i + 1] === "*") {
const end = line.indexOf("*/", i + 2);
if (end !== -1) {
tokens.push({ text: line.slice(i, end + 2), kind: "comment" });
i = end + 2;
} else {
tokens.push({ text: line.slice(i), kind: "comment" });
return tokens;
}
continue;
}
if (line[i] === "$") {
let dollar = "";
const start = i;
while (i < line.length && line[i] === "$") {
dollar += "$";
i++;
}
let tag = "";
if (dollar.length === 1 && i < line.length && line[i] !== "$") {
while (i < line.length && line[i] !== "$") {
tag += line[i];
i++;
}
if (line[i] === "$") {
i++;
dollar = `$${tag}$`;
}
}
const endTag = dollar;
const endIdx = line.indexOf(endTag, i);
if (endIdx !== -1) {
tokens.push({
text: line.slice(start, endIdx + endTag.length),
kind: "string",
});
i = endIdx + endTag.length;
} else {
tokens.push({ text: line.slice(start), kind: "string" });
return tokens;
}
continue;
}
if (line[i] === "'") {
let str = "'";
i++;
while (i < line.length) {
if (line[i] === "'" && line[i + 1] === "'") {
str += "''";
i += 2;
continue;
}
if (line[i] === "'") {
str += "'";
i++;
break;
}
str += line[i];
i++;
}
tokens.push({ text: str, kind: "string" });
continue;
}
if (/[0-9]/.test(line[i])) {
let num = "";
while (i < line.length && /[0-9.]/.test(line[i])) {
num += line[i];
i++;
}
tokens.push({ text: num, kind: "number" });
continue;
}
if (/[=<>!+\-*/%&|^~@#;,.[\](){}]/.test(line[i])) {
let op = line[i];
i++;
if (i < line.length) {
const pair = op + line[i];
if ([":=", "=>", "<=", ">=", "<>", "||", "::"].includes(pair)) {
op = pair;
i++;
}
}
tokens.push({ text: op, kind: "operator" });
continue;
}
let word = "";
while (i < line.length && /[a-zA-Z_]/.test(line[i])) {
word += line[i];
i++;
}
if (word) {
const upper = word.toUpperCase();
if (SQL_KEYWORDS.has(upper)) {
tokens.push({ text: word, kind: "keyword" });
} else if (SQL_TYPES.has(upper)) {
tokens.push({ text: word, kind: "type" });
} else {
tokens.push({ text: word, kind: "plain" });
}
} else {
// Catch-all for any character not matched above (non-ASCII, symbols, etc.)
tokens.push({ text: line[i], kind: "plain" });
i++;
}
}
return tokens;
}
function SyntaxCode({
source,
language: _language,
}: {
source: string;
language?: string;
}) {
const [expanded, setExpanded] = useState(false);
const maxLines = 60;
// Memoize the tokenized output — source doesn't change while viewing
const { displayLines, maxLineNum, truncated, totalLines } = useMemo(() => {
const lines: string[] = source.split("\n");
const total: number = lines.length;
const isTruncated: boolean = !expanded && total > maxLines;
const display: string[] = isTruncated
? lines.slice(0, maxLines)
: lines;
const maxNum: number = String(display.length).length;
const tokenized = display.map((line: string) => ({
tokens: tokenizeLine(line),
}));
return {
displayLines: tokenized,
maxLineNum: maxNum,
truncated: isTruncated,
totalLines: total,
};
}, [source, expanded]);
const TOKEN_COLORS: Record<string, string> = {
keyword: "text-blue-400",
type: "text-emerald-400",
string: "text-amber-300",
comment: "text-text-subtle italic",
number: "text-purple-400",
operator: "text-text-muted",
plain: "text-text",
};
return (
<div>
<div className="overflow-x-auto overscroll-x-none">
<pre className="text-xs leading-6 font-mono whitespace-pre w-max min-w-full">
{displayLines.map(
(entry: { tokens: Token[] }, i: number) => {
const { tokens } = entry;
const num = String(i + 1).padStart(maxLineNum, " ");
return (
<div
key={i}
className="flex hover:bg-surface/30"
>
<span
className="inline-block text-right select-none text-text-subtle border-r border-border pr-3 mx-3 shrink-0"
style={{
minWidth: `${maxLineNum + 2}ch`,
}}
>
{num}
</span>
<span className="flex-1 whitespace-pre">
{tokens.length === 1 &&
tokens[0].text.trim() === ""
? "\u00A0"
: tokens.map((t, j) => (
<span
key={j}
className={
TOKEN_COLORS[t.kind]
}
>
{t.text}
</span>
))}
</span>
</div>
);
},
)}
</pre>
</div>
{truncated && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(true)}
className="text-xs text-accent hover:underline"
>
Show all {totalLines} lines
</button>
</div>
)}
{expanded && totalLines > maxLines && (
<div className="flex items-center justify-center py-1.5 border-t border-border">
<button
type="button"
onClick={() => setExpanded(false)}
className="text-xs text-accent hover:underline"
>
Collapse
</button>
</div>
)}
</div>
);
}
function renderFunctionDetail(f: FunctionInfo) {
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Signature
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Returns
</span>
<span className="text-sm text-accent font-mono">
{f.return_type || "void"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Language
</span>
<span className="text-sm text-text">
{f.language}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Kind
</span>
<span className="text-sm text-text">
{f.kind === "f" ? "Function" : "Procedure"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{f.schema}
</span>
</div>
</div>
{f.argument_names.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Arguments
</span>
<span className="text-[10px] text-text-subtle">
{f.argument_names.length} total
</span>
</div>
{f.argument_names.map((name, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<div className="w-24 shrink-0">
<span className="text-xs text-text-muted">
{f.argument_modes?.[i] &&
f.argument_modes[i] !==
"IN" && (
<span className="text-amber-400 font-medium mr-1">
{f.argument_modes[i]}
</span>
)}
#{i + 1}
</span>
</div>
<span className="text-sm text-accent font-mono">
{name}
</span>
<span className="mx-2 text-border">:</span>
<span className="text-sm text-text-muted font-mono">
{f.argument_types?.[i] || "unknown"}
</span>
</div>
))}
</>
)}
{f.source && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Source
</span>
<span className="text-[10px] text-text-subtle">
{f.language}
</span>
</div>
<SyntaxCode
source={f.source}
language={f.language}
/>
</>
)}
</div>
);
}
function renderDetail(type: ObjectType, item: AnyObject) {
switch (type) {
case "functions":
return renderFunctionDetail(item as FunctionInfo);
case "procedures":
return renderFunctionDetail(item as FunctionInfo);
case "indexes": {
const idx = item as IndexInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Index
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{idx.table}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Method
</span>
<span className="text-sm text-accent font-mono">
{idx.method}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Unique
</span>
<span
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
>
{idx.is_unique ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Size
</span>
<span className="text-sm text-text font-mono">
{idx.size_bytes ?? "-"}
</span>
</div>
</div>
{idx.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{idx.columns.length}
</span>
</div>
{idx.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{idx.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={idx.definition} />
</>
)}
</div>
);
}
case "constraints": {
const c = item as ConstraintInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Constraint
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Type
</span>
<span className="text-sm text-accent font-mono">
{c.contype}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{c.table}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Deferrable
</span>
<span
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
>
{c.deferrable ? "Yes" : "No"}
</span>
</div>
<div className="px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Validated
</span>
<span
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
>
{c.validated ? "Yes" : "No"}
</span>
</div>
</div>
{c.columns.length > 0 && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Columns
</span>
<span className="text-[10px] text-text-subtle">
{c.columns.length}
</span>
</div>
{c.columns.map((col, i) => (
<div
key={i}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{col}
</span>
</div>
))}
</>
)}
{c.definition && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={c.definition} />
</>
)}
</div>
);
}
case "triggers": {
const t = item as TriggerInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Table
</span>
<span className="text-sm text-text font-mono">
{t.table_schema}.{t.table_name}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Event
</span>
<span className="text-sm text-text">
{t.event_manipulation}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Timing
</span>
<span className="text-sm text-text">
{t.action_timing} {t.action_orientation}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Status
</span>
<span
className={`text-sm ${t.enabled === "O" ? "text-emerald-400" : "text-red-400"}`}
>
{t.enabled === "O"
? "Enabled"
: t.enabled === "D"
? "Disabled"
: t.enabled}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{t.schema}
</span>
</div>
{t.action_statement && (
<>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Definition
</span>
<span className="text-[10px] text-text-subtle">
SQL
</span>
</div>
<SyntaxCode source={t.action_statement} />
</>
)}
</div>
);
}
case "sequences": {
const s = item as SequenceInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Sequence Values
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Current Value
</span>
<span className="text-sm text-accent font-mono">
{s.current_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Increment
</span>
<span className="text-sm text-text font-mono">
{s.increment}
</span>
</div>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-28 shrink-0">
Start
</span>
<span className="text-sm text-text font-mono">
{s.start_value}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Min / Max
</span>
<span className="text-sm text-text font-mono">
{s.min_value} / {s.max_value}
</span>
</div>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-28 shrink-0">
Cycle
</span>
<span
className={`text-sm ${s.cycle ? "text-amber-400" : "text-text-muted"}`}
>
{s.cycle ? "Yes" : "No"}
</span>
</div>
</div>
);
}
case "enums": {
const e = item as EnumInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Details
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Values
</span>
<span className="text-[10px] text-text-subtle">
{e.labels.length} labels
</span>
</div>
{e.labels.map((label, i) => (
<div
key={label}
className="border-b border-border px-4 py-2 flex items-center"
>
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
#{i + 1}
</span>
<span className="text-sm text-accent font-mono">
{label}
</span>
</div>
))}
</div>
);
}
case "extensions": {
const e = item as ExtensionInfo;
return (
<div>
<div className="border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
Extension
</span>
</div>
<div className="border-b border-border flex flex-row">
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
<span className="text-xs text-text-muted w-24 shrink-0">
Version
</span>
<span className="text-sm text-text font-mono">
{e.version}
</span>
</div>
<div className="flex-2 px-4 py-2 flex items-center">
<span className="text-xs text-text-muted w-24 shrink-0">
Schema
</span>
<span className="text-sm text-text font-mono">
{e.schema}
</span>
</div>
</div>
{e.comment && (
<div className="border-b border-border px-4 py-2.5">
<span className="text-xs text-text-muted block mb-1">
Comment
</span>
<p className="text-sm text-text leading-relaxed">
{e.comment}
</p>
</div>
)}
</div>
);
}
}
}
interface ObjectDetailProps {
connectionId: string;
type: ObjectType;
item: AnyObject;
}
export function ObjectDetail({ connectionId: _connectionId, type, item }: ObjectDetailProps) {
return <>{renderDetail(type, item)}</>;
}
@@ -0,0 +1,159 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { ObjectFormTab } from "./ObjectFormTab";
import * as objectCrud from "../../../lib/objectCrud";
import {
useDbViewerStore,
type ViewerTab,
} from "../../../stores/dbViewerStore";
vi.mock("../../../lib/objectCrud", () => ({
buildObjectDdl: vi.fn(),
}));
vi.mock("../../editor/SqlEditorField", () => ({
SqlEditorField: ({ value, readOnly }: { value: string; readOnly?: boolean }) => (
<textarea data-testid="sql-editor" readOnly={readOnly} value={value} onChange={() => {}} />
),
}));
describe("ObjectFormTab", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
useDbViewerStore.getState().reset();
});
const connectionId = "c1";
const baseTab: ViewerTab = {
id: "form-tab-1",
schema: "public",
table: "Create sequence",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: null,
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: false,
tabType: "objectForm",
objectType: null,
form: {
kind: "sequence",
title: "Create sequence",
description: "Create my_seq",
mode: "create",
params: { schema: "public", name: "my_seq", action: { op: "create" } },
},
};
it("renders the form for a sequence kind in Visual view", () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
it("toggles to SQL and shows the generated SQL, then back to Visual", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: "SQL" }));
const ed = await screen.findByTestId("sql-editor");
expect(ed).toHaveValue('CREATE SEQUENCE "public"."my_seq" START WITH 1;');
expect(ed).toHaveProperty("readOnly", true);
fireEvent.click(screen.getByRole("button", { name: "Visual" }));
expect(screen.getByPlaceholderText("Sequence name")).toBeInTheDocument();
});
it("stages one ddl change and closes the tab", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
useDbViewerStore.setState({
tabs: [baseTab],
activeTabId: baseTab.id,
});
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
await waitFor(() =>
expect(addChange).toHaveBeenCalledWith(
expect.objectContaining({
type: "ddl",
sql: 'CREATE SEQUENCE "public"."my_seq" START WITH 1;',
description: "Create my_seq",
}),
),
);
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
});
it("stages multiple statements with (n/total) descriptions", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
"DROP SEQUENCE \"public\".\"my_seq\";",
"CREATE SEQUENCE \"public\".\"my_seq\" START WITH 1;",
]);
useDbViewerStore.setState({
tabs: [baseTab],
activeTabId: baseTab.id,
});
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
fireEvent.click(screen.getByRole("button", { name: /stage/i }));
await waitFor(() => {
expect(addChange).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ description: "Create my_seq (1/2)" }),
);
expect(addChange).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ description: "Create my_seq (2/2)" }),
);
});
expect(useDbViewerStore.getState().tabs).toHaveLength(0);
});
it("shows a builder error and disables Stage", async () => {
vi.mocked(objectCrud.buildObjectDdl).mockRejectedValueOnce(
"Name is required",
);
render(<ObjectFormTab connectionId={connectionId} tab={baseTab} />);
await waitFor(() =>
expect(screen.getByText(/Name is required/)).toBeInTheDocument(),
);
expect(screen.getByRole("button", { name: /stage/i })).toBeDisabled();
});
it("renders only the uppercase kicker, not a duplicate title heading", () => {
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([]);
render(
<ObjectFormTab
connectionId={connectionId}
tab={{
...baseTab,
table: "Create Function",
form: {
...baseTab.form!,
kind: "function",
title: "Create Function",
},
}}
/>,
);
// Kicker: lowercase DOM text uppercased by CSS
expect(screen.getByText("create function")).toBeInTheDocument();
expect(screen.queryByText("Create Function")).toBeNull();
});
});
@@ -0,0 +1,155 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { KindForm } from "./KindForm";
import {
buildObjectDdl,
type DdlParams,
} from "../../../lib/objectCrud";
import { useDbViewerStore, type ViewerTab } from "../../../stores/dbViewerStore";
const SqlEditorField = lazy(() =>
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
);
interface Props {
connectionId: string;
tab: ViewerTab;
}
export function ObjectFormTab({ connectionId, tab }: Props) {
const form = tab.form;
if (!form) return null;
const { kind, params, description, mode } = form;
const [view, setView] = useState<"visual" | "sql">("visual");
const [preview, setPreview] = useState("");
const [error, setError] = useState<string | null>(null);
const open = true;
useEffect(() => {
if (!open) return;
let active = true;
buildObjectDdl(connectionId, kind, params)
.then((sqls) => {
if (active) {
setPreview(sqls.join("\n;\n"));
setError(null);
}
})
.catch((e) => {
if (active) {
setPreview("");
setError(e instanceof Error ? e.message : String(e));
}
});
return () => {
active = false;
};
}, [open, connectionId, kind, params]);
const stage = async () => {
const sqls = await buildObjectDdl(connectionId, kind, params);
sqls.forEach((sql, i) =>
useDbViewerStore.getState().addChange({
type: "ddl",
sql,
description:
sqls.length > 1
? `${description} (${i + 1}/${sqls.length})`
: description,
}),
);
useDbViewerStore.getState().closeTab(tab.id);
};
const schemas = useDbViewerStore((s) => s.schemas);
const handleChange = (next: DdlParams) => {
useDbViewerStore.getState().updateFormTabParams(tab.id, next);
};
return (
<div className="flex h-full flex-col bg-transparent">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{mode} {kind}
</span>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-border overflow-hidden">
<button
type="button"
aria-label="Visual"
onClick={() => setView("visual")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "visual"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
Visual
</button>
<button
type="button"
aria-label="SQL"
onClick={() => setView("sql")}
className={[
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
view === "sql"
? "bg-surface-raised text-text"
: "text-text-muted hover:text-text",
].join(" ")}
>
SQL
</button>
</div>
<button
type="button"
onClick={stage}
disabled={!!error}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Stage
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
{view === "visual" ? (
<KindForm
connectionId={connectionId}
kind={kind}
params={params}
schemas={schemas}
onChange={handleChange}
/>
) : (
<div className="px-4 py-3">
<Suspense
fallback={
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">
{preview}
</pre>
}
>
<SqlEditorField
value={preview}
onChange={() => {}}
readOnly
height={420}
/>
</Suspense>
</div>
)}
</div>
{error && (
<div className="border-t border-border px-4 py-2">
<p className="text-xs text-red-400">{error}</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,113 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { SequenceForm } from "./SequenceForm";
import type { DdlParams } from "../../../lib/objectCrud";
function StatefulSequenceForm({ initialParams }: { initialParams: DdlParams }) {
const [params, setParams] = useState(initialParams);
return <SequenceForm params={params} onChange={setParams} />;
}
describe("SequenceForm", () => {
it("renders create fields and emits params on change", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{
schema: "public",
name: "s",
action: {
op: "create",
increment: "1",
min_value: "1",
max_value: "9",
start: "1",
cycle: false,
},
}}
onChange={onChange}
/>,
);
expect(screen.getByPlaceholderText("Sequence name")).toHaveValue("s");
fireEvent.change(screen.getByPlaceholderText("Sequence name"), {
target: { value: "s2" },
});
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ name: "s2" }),
);
});
it("renders a schema dropdown when schemas are provided", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{ schema: "public", name: "s", action: { op: "create" } }}
schemas={["public", "utils"]}
onChange={onChange}
/>,
);
const select = screen.getByLabelText("Schema");
expect(select).toBeInTheDocument();
expect(screen.getByRole("option", { name: "public" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "utils" })).toBeInTheDocument();
fireEvent.change(select, { target: { value: "utils" } });
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ schema: "utils" }),
);
});
it("the editing outline wraps only the value area, not the label cell", () => {
const onChange = vi.fn();
render(
<SequenceForm
params={{ schema: "public", name: "s", action: { op: "create" } }}
onChange={onChange}
/>,
);
// The children wrapper (direct parent of the input) carries the amber
// focus-within editing outline, exactly like a grid editing cell.
const input = screen.getByPlaceholderText("Sequence name");
const valueArea = input.parentElement;
expect(valueArea).not.toBeNull();
expect(valueArea!.className).toContain("focus-within:outline");
expect(valueArea!.className).toContain("focus-within:outline-amber-400");
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
// The label cell must stay clean: no ancestor of the label may carry
// the editing outline (regression: the old row-level outline lit up the
// whole row, label cell included).
const label = screen.getByText("Name");
expect(label.closest('[class*="focus-within:outline"]')).toBeNull();
});
it("switching to restart shows only the with-field", () => {
render(
<StatefulSequenceForm
initialParams={{
schema: "public",
name: "s",
action: {
op: "create",
increment: "1",
min_value: "1",
max_value: "9",
start: "1",
cycle: false,
},
}}
/>,
);
const opSelect = screen.getByLabelText("Operation");
fireEvent.change(opSelect, { target: { value: "restart" } });
expect(screen.getByPlaceholderText("Restart with")).toBeInTheDocument();
expect(screen.queryByPlaceholderText("Increment")).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText("Start")).not.toBeInTheDocument();
expect(screen.queryByLabelText("CYCLE")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,166 @@
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type SequenceOp = "create" | "alter" | "restart";
const OP_LABELS: Record<SequenceOp, string> = {
create: "Create",
alter: "Alter",
restart: "Restart",
};
function getOp(params: DdlParams): SequenceOp {
const action = (params.action ?? {}) as Record<string, unknown>;
const op = action.op;
if (op === "alter" || op === "restart") return op;
return "create";
}
function patchAction(
params: DdlParams,
patch: Record<string, unknown>,
): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function SequenceForm({ params, schemas, onChange }: Props) {
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Sequence name"
value={(params.name as string) ?? ""}
onChange={(e) => onChange({ ...params, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) =>
onChange(patchAction(params, { op: e.target.value }))
}
className={controlClass}
>
{(Object.keys(OP_LABELS) as SequenceOp[]).map((key) => (
<option key={key} value={key}>
{OP_LABELS[key]}
</option>
))}
</select>
</FormRow>
{(op === "create" || op === "alter") && (
<>
<FormRow label="Increment">
<input
type="text"
placeholder="Increment"
value={(action.increment as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { increment: e.target.value }))
}
className={inputClass}
/>
</FormRow>
<FormRow label="Min value">
<input
type="text"
placeholder="Min value"
value={(action.min_value as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { min_value: e.target.value }))
}
className={inputClass}
/>
</FormRow>
<FormRow label="Max value">
<input
type="text"
placeholder="Max value"
value={(action.max_value as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { max_value: e.target.value }))
}
className={inputClass}
/>
</FormRow>
{op === "create" && (
<FormRow label="Start">
<input
type="text"
placeholder="Start"
value={(action.start as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { start: e.target.value }))
}
className={inputClass}
/>
</FormRow>
)}
<FormRow label="Cycle">
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
<input
type="checkbox"
checked={!!action.cycle}
onChange={(e) =>
onChange(patchAction(params, { cycle: e.target.checked }))
}
aria-label="CYCLE"
className="rounded border-border bg-surface text-accent focus:ring-accent"
/>
<span>CYCLE</span>
</label>
</FormRow>
</>
)}
{op === "restart" && (
<FormRow label="Restart with">
<input
type="text"
placeholder="Restart with"
value={(action.with as string) ?? ""}
onChange={(e) =>
onChange(patchAction(params, { with: e.target.value }))
}
className={inputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,93 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TriggerForm } from "./TriggerForm";
import * as cmd from "../../../lib/commands";
import type { DdlParams } from "../../../lib/objectCrud";
vi.mock("../../../lib/commands", () => ({ getFunctions: vi.fn() }));
const baseParams: DdlParams = {
schema: "public",
name: "tr",
action: {
op: "create",
table: "orders",
timing: "BEFORE",
events: ["INSERT"],
orientation: "ROW",
function_schema: "public",
function_name: "",
function_args: [],
when: null,
},
};
describe("TriggerForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists only trigger-returning functions", async () => {
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: "audit_fn",
schema: "public",
return_type: "trigger",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: null,
kind: "f",
},
{
name: "not_a_trigger",
schema: "public",
return_type: "void",
argument_types: [],
argument_names: [],
argument_modes: [],
language: "plpgsql",
source: null,
kind: "f",
},
]);
render(
<TriggerForm
connectionId="c1"
params={baseParams}
onChange={() => {}}
/>,
);
expect(await screen.findByText("audit_fn")).toBeInTheDocument();
expect(screen.queryByText("not_a_trigger")).not.toBeInTheDocument();
});
it("emits timing + events", async () => {
(cmd.getFunctions as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const onChange = vi.fn();
render(
<TriggerForm connectionId="c1" params={baseParams} onChange={onChange} />,
);
await waitFor(() => expect(cmd.getFunctions).toHaveBeenCalledWith("c1", "public"));
fireEvent.change(screen.getByDisplayValue("BEFORE"), {
target: { value: "AFTER" },
});
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ timing: "AFTER" }),
}),
);
fireEvent.click(screen.getByText("UPDATE"));
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({
action: expect.objectContaining({ events: ["INSERT", "UPDATE"] }),
}),
);
});
});
@@ -0,0 +1,234 @@
import { useEffect, useState } from "react";
import { getFunctions } from "../../../lib/commands";
import type { DdlParams } from "../../../lib/objectCrud";
import type { FunctionInfo } from "../../../lib/types";
import { FormRow, inputClass, controlClass } from "./formRow";
interface Props {
connectionId: string;
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type TriggerOp = "create" | "enable" | "disable";
const TIMINGS = ["BEFORE", "AFTER", "INSTEAD OF"];
const EVENTS = ["INSERT", "UPDATE", "DELETE", "TRUNCATE"];
const ORIENT = ["ROW", "STATEMENT"];
function getOp(params: DdlParams): TriggerOp {
const action = (params.action ?? {}) as Record<string, unknown>;
const op = action.op as string;
if (op === "enable" || op === "disable") return op;
return "create";
}
function patchAction(
params: DdlParams,
patch: Record<string, unknown>,
): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function TriggerForm({ connectionId, params, schemas, onChange }: Props) {
const [fns, setFns] = useState<FunctionInfo[]>([]);
const op = getOp(params);
const action = (params.action ?? {}) as Record<string, unknown>;
const events = (action.events as string[]) ?? [];
useEffect(() => {
let cancelled = false;
getFunctions(connectionId, (params.schema as string) || undefined)
.then((all) => {
if (!cancelled) {
setFns(all.filter((f) => f.return_type === "trigger"));
}
})
.catch(() => {
if (!cancelled) setFns([]);
});
return () => {
cancelled = true;
};
}, [connectionId, params.schema]);
const setAction = (patch: Record<string, unknown>) =>
onChange(patchAction(params, patch));
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder="Trigger name"
value={(params.name as string) ?? ""}
onChange={(e) => onChange({ ...params, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) =>
onChange({ ...params, action: { op: e.target.value } })
}
className={controlClass}
>
<option value="create">Create</option>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
</select>
</FormRow>
{op === "create" && (
<>
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(action.table as string) ?? ""}
onChange={(e) => setAction({ table: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Timing">
<select
aria-label="Timing"
value={(action.timing as string) ?? "BEFORE"}
onChange={(e) => setAction({ timing: e.target.value })}
className={controlClass}
>
{TIMINGS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</FormRow>
<FormRow label="Events" className="items-stretch">
<div className="min-w-0 flex-1 flex flex-wrap gap-1 px-4 py-2">
{EVENTS.map((ev) => (
<button
type="button"
key={ev}
onClick={() =>
setAction({
events: events.includes(ev)
? events.filter((x) => x !== ev)
: [...events, ev],
})
}
className={`text-xs px-2 py-1 rounded border transition-colors ${
events.includes(ev)
? "bg-accent text-white border-accent"
: "border-border text-text hover:border-text-muted"
}`}
>
{ev}
</button>
))}
</div>
</FormRow>
<FormRow label="Orientation">
<select
aria-label="Orientation"
value={(action.orientation as string) ?? "ROW"}
onChange={(e) => setAction({ orientation: e.target.value })}
className={controlClass}
>
{ORIENT.map((o) => (
<option key={o} value={o}>
FOR EACH {o}
</option>
))}
</select>
</FormRow>
<FormRow label="Function">
<select
aria-label="Function"
value={(action.function_name as string) ?? ""}
onChange={(e) => {
const f = fns.find((fn) => fn.name === e.target.value);
setAction({
function_name: e.target.value,
function_schema: f?.schema ?? (params.schema as string),
});
}}
className={controlClass}
>
<option value="">(trigger function)</option>
{fns.map((f) => (
<option key={`${f.schema}.${f.name}`} value={f.name}>
{f.name}
</option>
))}
</select>
</FormRow>
<FormRow label="Function args">
<input
type="text"
placeholder="Function args (comma-separated)"
value={(action.function_args as string[] | undefined)?.join(", ") ?? ""}
onChange={(e) =>
setAction({
function_args: e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
})
}
className={inputClass}
/>
</FormRow>
<FormRow label="When">
<input
type="text"
placeholder="WHEN (optional)"
value={(action.when as string) ?? ""}
onChange={(e) =>
setAction({ when: e.target.value || null })
}
className={inputClass}
/>
</FormRow>
</>
)}
{(op === "enable" || op === "disable") && (
<FormRow label="Table">
<input
type="text"
placeholder="Table"
value={(action.table as string) ?? ""}
onChange={(e) => setAction({ table: e.target.value })}
className={inputClass}
/>
</FormRow>
)}
</div>
);
}
@@ -0,0 +1,35 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ViewForm } from "./ViewForm";
vi.mock("../../editor/SqlEditorField", () => ({
SqlEditorField: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<textarea data-testid="sql-editor" value={value} onChange={(e) => onChange(e.target.value)} />
),
}));
describe("ViewForm", () => {
it("create: emits the definition", async () => {
const onChange = vi.fn();
render(
<ViewForm
params={{ schema: "public", name: "v", materialized: false, action: { op: "create", definition: "SELECT 1" } }}
onChange={onChange}
/>,
);
fireEvent.change(await screen.findByTestId("sql-editor"), { target: { value: "SELECT 2" } });
expect(onChange).toHaveBeenLastCalledWith(
expect.objectContaining({ action: expect.objectContaining({ definition: "SELECT 2" }) }),
);
});
it("matview replace shows a note about drop+create", () => {
render(
<ViewForm
params={{ schema: "public", name: "mv", materialized: true, action: { op: "replace", definition: "SELECT 1" } }}
onChange={() => {}}
/>,
);
expect(screen.getByText(/drop and recreate/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,111 @@
import { lazy, Suspense } from "react";
import type { DdlParams } from "../../../lib/objectCrud";
import { FormRow, inputClass, controlClass } from "./formRow";
const SqlEditorField = lazy(() =>
import("../../editor/SqlEditorField").then((m) => ({ default: m.SqlEditorField })),
);
interface Props {
params: DdlParams;
schemas?: string[];
onChange: (p: DdlParams) => void;
}
type ViewOp = "create" | "replace";
function getOp(params: DdlParams): ViewOp {
const action = (params.action ?? {}) as Record<string, unknown>;
const op = action.op;
if (op === "replace") return "replace";
return "create";
}
function patchAction(
params: DdlParams,
patch: Record<string, unknown>,
): DdlParams {
const action = (params.action ?? {}) as Record<string, unknown>;
return { ...params, action: { ...action, ...patch } };
}
export function ViewForm({ params, schemas, onChange }: Props) {
const op = getOp(params);
const isMat = !!params.materialized;
const action = (params.action ?? {}) as Record<string, unknown>;
return (
<div>
<FormRow label="Schema">
{schemas && schemas.length > 0 ? (
<select
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
aria-label="Schema"
className={controlClass}
>
<option value="" disabled>Schema</option>
{schemas.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
) : (
<input
type="text"
placeholder="Schema"
value={(params.schema as string) ?? ""}
onChange={(e) => onChange({ ...params, schema: e.target.value })}
className={inputClass}
/>
)}
</FormRow>
<FormRow label="Name">
<input
type="text"
placeholder={isMat ? "Materialized view name" : "View name"}
value={(params.name as string) ?? ""}
onChange={(e) => onChange({ ...params, name: e.target.value })}
className={inputClass}
/>
</FormRow>
<FormRow label="Operation">
<select
aria-label="Operation"
value={op}
onChange={(e) => onChange(patchAction(params, { op: e.target.value }))}
className={controlClass}
>
<option value="create">{isMat ? "Create" : "Create or replace"}</option>
{isMat && <option value="replace">Replace (drop + create)</option>}
</select>
</FormRow>
{isMat && op === "replace" && (
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-text-muted">
Materialized views cannot be CREATE OR REPLACE this will drop and recreate.
</p>
</div>
)}
<FormRow label={isMat ? "Definition" : "Body"} className="items-stretch" outline={false}>
<div className="min-w-0 flex-1 py-2" style={{ minHeight: 140 }}>
<Suspense
fallback={
<textarea
rows={6}
value={(action.definition as string) ?? ""}
onChange={(e) => onChange(patchAction(params, { definition: e.target.value }))}
className="w-full h-full bg-transparent px-3 font-mono text-xs text-text outline-none resize-none"
/>
}
>
<div className="h-full w-full font-mono">
<SqlEditorField
value={(action.definition as string) ?? ""}
onChange={(v) => onChange(patchAction(params, { definition: v }))}
height={140}
/>
</div>
</Suspense>
</div>
</FormRow>
</div>
);
}
@@ -0,0 +1,62 @@
import type { ReactNode } from "react";
export const inputClass =
"min-w-0 flex-1 bg-transparent px-3 font-heading text-xs text-text outline-none placeholder:text-text-muted";
export const controlClass =
"min-w-0 flex-1 rounded bg-surface px-2 py-1 font-heading text-xs text-text outline-none placeholder:text-text-muted";
export const monoInputClass =
"min-w-0 flex-1 bg-transparent px-3 font-mono text-xs text-text outline-none placeholder:text-text-muted";
export interface FormRowProps {
label: string;
children: ReactNode;
className?: string;
/** When false, the row shows no amber focus outline (e.g. Monaco rows). */
outline?: boolean;
}
export function FormRow({ label, children, className, outline = true }: FormRowProps) {
return (
<div
className={[
"border-b border-border flex flex-row items-stretch",
className ?? "",
].join(" ")}
>
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{label}
</span>
</div>
<div
className={[
"flex-1 min-w-0 flex flex-row items-center",
outline
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400 focus-within:outline-offset-[-2px]"
: "",
].join(" ")}
>
{children}
</div>
</div>
);
}
export function FormSectionHeader({
label,
count,
}: {
label: string;
count?: number | string;
}) {
return (
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
{label}
</span>
{count !== undefined && (
<span className="text-[10px] text-text-subtle">{count}</span>
)}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useCallback } from "react";
import Editor, { type BeforeMount } from "@monaco-editor/react";
import { useSettingsStore } from "../../stores/settingsStore";
interface SqlEditorFieldProps {
value: string;
onChange: (value: string) => void;
height?: number;
readOnly?: boolean;
}
export function SqlEditorField({
value,
onChange,
height = 140,
readOnly = false,
}: SqlEditorFieldProps) {
const editorFontFamily = useSettingsStore(
(s) => s.settings?.editor_font_family ?? "Space Mono",
);
const editorFontSize = useSettingsStore(
(s) => s.settings?.editor_font_size ?? 13,
);
const editorTabSize = useSettingsStore(
(s) => s.settings?.editor_tab_size ?? 4,
);
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
monaco.editor.defineTheme("gridline-sql", {
base: "vs-dark",
inherit: true,
rules: [],
colors: {
"editor.background": "#00000000",
"editorGutter.background": "#00000000",
"editor.lineHighlightBackground": "#ffffff08",
"editorLineNumber.foreground": "#5b5b5e",
"editorLineNumber.activeForeground": "#a1a1a6",
},
});
}, []);
return (
<div className="h-full min-h-0" data-testid="sql-editor-field">
<Editor
height={height}
language="sql"
theme="gridline-sql"
beforeMount={handleBeforeMount}
value={value}
onChange={(v) => onChange(v ?? "")}
options={{
minimap: { enabled: false },
fontSize: editorFontSize,
fontFamily: editorFontFamily,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
readOnly,
automaticLayout: true,
tabSize: editorTabSize,
}}
/>
</div>
);
}
+2 -2
View File
@@ -7,7 +7,7 @@ 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 { cellToUpdateChange, isCellEditable, type TabKind } from "./gridEditability";
import { nextCell, type CellPos } from "./keyboardNav";
interface VirtualDataGridProps {
@@ -21,7 +21,7 @@ interface VirtualDataGridProps {
onToggleRow: (rowIndex: number) => void;
onToggleAll: () => void;
dbType?: string;
tabType?: "table" | "query";
tabType?: TabKind;
onStageEdit?: (payload: {
type: "update";
schema: string;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ColumnInfo, ChangeItemType } from "../../lib/types";
export type TabKind = "table" | "query";
export type TabKind = "table" | "query" | "object" | "objectForm";
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. */