v0.7.7: PG roles/grants, table maintenance, Create/Edit Table + atomic column-reorder rebuild, FK management, table options (#13)
* feat(models): Change::RebuildTable + role/privilege/readiness/maintenance structs (Task 1.1) * feat(introspection): role/privilege/tablespace/rebuild-readiness query builders (Task 1.2) * feat(lib): rebuild_table payload, table/role object kinds, v0.7.7 capabilities + types (Task 1.3) * feat(object_crud): table_ddl builder (create/edit-diff/options/drop) + build_ddl table arm (Task 2.1) * feat(object_crud): role_ddl builder (create/edit/drop/grant/revoke) + build_ddl role arm (Task 2.2) * feat(object_crud): rebuild_script assembler + fk/grant/sequence introspection queries (Task 2.3) * feat(commands): transactional RebuildTable arm + build_rebuild_script + get_table_columns commands (Task 2.4) * feat(commands): get_roles/privileges/readiness/tablespaces + run_maintenance (Task 2.5) * feat(commands): role/privilege/rebuild/maintenance typed wrappers (Task 3.1) * feat(store): roles slice + openFormTab table/role dedup (Task 3.2) * feat(ui): TableForm (create/edit-diff/rebuild) + cross-schema FK with ON DELETE/UPDATE (Task 4.1) * feat(ui): RoleForm + RoleDetail + RoleGrantsEditor (Task 4.2) * feat(ui): wire roles into object registry + explorer (Task 4.3) * feat(ui): Edit Table + maintenance menu + Create Table toolbar + rebuild badge (Task 4.4) * chore: bump version to 0.7.7 (Task 5.1) * docs: sync README/ROADMAP/AGENTS to v0.7.7 (Task 5.2) * test: v0.7.7 manual smoke checklist (Task 5.3) * chore: sync Cargo.lock to v0.7.7 * style(ui): match TableForm visual layout to shared object-form rows (remove stray padding) * feat(ui): table creator grid redesign + per-column FK slide-in panel with relationship controls * style(ui): table-creator columns grid — bordered cells like the data table view * feat(ui): table creator — type dropdown, PK/Nullable rules, drag-to-reorder columns (vertical), tidy add-column row * feat(ui): table creator — single-PK exclusivity, name on top, fixed column widths + contained h-scroll, no-wrap constraints * style(ui): table creator — constraints column sized to content (no clip), overscroll containment * style(ui): table creator — overscrollBehavior none on both scroll containers, add-column row py-2 * feat(ui): table creator — centered add-column button, schema dropdown auto-selected to current schema * style(ui): table creator — columns grid fills container width, scrolls only when too narrow * feat(ui): table creator — shorthand PG types in dropdown, Auto-Increment only for int-family * feat(ui): FK panel — animate in/out from the right, flush create-table-style rows * fix(ui): FK panel — smooth slide (willChange + eased), local column options from the form grid * feat(ui): FK composer (multi-column, type preview, gated sections) + composite PK + constraints cog in actions col + auto-named FKs * fix(ui): FK panel blocks unnamed table with hint; Foreign keys section lists staged + DB FKs * style(ui): FK panel — transparent full-width selects, title↔select borders, 2-col column picker * style(ui): FK panel — vertical divider between local/ref columns * style(ui): FK panel — section title padding py-2 * style(ui): FK panel — remove row margins + select/text padding in column picker * style(ui): FK panel — flush column picker (no section padding), border-r + py-1 cells * style(ui): FK panel — cell padding + row borders on column picker (user polish) * feat: FK inline into CREATE TABLE (single staged change in create mode); touch up class * style(ui): hide FK icon on PK columns; fix grid header/row column alignment * feat(ui): SQL preview in table creator uses read-only Monaco editor * feat(ui): Foreign keys section — relation rows with icon + Edit/Remove actions * style(ui): FK relation row — label, icon, table on one line * style(ui): FormRow selects transparent like FK panel; test matches FK relation label * style(ui): form-row focus outline amber-400/20 (subtle) * style(ui): SQL view — remove px-4 py-3 wrapper padding * feat(ui): Create Table button label; auto tree refresh after schema-modifying commits; table name on staged ddl card * fix(roles): cast rolconnlimit::int8 to fix panic deserializing int4 into i64 * fix(privileges): cast privilege_type::text to avoid domain-array panic; aclexplode for sequences/schemas (role_sequence_grants removed in PG15, schema_privileges never existed) * feat(ui): role privileges sections collapsible (collapsed by default) with 5-entry preview + fade + Show more * feat(ui): role privileges — collapsed state shows first 5 with fade; expand reveals all * chore(test): drop unused fireEvent import in RoleDetail test * style(ui): hide chevron when section has 5 or fewer entries * docs: README/ROADMAP/AGENTS — v0.7.7 shipped details (roles/grants, table editor, FK composer, privilege explorer); ROADMAP adds v0.7.8 Next up (quick wins) * docs(readme): collapse Recent changes and Key features into details blocks * docs(readme): move hero caption under image; align download + comparison table columns * docs(readme): collapse only body content — keep Recent Changes / Key Features headings visible * docs(readme): per-subsection collapsibles with meaningful summary labels
This commit is contained in:
@@ -144,6 +144,23 @@ describe("ChangesQueuePanel", () => {
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("refreshes the schema tree after committing a schema-modifying ddl change", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
|
||||
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
|
||||
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
schema: "public",
|
||||
table: "products",
|
||||
sql: 'CREATE TABLE "public"."products" ("id" integer NOT NULL)',
|
||||
description: "Create Table",
|
||||
});
|
||||
render(<ChangesQueuePanel />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1"));
|
||||
});
|
||||
|
||||
it("SQL toggle shows the generated SQL", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
@@ -222,4 +239,18 @@ describe("ChangesQueuePanel", () => {
|
||||
expect(tabs.some((t) => t.table === "posts")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a rebuild_table change with an amber REBUILD badge and SQL preview", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "rebuild_table",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
sql: "BEGIN; ALTER TABLE \"public\".\"users\" ...; COMMIT;",
|
||||
description: "Rebuild public.users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
expect(screen.getByText("REBUILD")).toBeInTheDocument();
|
||||
expect(screen.getByText(/rebuild public\.users/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/BEGIN;/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { buildChangePayload, buildChangeSql } from "../../lib/changePayload";
|
||||
import { isSchemaModifyingQuery } from "../../lib/utils";
|
||||
import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore";
|
||||
|
||||
const statusBg: Record<QueueStatus, string> = {
|
||||
@@ -25,6 +26,8 @@ function formatChangeLabel(change: QueueItem): string {
|
||||
return `Empty Table: ${fullName}`;
|
||||
case "drop_table":
|
||||
return `Drop Table: ${fullName}`;
|
||||
case "rebuild_table":
|
||||
return change.description ?? `Rebuild ${fullName}`;
|
||||
case "ddl":
|
||||
return change.description ?? "DDL";
|
||||
default:
|
||||
@@ -52,9 +55,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. */
|
||||
/** Badge label for a queue-item type — ddl/rebuild render uppercase. */
|
||||
function badgeLabel(type: string): string {
|
||||
return type === "ddl" ? "DDL" : capitalizeType(type);
|
||||
if (type === "ddl") return "DDL";
|
||||
if (type === "rebuild_table") return "REBUILD";
|
||||
return capitalizeType(type);
|
||||
}
|
||||
|
||||
function tableRef(change: QueueItem): string {
|
||||
@@ -97,6 +102,11 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
treeDirty = true;
|
||||
const st = useDbViewerStore.getState();
|
||||
st.closeTabsForTable(change.schema ?? "", change.table ?? "");
|
||||
} else if (
|
||||
change.type === "rebuild_table" ||
|
||||
(change.type === "ddl" && change.sql && isSchemaModifyingQuery(change.sql))
|
||||
) {
|
||||
treeDirty = true;
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -177,7 +187,14 @@ 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">
|
||||
<span
|
||||
className={[
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
change.type === "rebuild_table"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "bg-surface-raised text-text-muted",
|
||||
].join(" ")}
|
||||
>
|
||||
{badgeLabel(change.type)}
|
||||
</span>
|
||||
<span className="text-sm text-text truncate">
|
||||
@@ -204,19 +221,32 @@ export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void }
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
{change.type === "rebuild_table" ? (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md bg-canvas border border-border p-2 text-xs text-text-muted font-mono">
|
||||
{buildChangeSql(change)}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -1439,6 +1439,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
setCurrentSchema={setCurrentSchema}
|
||||
onEdit={() => setEditModalOpen(true)}
|
||||
connectionId={connectionId}
|
||||
dbType={currentConnection?.db_type}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
@@ -1446,7 +1447,7 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
<TableTree searchQuery={searchQuery} />
|
||||
<TableTree searchQuery={searchQuery} dbType={currentConnection?.db_type} />
|
||||
</div>
|
||||
</div>
|
||||
{/* panel resize handle */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DbViewerToolbar } from "./DbViewerToolbar";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
@@ -84,6 +84,17 @@ describe("DbViewerToolbar", () => {
|
||||
expect(screen.getByLabelText(/create table/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a create table form tab when Create Table is clicked", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DbViewerToolbar {...defaultProps} currentSchema="public" />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText(/create table/i));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "create" }));
|
||||
});
|
||||
|
||||
it("shows a disabled schema loading indicator while schema tree is loading", () => {
|
||||
useDbViewerStore.setState({ schemaTreeLoading: true });
|
||||
render(
|
||||
|
||||
@@ -13,6 +13,8 @@ import { Tooltip } from "../ui/Tooltip";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { SchemaMenu } from "./SchemaMenu";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import type { DbType } from "../../lib/types";
|
||||
|
||||
export function DbViewerToolbar({
|
||||
databases,
|
||||
@@ -23,6 +25,7 @@ export function DbViewerToolbar({
|
||||
setCurrentSchema,
|
||||
onEdit,
|
||||
connectionId,
|
||||
dbType,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
}: {
|
||||
@@ -34,6 +37,7 @@ export function DbViewerToolbar({
|
||||
setCurrentSchema: (schema: string | null) => void;
|
||||
onEdit?: () => void;
|
||||
connectionId?: string;
|
||||
dbType?: DbType;
|
||||
searchQuery: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
}) {
|
||||
@@ -78,6 +82,21 @@ export function DbViewerToolbar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCreateTable = useCallback(() => {
|
||||
const schema = currentSchema ?? "public";
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: "",
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
params: { schema, name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
}, [currentSchema]);
|
||||
|
||||
const showCreateTable = getCapabilities(dbType ?? "postgresql").tableManagement;
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (!connectionId || refreshing) return;
|
||||
setRefreshing(true);
|
||||
@@ -147,14 +166,17 @@ export function DbViewerToolbar({
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer opacity-50"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showCreateTable && (
|
||||
<Tooltip content="Create Table" side="bottom">
|
||||
<button
|
||||
aria-label="Create Table"
|
||||
onClick={handleCreateTable}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-text-muted hover:text-text hover:bg-surface-raised cursor-pointer"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="Search Tables" side="bottom">
|
||||
<button
|
||||
aria-label="Search Tables"
|
||||
|
||||
@@ -351,6 +351,39 @@ describe("ObjectExplorerPage", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roles fetch ignores the schema filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRoles = vi.spyOn(commands, "getRoles").mockResolvedValue([
|
||||
{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Roles"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("app")).toBeInTheDocument(),
|
||||
);
|
||||
// Roles are cluster-scoped: no schema argument is ever passed, even when
|
||||
// the schema changes (no schema-change refetch for roles).
|
||||
expect(getRoles).toHaveBeenCalledTimes(1);
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
expect(getRoles).not.toHaveBeenCalledWith("c1", "public");
|
||||
useDbViewerStore.setState({ currentSchema: "analytics" });
|
||||
await waitFor(() => expect(getRoles).toHaveBeenCalledTimes(1));
|
||||
expect(getRoles).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("preselects type from store on mount", () => {
|
||||
useDbViewerStore.setState({ selectedObjectType: "sequences" });
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
|
||||
@@ -59,7 +59,8 @@ function itemLabel(item: AnyObject): string {
|
||||
}
|
||||
|
||||
function schemaOf(item: AnyObject): string {
|
||||
return item.schema;
|
||||
// Roles are cluster-scoped and carry no schema — default to "".
|
||||
return (item as { schema?: string }).schema ?? "";
|
||||
}
|
||||
|
||||
function objectName(item: AnyObject): string {
|
||||
@@ -76,6 +77,7 @@ function typeToDdlType(type: ObjectType): string {
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
roles: "role",
|
||||
};
|
||||
return map[type];
|
||||
}
|
||||
@@ -149,7 +151,10 @@ export function ObjectExplorerPage({
|
||||
setError(null);
|
||||
try {
|
||||
let result: AnyObject[];
|
||||
if (type === "extensions") {
|
||||
if (type === "roles") {
|
||||
// Roles are cluster-scoped — no schema filter.
|
||||
result = await cmd.getRoles(connectionId);
|
||||
} else if (type === "extensions") {
|
||||
result = await cmd.getExtensions(connectionId);
|
||||
} else if (type === "functions") {
|
||||
result = (await cmd.getFunctions(
|
||||
@@ -201,8 +206,14 @@ export function ObjectExplorerPage({
|
||||
}, [type, connectionId, currentSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only re-fetch if schema actually changed (or first load)
|
||||
if (lastSchemaRef.current !== (currentSchema ?? undefined)) {
|
||||
// Only re-fetch if schema actually changed (or first load). Roles are
|
||||
// cluster-scoped — skip the refetch when the schema changes (the mount
|
||||
// fetch still runs via lastSchemaRef === undefined).
|
||||
const schema = currentSchema ?? undefined;
|
||||
const schemaChanged = lastSchemaRef.current !== schema;
|
||||
const skipForRoles =
|
||||
schemaChanged && type === "roles" && lastSchemaRef.current !== undefined;
|
||||
if (schemaChanged && !skipForRoles) {
|
||||
fetch();
|
||||
}
|
||||
}, [currentSchema, fetch]);
|
||||
@@ -488,7 +499,7 @@ export function ObjectExplorerPage({
|
||||
sidebarMode
|
||||
? openObjectTab(
|
||||
type,
|
||||
item.schema,
|
||||
schemaOf(item),
|
||||
item.name,
|
||||
item,
|
||||
)
|
||||
@@ -517,7 +528,10 @@ export function ObjectExplorerPage({
|
||||
objectType={
|
||||
typeToDdlType(type) as ObjectKind
|
||||
}
|
||||
item={item}
|
||||
item={{
|
||||
...item,
|
||||
schema: schemaOf(item),
|
||||
}}
|
||||
onRefresh={fetch}
|
||||
open={openKey === key}
|
||||
onOpenChange={(o) =>
|
||||
|
||||
@@ -117,4 +117,22 @@ describe("TableOverflowMenu", () => {
|
||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith([[1]], columns, "csv", "public.t"));
|
||||
});
|
||||
|
||||
it("maintenance items are gated by capability and run via confirm", async () => {
|
||||
vi.spyOn(commands, "runMaintenance").mockResolvedValue({ duration_ms: 3, message: "VACUUM completed" });
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("VACUUM"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /vacuum/i }));
|
||||
await screen.findByText(/completed/i);
|
||||
expect(commands.runMaintenance).toHaveBeenCalledWith("c1", "public", "users", "vacuum");
|
||||
});
|
||||
|
||||
it("Edit Table opens a table form tab", () => {
|
||||
const openFormTab = vi.spyOn(useDbViewerStore.getState(), "openFormTab");
|
||||
render(<TableOverflowMenu schema="public" table="users" onOpenTab={() => ""} connectionId="c1" columns={[{ name: "id", data_type: "int", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }]} />);
|
||||
fireEvent.click(screen.getByLabelText("Table options"));
|
||||
fireEvent.click(screen.getByText("Edit Table…"));
|
||||
expect(openFormTab).toHaveBeenCalledWith(expect.objectContaining({ kind: "table", mode: "edit" }));
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import { MoreVertical, RefreshCw } from "lucide-react";
|
||||
import { ConfirmDialog } from "../ui/ConfirmDialog";
|
||||
import { AnimatedModal } from "../ui/AnimatedModal";
|
||||
import { Button } from "../ui/Button";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { useNotificationStore } from "../../stores/notificationStore";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import { getCapabilities } from "../../lib/dbCapabilities";
|
||||
import { DependencyDialog } from "./DependencyDialog";
|
||||
import { initialCrudParams } from "../../lib/objectCrud";
|
||||
import type { ColumnInfo, DependencyInfo } from "../../lib/types";
|
||||
import type { ColumnInfo, DependencyInfo, MaintenanceResult } from "../../lib/types";
|
||||
|
||||
interface TableOverflowMenuProps {
|
||||
schema: string;
|
||||
@@ -17,12 +21,26 @@ interface TableOverflowMenuProps {
|
||||
connectionId?: string;
|
||||
columns?: ColumnInfo[];
|
||||
rows?: unknown[][];
|
||||
dbType?: string;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
danger?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
type MaintenanceAction = "vacuum" | "analyze" | "reindex";
|
||||
|
||||
const maintenanceLockCopy: Record<MaintenanceAction, string> = {
|
||||
vacuum: "VACUUM blocks concurrent DDL only on this table.",
|
||||
analyze: "ANALYZE blocks concurrent DDL only on this table.",
|
||||
reindex: "REINDEX takes an ACCESS EXCLUSIVE lock — blocks reads and writes on this table until complete.",
|
||||
};
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
export function TableOverflowMenu({
|
||||
@@ -32,15 +50,23 @@ export function TableOverflowMenu({
|
||||
connectionId: connectionIdProp,
|
||||
columns,
|
||||
rows,
|
||||
dbType,
|
||||
}: TableOverflowMenuProps) {
|
||||
const storeConnectionId = useUiStore((s) => s.activeConnectionId);
|
||||
const connectionId = connectionIdProp ?? storeConnectionId;
|
||||
const addChange = useDbViewerStore((s) => s.addChange);
|
||||
const notify = useNotificationStore((s) => s.notify);
|
||||
const caps = getCapabilities(dbType ?? "postgresql");
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<"empty" | "delete" | null>(null);
|
||||
const [dropDeps, setDropDeps] = useState<DependencyInfo[]>([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [maintenance, setMaintenance] = useState<{ action: MaintenanceAction; lockCopy: string } | null>(null);
|
||||
const [maintenanceRunning, setMaintenanceRunning] = useState(false);
|
||||
const [maintenanceResult, setMaintenanceResult] = useState<
|
||||
{ type: "success" | "error"; message: string; duration_ms: number } | null
|
||||
>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,6 +89,28 @@ export function TableOverflowMenu({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleMaintenanceConfirm = async () => {
|
||||
if (!connectionId || !maintenance) return;
|
||||
setMaintenanceRunning(true);
|
||||
setMaintenanceResult(null);
|
||||
try {
|
||||
const result: MaintenanceResult = await cmd.runMaintenance(
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
maintenance.action,
|
||||
);
|
||||
setMaintenanceResult({ type: "success", message: result.message, duration_ms: result.duration_ms });
|
||||
notify(`${result.message} · ${result.duration_ms}ms`, "success");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setMaintenanceResult({ type: "error", message, duration_ms: 0 });
|
||||
notify(message, "error");
|
||||
} finally {
|
||||
setMaintenanceRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (id: string) => {
|
||||
switch (id) {
|
||||
case "open":
|
||||
@@ -97,6 +145,34 @@ export function TableOverflowMenu({
|
||||
setImportOpen(true);
|
||||
setOpen(false);
|
||||
break;
|
||||
case "edit_table": {
|
||||
const columnMeta = (columns ?? []).map((c) => ({
|
||||
name: c.name,
|
||||
type: c.data_type,
|
||||
nullable: c.is_nullable,
|
||||
default: c.default_value,
|
||||
is_pk: c.is_pk,
|
||||
}));
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table",
|
||||
schema,
|
||||
name: table,
|
||||
title: "Edit Table",
|
||||
description: `Edit ${schema}.${table}`,
|
||||
mode: "edit",
|
||||
params: {
|
||||
schema,
|
||||
name: table,
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: columnMeta,
|
||||
old_columns: columnMeta,
|
||||
},
|
||||
},
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
}
|
||||
case "create_index":
|
||||
if (!connectionId) break;
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
@@ -123,6 +199,12 @@ export function TableOverflowMenu({
|
||||
});
|
||||
setOpen(false);
|
||||
break;
|
||||
case "vacuum":
|
||||
case "analyze":
|
||||
case "reindex":
|
||||
setMaintenance({ action: id, lockCopy: maintenanceLockCopy[id] });
|
||||
setOpen(false);
|
||||
break;
|
||||
case "empty":
|
||||
setConfirmAction("empty");
|
||||
setOpen(false);
|
||||
@@ -154,6 +236,15 @@ export function TableOverflowMenu({
|
||||
{ id: "import", label: "Import data (CSV/JSON)" },
|
||||
{ id: "create_index", label: "Create Index…" },
|
||||
{ id: "create_constraint", label: "Create Constraint…" },
|
||||
...(caps.tableManagement ? [{ id: "edit_table", label: "Edit Table…" }] : []),
|
||||
...(caps.maintenance
|
||||
? [
|
||||
{ id: "maintenance-divider", divider: true },
|
||||
{ id: "vacuum", label: "VACUUM" },
|
||||
{ id: "analyze", label: "ANALYZE" },
|
||||
{ id: "reindex", label: "REINDEX", danger: true },
|
||||
]
|
||||
: []),
|
||||
{ id: "empty", label: "Empty Table", danger: true },
|
||||
{ id: "delete", label: "Delete Table", danger: true },
|
||||
];
|
||||
@@ -169,22 +260,69 @@ export function TableOverflowMenu({
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 rounded-xl bg-surface border border-border py-1 z-20 min-w-[180px] shadow-lg">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{items.map((item) =>
|
||||
item.divider ? (
|
||||
<div key={item.id} className="border-t border-border my-1" />
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(item.id)}
|
||||
className={[
|
||||
"flex items-center justify-between px-3 py-2 text-sm w-full text-left transition-colors cursor-pointer",
|
||||
item.danger ? "text-red-400 hover:bg-red-500/10 hover:text-red-300" : "text-text-muted hover:text-text hover:bg-surface-raised",
|
||||
].join(" ")}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maintenance && (
|
||||
<AnimatedModal open onClose={() => setMaintenance(null)}>
|
||||
<div className="w-80">
|
||||
<h3 className="font-heading text-text text-lg mb-3">
|
||||
{maintenanceResult
|
||||
? maintenanceResult.type === "success"
|
||||
? "Maintenance Complete"
|
||||
: "Maintenance Failed"
|
||||
: `${capitalize(maintenance.action)}: ${schema}.${table}`}
|
||||
</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
{maintenanceResult
|
||||
? `${maintenanceResult.message} · ${maintenanceResult.duration_ms}ms`
|
||||
: maintenance.lockCopy}
|
||||
</p>
|
||||
{maintenanceRunning && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted mb-4">
|
||||
<RefreshCw size={14} className="animate-spin" />
|
||||
<span>Running {maintenance.action}…</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setMaintenance(null)}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceResult ? "Close" : "Cancel"}
|
||||
</Button>
|
||||
{!maintenanceResult && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleMaintenanceConfirm}
|
||||
disabled={maintenanceRunning}
|
||||
>
|
||||
{maintenanceRunning ? "Running…" : capitalize(maintenance.action)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedModal>
|
||||
)}
|
||||
|
||||
{confirmAction === "delete" && dropDeps.length > 0 && (
|
||||
<DependencyDialog
|
||||
open
|
||||
|
||||
@@ -7,7 +7,7 @@ import { abbreviateType } from "../../lib/utils";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import * as cmd from "../../lib/commands";
|
||||
|
||||
export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
export function TableTree({ searchQuery, dbType }: { searchQuery?: string; dbType?: string }) {
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const currentSchema = useDbViewerStore((s) => s.currentSchema);
|
||||
const schemaTreeLoading = useDbViewerStore((s) => s.schemaTreeLoading);
|
||||
@@ -113,6 +113,7 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
schema={table.schema}
|
||||
table={table.name}
|
||||
connectionId={connectionId ?? undefined}
|
||||
dbType={dbType}
|
||||
columns={cols}
|
||||
onOpenTab={handleOpenTab}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
vi.mock("../../../lib/commands", () => ({ getSchemaGraph: vi.fn() }));
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
@@ -44,12 +50,8 @@ const graph = {
|
||||
};
|
||||
|
||||
describe("ConstraintForm", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockReset();
|
||||
});
|
||||
|
||||
it("check: emits the expression", () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
@@ -74,7 +76,7 @@ describe("ConstraintForm", () => {
|
||||
});
|
||||
|
||||
it("foreign_key: picks referenced table + column", async () => {
|
||||
vi.mocked(cmd.getSchemaGraph).mockResolvedValue(graph as any);
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue(graph as any);
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<ConstraintForm
|
||||
@@ -97,8 +99,8 @@ describe("ConstraintForm", () => {
|
||||
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" } });
|
||||
const refTable = await screen.findByLabelText("Referenced table");
|
||||
fireEvent.change(refTable, { target: { value: "public.users" } });
|
||||
expect(onChange).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({
|
||||
@@ -108,4 +110,59 @@ describe("ConstraintForm", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("FK form includes ON DELETE/UPDATE + deferrable and cross-schema ref picker", async () => {
|
||||
// mock getSchemaGraph to return tables across two schemas
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
table_type: "TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: false, is_nullable: false, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
} as any);
|
||||
render(
|
||||
<ConstraintForm
|
||||
connectionId="c1"
|
||||
schemas={["public", "auth"]}
|
||||
params={{
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "fk1",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: ["org_id"],
|
||||
ref_schema: "auth",
|
||||
ref_table: "orgs",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
// referenced-table picker includes the auth.orgs option
|
||||
const ref = screen.getByLabelText("Referenced table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...ref.options].map((o) => o.value)).toContain("auth.orgs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,12 +13,23 @@ interface Props {
|
||||
}
|
||||
|
||||
const KINDS = ["check", "unique", "primary_key", "foreign_key"];
|
||||
const FK_ACTIONS = ["NO ACTION", "RESTRICT", "CASCADE", "SET NULL", "SET DEFAULT"];
|
||||
|
||||
function patchAction(params: DdlParams, patch: Record<string, unknown>): DdlParams {
|
||||
const action = (params.action ?? {}) as Record<string, unknown>;
|
||||
return { ...params, action: { ...action, ...patch } };
|
||||
}
|
||||
|
||||
function refKey(schema: string, table: string): string {
|
||||
return `${schema}.${table}`;
|
||||
}
|
||||
|
||||
function parseRefKey(key: string): { schema: string; table: string } {
|
||||
const parts = key.split(".");
|
||||
if (parts.length >= 2) return { schema: parts[0] ?? "", table: parts.slice(1).join(".") };
|
||||
return { schema: "", table: key };
|
||||
}
|
||||
|
||||
export function ConstraintForm({ connectionId, params, schemas, onChange }: Props) {
|
||||
const p = params as Record<string, unknown>;
|
||||
const action = (p.action ?? {}) as Record<string, unknown>;
|
||||
@@ -28,10 +39,10 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
|
||||
useEffect(() => {
|
||||
cmd
|
||||
.getSchemaGraph(connectionId, (p.schema as string) || undefined)
|
||||
.getSchemaGraph(connectionId, undefined)
|
||||
.then((g: SchemaGraph) => setGraph(g))
|
||||
.catch(() => setGraph({ tables: [], relationships: [] }));
|
||||
}, [connectionId, p.schema]);
|
||||
}, [connectionId]);
|
||||
|
||||
const setAction = (patch: Record<string, unknown>) => onChange(patchAction(params, patch));
|
||||
|
||||
@@ -147,20 +158,43 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="Referenced table">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Referenced table"
|
||||
value={(action.ref_table as string) ?? ""}
|
||||
<select
|
||||
aria-label="Referenced table"
|
||||
value={refKey(
|
||||
(action.ref_schema as string) ?? "",
|
||||
(action.ref_table as string) ?? "",
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const t = graph.tables.find((t) => t.name === e.target.value);
|
||||
const { schema, table } = parseRefKey(e.target.value);
|
||||
const t = graph.tables.find(
|
||||
(tbl) => tbl.name === table && tbl.schema === schema,
|
||||
);
|
||||
setAction({
|
||||
ref_table: e.target.value,
|
||||
ref_schema: t?.schema ?? "",
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: [],
|
||||
});
|
||||
if (!t) return;
|
||||
const pkCols = t.columns.filter((c) => c.is_pk).map((c) => c.name);
|
||||
if (pkCols.length > 0 && selected.length > 0) {
|
||||
setAction({
|
||||
ref_table: table,
|
||||
ref_schema: schema,
|
||||
ref_columns: pkCols.slice(0, selected.length),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={inputClass}
|
||||
/>
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a table
|
||||
</option>
|
||||
{graph.tables.map((t) => (
|
||||
<option key={refKey(t.schema, t.name)} value={refKey(t.schema, t.name)}>
|
||||
{t.schema}.{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Referenced columns" className="items-stretch">
|
||||
<div className="min-w-0 flex-1 flex flex-col gap-1 px-4 py-2">
|
||||
@@ -171,6 +205,65 @@ export function ConstraintForm({ connectionId, params, schemas, onChange }: Prop
|
||||
/>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label="On delete">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={(action.on_delete as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_delete: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="On update">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={(action.on_update as string) ?? "NO ACTION"}
|
||||
onChange={(e) => setAction({ on_update: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
<FormRow label="Deferrable">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="DEFERRABLE"
|
||||
checked={!!action.deferrable}
|
||||
onChange={(e) =>
|
||||
setAction({
|
||||
deferrable: e.target.checked,
|
||||
initially_deferred: e.target.checked ? action.initially_deferred ?? false : false,
|
||||
})
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>DEFERRABLE</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
{!!action.deferrable && (
|
||||
<FormRow label="Initially deferred">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="INITIALLY DEFERRED"
|
||||
checked={!!action.initially_deferred}
|
||||
onChange={(e) => setAction({ initially_deferred: e.target.checked })}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>INITIALLY DEFERRED</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { FkPanel } from "./FkPanel";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
const graph = {
|
||||
tables: [
|
||||
{
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "user_id", data_type: "int", is_pk: false, is_fk: true, is_unique: false, is_nullable: true, fk_ref: ["public", "users", "id"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "email", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "orgs",
|
||||
schema: "auth",
|
||||
table_type: "BASE TABLE",
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null },
|
||||
{ name: "name", data_type: "text", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue(graph as any);
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockReset().mockResolvedValue(["ALTER TABLE ... ADD CONSTRAINT ..."]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderPanel(props: Partial<React.ComponentProps<typeof FkPanel>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onStaged = vi.fn();
|
||||
const utils = render(
|
||||
<FkPanel
|
||||
connectionId="c1"
|
||||
schema="public"
|
||||
table="orders"
|
||||
column="user_id"
|
||||
mode="edit"
|
||||
localColumns={[
|
||||
{ name: "user_id", data_type: "int" },
|
||||
{ name: "amount", data_type: "numeric" },
|
||||
{ name: "created_at", data_type: "timestamp" },
|
||||
]}
|
||||
onClose={onClose}
|
||||
onStaged={onStaged}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...utils, onClose, onStaged };
|
||||
}
|
||||
|
||||
/** The columns/actions sections only render once a referenced table is chosen. */
|
||||
async function waitForTable() {
|
||||
await screen.findByLabelText("Table");
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Local column 1")).not.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
describe("FkPanel", () => {
|
||||
it("renders cascading dropdowns with schemas, tables, and PK-first columns", async () => {
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
|
||||
// Schema select contains public and auth
|
||||
const schema = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...schema.options].map((o) => o.value)).toContain("public");
|
||||
expect([...schema.options].map((o) => o.value)).toContain("auth");
|
||||
});
|
||||
|
||||
// Table select contains public tables by default
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("users");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("orgs");
|
||||
});
|
||||
|
||||
// Once a table is referenced, the local column pair appears, preselected
|
||||
await waitForTable();
|
||||
const local = screen.getByLabelText("Local column 1") as HTMLSelectElement;
|
||||
expect(local.value).toBe("user_id");
|
||||
expect([...local.options].map((o) => o.textContent)).toContain("user_id (int)");
|
||||
|
||||
// Referenced column select lists PKs first and marks them
|
||||
const refCol = screen.getByLabelText("Referenced column 1") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const opts = [...refCol.options].map((o) => o.textContent);
|
||||
expect(opts[0]).toMatch(/id/);
|
||||
});
|
||||
});
|
||||
|
||||
it("updates table and column lists when schema changes", async () => {
|
||||
renderPanel();
|
||||
const schema = await screen.findByLabelText("Schema");
|
||||
fireEvent.change(schema, { target: { value: "auth" } });
|
||||
|
||||
const table = screen.getByLabelText("Table") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...table.options].map((o) => o.value)).toContain("orgs");
|
||||
expect([...table.options].map((o) => o.value)).not.toContain("users");
|
||||
});
|
||||
});
|
||||
|
||||
it("hides the column-pairs and action sections until a referenced table is chosen", async () => {
|
||||
let resolveGraph: (g: unknown) => void = () => {};
|
||||
(cmd.getSchemaGraph as any).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveGraph = resolve;
|
||||
}),
|
||||
);
|
||||
renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
// while the graph is still loading, the rest is hidden
|
||||
expect(screen.queryByLabelText("Local column 1")).toBeNull();
|
||||
// resolve the graph → table auto-selects → sections appear
|
||||
resolveGraph(graph);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Local column 1")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("On update")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("On delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports adding multiple column pairs", async () => {
|
||||
renderPanel();
|
||||
await waitForTable();
|
||||
fireEvent.click(screen.getByLabelText("Add another column"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(2);
|
||||
expect(screen.getAllByLabelText(/Referenced column/)).toHaveLength(2);
|
||||
// removing a pair works
|
||||
fireEvent.click(screen.getByLabelText("Remove pair 2"));
|
||||
expect(screen.getAllByLabelText(/Local column/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("stages a foreign key DDL change and closes the panel", async () => {
|
||||
const { onStaged } = renderPanel();
|
||||
await screen.findByText("Foreign key");
|
||||
|
||||
await waitForTable();
|
||||
fireEvent.change(screen.getByLabelText("On delete"), { target: { value: "CASCADE" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(objectCrud.buildObjectDdl).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"constraint",
|
||||
expect.objectContaining({
|
||||
schema: "public",
|
||||
table: "orders",
|
||||
name: "",
|
||||
action: expect.objectContaining({
|
||||
op: "foreign_key",
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "CASCADE",
|
||||
on_update: "NO ACTION",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("ALTER TABLE ... ADD CONSTRAINT ...");
|
||||
// hands back the local column + the referenced column's type (for auto-matching)
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("create mode returns the FK inline instead of staging a separate ALTER", async () => {
|
||||
const { onStaged } = renderPanel({ mode: "create" });
|
||||
await screen.findByText("Foreign key");
|
||||
await waitForTable();
|
||||
const refCol = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refCol.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add FK" }));
|
||||
|
||||
// no separate ALTER change is staged
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
expect(objectCrud.buildObjectDdl).not.toHaveBeenCalled();
|
||||
|
||||
// the FK definition is handed back for inlining into the CREATE TABLE
|
||||
expect(onStaged).toHaveBeenCalledWith({
|
||||
pairs: [{ localCol: "user_id", refType: "int" }],
|
||||
fk: expect.objectContaining({
|
||||
columns: ["user_id"],
|
||||
ref_schema: "public",
|
||||
ref_table: "orders",
|
||||
ref_columns: ["id"],
|
||||
on_delete: "NO ACTION",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { X, Link, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import { buildObjectDdl } from "../../../lib/objectCrud";
|
||||
import type { SchemaGraph } from "../../../lib/types";
|
||||
|
||||
// Panel selects: transparent, fill the available width (no surface bg like the grid).
|
||||
const panelSelect =
|
||||
"w-full bg-transparent font-heading text-xs text-text outline-none placeholder:text-text-muted cursor-pointer";
|
||||
|
||||
export interface FkColumn {
|
||||
name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export interface FkStagedPair {
|
||||
localCol: string;
|
||||
refType: string;
|
||||
}
|
||||
|
||||
export interface FkDefinition {
|
||||
columns: string[];
|
||||
ref_schema: string;
|
||||
ref_table: string;
|
||||
ref_columns: string[];
|
||||
on_delete?: string;
|
||||
on_update?: string;
|
||||
deferrable?: boolean;
|
||||
initially_deferred?: boolean;
|
||||
}
|
||||
|
||||
interface FkPair {
|
||||
localCol: string;
|
||||
refCol: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
column: string | null;
|
||||
/** Columns of the table being edited (from the form grid — works even when the table isn't created yet). */
|
||||
localColumns: FkColumn[];
|
||||
mode: "create" | "edit";
|
||||
onClose: () => void;
|
||||
/** Called after staging: pairs (for type auto-match); in create mode the FK definition is returned
|
||||
* so the form can inline it into the CREATE TABLE instead of a separate ALTER change. */
|
||||
onStaged?: (result: { pairs: FkStagedPair[]; fk?: FkDefinition }) => void;
|
||||
}
|
||||
|
||||
const FK_ACTIONS = [
|
||||
"NO ACTION",
|
||||
"RESTRICT",
|
||||
"CASCADE",
|
||||
"SET NULL",
|
||||
"SET DEFAULT",
|
||||
];
|
||||
|
||||
function Section({
|
||||
label,
|
||||
children,
|
||||
flush = false,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="border-b border-border px-4 py-2 text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
<div className={flush ? "" : "px-2 py-2"}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FkPanel({
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
column,
|
||||
localColumns,
|
||||
mode,
|
||||
onClose,
|
||||
onStaged,
|
||||
}: Props) {
|
||||
const [graph, setGraph] = useState<SchemaGraph>({
|
||||
tables: [],
|
||||
relationships: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
|
||||
const [pairs, setPairs] = useState<FkPair[]>(() =>
|
||||
column
|
||||
? [{ localCol: column, refCol: "" }]
|
||||
: [{ localCol: "", refCol: "" }],
|
||||
);
|
||||
const [refSchema, setRefSchema] = useState<string>(schema);
|
||||
const [refTable, setRefTable] = useState<string>("");
|
||||
const [onDelete, setOnDelete] = useState<string>("NO ACTION");
|
||||
const [onUpdate, setOnUpdate] = useState<string>("NO ACTION");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
cmd.getSchemaGraph(connectionId, undefined)
|
||||
.then((g) => {
|
||||
if (!active) return;
|
||||
setGraph(g);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!active) return;
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
const localNames = useMemo(
|
||||
() => localColumns.map((c) => c.name),
|
||||
[localColumns],
|
||||
);
|
||||
|
||||
const schemas = useMemo(
|
||||
() => Array.from(new Set(graph.tables.map((t) => t.schema))).sort(),
|
||||
[graph],
|
||||
);
|
||||
|
||||
const tablesInSchema = useMemo(
|
||||
() =>
|
||||
graph.tables
|
||||
.filter((t) => t.schema === refSchema)
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[graph, refSchema],
|
||||
);
|
||||
|
||||
const refTableInfo = useMemo(
|
||||
() => tablesInSchema.find((t) => t.name === refTable),
|
||||
[tablesInSchema, refTable],
|
||||
);
|
||||
|
||||
const refColumns = useMemo(() => {
|
||||
if (!refTableInfo) return [];
|
||||
return [...refTableInfo.columns].sort((a, b) => {
|
||||
if (a.is_pk && !b.is_pk) return -1;
|
||||
if (!a.is_pk && b.is_pk) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [refTableInfo]);
|
||||
|
||||
// Defaults: keep selections valid as the graph loads / inputs change.
|
||||
useEffect(() => {
|
||||
if (schemas.length > 0 && !schemas.includes(refSchema)) {
|
||||
setRefSchema(schemas[0] ?? "");
|
||||
}
|
||||
}, [schemas, refSchema]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
tablesInSchema.length > 0 &&
|
||||
!tablesInSchema.some((t) => t.name === refTable)
|
||||
) {
|
||||
setRefTable(tablesInSchema[0]?.name ?? "");
|
||||
} else if (tablesInSchema.length === 0) {
|
||||
setRefTable("");
|
||||
}
|
||||
}, [tablesInSchema, refTable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localNames.length === 0) return;
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
localNames.includes(p.localCol)
|
||||
? p
|
||||
: { ...p, localCol: localNames[0] ?? "" },
|
||||
),
|
||||
);
|
||||
}, [localNames]);
|
||||
|
||||
useEffect(() => {
|
||||
setPairs((prev) =>
|
||||
prev.map((p) =>
|
||||
p.refCol && refColumns.some((c) => c.name === p.refCol)
|
||||
? p
|
||||
: { ...p, refCol: refColumns[0]?.name ?? "" },
|
||||
),
|
||||
);
|
||||
}, [refColumns]);
|
||||
|
||||
const setPair = (i: number, key: keyof FkPair, value: string) =>
|
||||
setPairs((prev) =>
|
||||
prev.map((p, j) => (j === i ? { ...p, [key]: value } : p)),
|
||||
);
|
||||
|
||||
const addPair = () =>
|
||||
setPairs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
localCol: localNames[0] ?? "",
|
||||
refCol: refColumns[0]?.name ?? "",
|
||||
},
|
||||
]);
|
||||
|
||||
const removePair = (i: number) =>
|
||||
setPairs((prev) =>
|
||||
prev.length > 1 ? prev.filter((_, j) => j !== i) : prev,
|
||||
);
|
||||
|
||||
const typeMappings = pairs
|
||||
.map((p) => ({
|
||||
localType:
|
||||
localColumns.find((c) => c.name === p.localCol)?.data_type ??
|
||||
"",
|
||||
refType:
|
||||
refTableInfo?.columns.find((c) => c.name === p.refCol)
|
||||
?.data_type ?? "",
|
||||
}))
|
||||
.filter((m) => m.localType !== "" || m.refType !== "");
|
||||
|
||||
const tableLabel = table.trim() === "" ? "unnamed table" : table;
|
||||
|
||||
const addFk = async () => {
|
||||
const cols = pairs.map((p) => p.localCol);
|
||||
const refCols = pairs.map((p) => p.refCol);
|
||||
if (
|
||||
cols.length === 0 ||
|
||||
cols.some((c) => c === "") ||
|
||||
refCols.some((c) => c === "")
|
||||
)
|
||||
return;
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
const refTypes: Record<string, string> = {};
|
||||
for (const c of refTableInfo?.columns ?? []) refTypes[c.name] = c.data_type;
|
||||
const pairsResult = pairs.map((p) => ({
|
||||
localCol: p.localCol,
|
||||
refType: refTypes[p.refCol] ?? "",
|
||||
}));
|
||||
const fk: FkDefinition = {
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
deferrable: false,
|
||||
initially_deferred: false,
|
||||
};
|
||||
try {
|
||||
if (mode === "create") {
|
||||
// Inline the FK into the CREATE TABLE — no separate ALTER change.
|
||||
onStaged?.({ pairs: pairsResult, fk });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const sqls = await buildObjectDdl(connectionId, "constraint", {
|
||||
schema,
|
||||
table,
|
||||
name: "",
|
||||
action: {
|
||||
op: "foreign_key",
|
||||
columns: cols,
|
||||
ref_schema: refSchema,
|
||||
ref_table: refTable,
|
||||
ref_columns: refCols,
|
||||
on_delete: onDelete,
|
||||
on_update: onUpdate,
|
||||
},
|
||||
});
|
||||
sqls.forEach((sql, i) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description:
|
||||
sqls.length > 1
|
||||
? `Add FK ${cols.join(", ")} → ${refSchema}.${refTable} (${i + 1}/${sqls.length})`
|
||||
: `Add FK ${cols.join(", ")} → ${refSchema}.${refTable}`,
|
||||
}),
|
||||
);
|
||||
onStaged?.({ pairs: pairsResult });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
|
||||
style={{ willChange: "transform" }}
|
||||
className="fixed top-0 right-0 h-full w-[420px] bg-canvas border-l border-border z-40 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2 text-text">
|
||||
<Link size={14} />
|
||||
<span className="text-sm font-semibold">Foreign key</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-text-muted hover:text-text"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-auto"
|
||||
style={{ overscrollBehavior: "none" }}
|
||||
>
|
||||
{table.trim() === "" && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-amber-400">
|
||||
Enter a table name in the form first — a foreign key
|
||||
needs a named table.
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
Loading schema graph…
|
||||
</p>
|
||||
)}
|
||||
{!loading && graph.tables.length === 0 && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-text-muted">
|
||||
No tables available for foreign key reference.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && graph.tables.length > 0 && (
|
||||
<>
|
||||
<Section label="Select a Schema">
|
||||
<select
|
||||
aria-label="Schema"
|
||||
value={refSchema}
|
||||
onChange={(e) => setRefSchema(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{schemas.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Select a Table to reference to">
|
||||
<select
|
||||
aria-label="Table"
|
||||
value={refTable}
|
||||
onChange={(e) => setRefTable(e.target.value)}
|
||||
className={panelSelect}
|
||||
>
|
||||
{tablesInSchema.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
{refSchema && refTable && (
|
||||
<>
|
||||
<Section
|
||||
flush
|
||||
label={`Select columns from ${schema}.${tableLabel} to reference to`}
|
||||
>
|
||||
<div className="grid grid-cols-2 text-xs text-text-muted border-b border-border">
|
||||
<div className="border-r border-border px-4 py-1">
|
||||
<span className="truncate">
|
||||
{schema}.{tableLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-1">
|
||||
<span className="truncate">
|
||||
{refSchema}.{refTable || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{pairs.map((pair, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-2 items-center border-b border-border"
|
||||
>
|
||||
<div className="border-r border-border px-2 py-1">
|
||||
<select
|
||||
aria-label={`Local column ${i + 1}`}
|
||||
value={pair.localCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"localCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{localColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-2 py-1">
|
||||
<select
|
||||
aria-label={`Referenced column ${i + 1}`}
|
||||
value={pair.refCol}
|
||||
onChange={(e) =>
|
||||
setPair(
|
||||
i,
|
||||
"refCol",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{refColumns.map((c) => (
|
||||
<option
|
||||
key={c.name}
|
||||
value={c.name}
|
||||
>
|
||||
{c.name} (
|
||||
{c.data_type})
|
||||
{c.is_pk
|
||||
? " (PK)"
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pairs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove pair ${i + 1}`}
|
||||
onClick={() =>
|
||||
removePair(i)
|
||||
}
|
||||
className="shrink-0 text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add another column"
|
||||
onClick={addPair}
|
||||
className="text-xs text-accent hover:text-accent-hover px-4 py-2 cursor-pointer"
|
||||
>
|
||||
<Plus size={12} className="inline" />{" "}
|
||||
Add another column
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{typeMappings.length > 0 && (
|
||||
<Section label="Types will be updated">
|
||||
<ul className="space-y-0.5">
|
||||
{typeMappings.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="px-2 font-mono text-xs text-text"
|
||||
>
|
||||
<span className="text-text-muted mr-2">
|
||||
{m.localType || "?"}
|
||||
</span>
|
||||
<span className="text-accent">
|
||||
→
|
||||
</span>{" "}
|
||||
<span className="">
|
||||
{m.refType || "?"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Action if referenced row is updated">
|
||||
<select
|
||||
aria-label="On update"
|
||||
value={onUpdate}
|
||||
onChange={(e) =>
|
||||
setOnUpdate(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
<Section label="Action if referenced row is removed">
|
||||
<select
|
||||
aria-label="On delete"
|
||||
value={onDelete}
|
||||
onChange={(e) =>
|
||||
setOnDelete(e.target.value)
|
||||
}
|
||||
className={panelSelect}
|
||||
>
|
||||
{FK_ACTIONS.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="border-b border-border px-4 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs font-medium text-text hover:text-text"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addFk}
|
||||
disabled={
|
||||
staging ||
|
||||
table.trim() === "" ||
|
||||
pairs.length === 0 ||
|
||||
pairs.some((p) => p.localCol === "" || p.refCol === "")
|
||||
}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Add FK
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { IndexForm } from "./IndexForm";
|
||||
import { ConstraintForm } from "./ConstraintForm";
|
||||
import { FunctionForm } from "./FunctionForm";
|
||||
import { TriggerForm } from "./TriggerForm";
|
||||
import { RoleForm } from "./RoleForm";
|
||||
import type { ObjectKind, DdlParams } from "../../../lib/objectCrud";
|
||||
|
||||
interface Props {
|
||||
@@ -64,5 +65,11 @@ export function KindForm({ connectionId, kind, params, schemas, onChange }: Prop
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case "table":
|
||||
// Unreachable in the form-tab flow (ObjectFormTab delegates table to
|
||||
// TableForm directly); kept only so the switch is exhaustive.
|
||||
return null;
|
||||
case "role":
|
||||
return <RoleForm connectionId={connectionId} params={params} onChange={onChange} />;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { ObjectContextMenu } from "./ObjectContextMenu";
|
||||
import { ObjectContextMenu, DROP_TITLE } 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("DROP_TITLE covers the table and role kinds (exhaustiveness)", () => {
|
||||
expect(DROP_TITLE.table).toBe("table");
|
||||
expect(DROP_TITLE.role).toBe("role");
|
||||
});
|
||||
|
||||
it("Edit on a sequence opens an objectForm tab with prefilled edit params", async () => {
|
||||
render(
|
||||
<ObjectContextMenu
|
||||
@@ -59,7 +55,7 @@ describe("ObjectContextMenu", () => {
|
||||
});
|
||||
|
||||
it("Drop fetches dependencies then stages the drop", async () => {
|
||||
vi.mocked(objectCrud.buildObjectDdl).mockResolvedValue([
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue([
|
||||
'DROP SEQUENCE "public"."s"',
|
||||
]);
|
||||
const addChange = vi.spyOn(useDbViewerStore.getState(), "addChange");
|
||||
|
||||
@@ -23,7 +23,7 @@ interface Props {
|
||||
extraItems?: { id: string; label: string; danger?: boolean; onClick: () => void }[];
|
||||
}
|
||||
|
||||
const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
export const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
sequence: "sequence",
|
||||
enum: "type",
|
||||
view: "view",
|
||||
@@ -33,6 +33,8 @@ const DROP_TITLE: Record<ObjectKind, string> = {
|
||||
function: "function",
|
||||
procedure: "procedure",
|
||||
trigger: "trigger",
|
||||
table: "table",
|
||||
role: "role",
|
||||
};
|
||||
|
||||
export function ObjectContextMenu({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ObjectDetail } from "./ObjectDetail";
|
||||
import {
|
||||
ObjectDetail,
|
||||
OBJECT_ICONS,
|
||||
SINGULAR_LABELS,
|
||||
TYPE_LABELS,
|
||||
} from "./ObjectDetail";
|
||||
import * as commands from "../../../lib/commands";
|
||||
|
||||
describe("ObjectDetail", () => {
|
||||
it("renders an enum's labels as list items", () => {
|
||||
@@ -36,4 +42,34 @@ describe("ObjectDetail", () => {
|
||||
expect(screen.getByText("int")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/plpgsql/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("roles detail renders RoleDetail", () => {
|
||||
vi.spyOn(commands, "getRolePrivileges").mockResolvedValue([]);
|
||||
render(
|
||||
<ObjectDetail
|
||||
connectionId="c1"
|
||||
type="roles"
|
||||
item={{
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("registries include roles", () => {
|
||||
expect(TYPE_LABELS.roles).toBe("Roles");
|
||||
expect(SINGULAR_LABELS.roles).toBe("role");
|
||||
expect(OBJECT_ICONS.roles).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
@@ -18,7 +19,9 @@ import type {
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RoleInfo,
|
||||
} from "../../../lib/types";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
|
||||
export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
functions: "Functions",
|
||||
@@ -29,6 +32,7 @@ export const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
roles: "Roles",
|
||||
};
|
||||
|
||||
export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
@@ -40,6 +44,7 @@ export const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
roles: "role",
|
||||
};
|
||||
|
||||
export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
@@ -53,6 +58,7 @@ export const OBJECT_ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||
roles: <ShieldCheck size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
export type AnyObject =
|
||||
@@ -62,7 +68,8 @@ export type AnyObject =
|
||||
| EnumInfo
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo;
|
||||
| ConstraintInfo
|
||||
| RoleInfo;
|
||||
|
||||
// ─── syntax highlighting for PL/pgSQL / SQL ──────────────
|
||||
|
||||
@@ -562,8 +569,10 @@ function renderFunctionDetail(f: FunctionInfo) {
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(type: ObjectType, item: AnyObject) {
|
||||
function renderDetail(connectionId: string, type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
case "roles":
|
||||
return <RoleDetail connectionId={connectionId} item={item as RoleInfo} />;
|
||||
case "functions":
|
||||
return renderFunctionDetail(item as FunctionInfo);
|
||||
case "procedures":
|
||||
@@ -969,6 +978,6 @@ interface ObjectDetailProps {
|
||||
item: AnyObject;
|
||||
}
|
||||
|
||||
export function ObjectDetail({ connectionId: _connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(type, item)}</>;
|
||||
export function ObjectDetail({ connectionId, type, item }: ObjectDetailProps) {
|
||||
return <>{renderDetail(connectionId, type, item)}</>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { KindForm } from "./KindForm";
|
||||
import { TableForm } from "./TableForm";
|
||||
import {
|
||||
buildObjectDdl,
|
||||
type DdlParams,
|
||||
@@ -19,6 +20,10 @@ export function ObjectFormTab({ connectionId, tab }: Props) {
|
||||
const form = tab.form;
|
||||
if (!form) return null;
|
||||
|
||||
if (form.kind === "table") {
|
||||
return <TableForm connectionId={connectionId} tab={tab} />;
|
||||
}
|
||||
|
||||
const { kind, params, description, mode } = form;
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RoleDetail } from "./RoleDetail";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
});
|
||||
|
||||
const baseRole: RoleInfo = {
|
||||
name: "app",
|
||||
superuser: false,
|
||||
inherit: true,
|
||||
create_db: false,
|
||||
create_role: false,
|
||||
can_login: true,
|
||||
replication: false,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
};
|
||||
|
||||
describe("RoleDetail", () => {
|
||||
it("renders role attributes + memberships + grants editor", async () => {
|
||||
render(<RoleDetail connectionId="c1" item={baseRole} />);
|
||||
expect(screen.getByText("app")).toBeInTheDocument();
|
||||
expect(screen.getByText("Can login")).toBeInTheDocument();
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders memberships when present", () => {
|
||||
render(
|
||||
<RoleDetail
|
||||
connectionId="c1"
|
||||
item={{
|
||||
...baseRole,
|
||||
memberships: [{ role: "app", member: "other", grantor: "postgres", admin_option: true }],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("other")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormSectionHeader } from "./formRow";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
item: RoleInfo;
|
||||
}
|
||||
|
||||
interface AttributeRow {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export function RoleDetail({ connectionId, item }: Props) {
|
||||
const attributes: AttributeRow[] = [
|
||||
{ label: "Can login", value: item.can_login ? "Yes" : "No", highlight: item.can_login },
|
||||
{ label: "Superuser", value: item.superuser ? "Yes" : "No", highlight: item.superuser },
|
||||
{ label: "Create databases", value: item.create_db ? "Yes" : "No", highlight: item.create_db },
|
||||
{ label: "Create roles", value: item.create_role ? "Yes" : "No", highlight: item.create_role },
|
||||
{ label: "Inherit privileges", value: item.inherit ? "Yes" : "No", highlight: item.inherit },
|
||||
{ label: "Replication", value: item.replication ? "Yes" : "No", highlight: item.replication },
|
||||
{ label: "Bypass RLS", value: item.bypass_rls ? "Yes" : "No", highlight: item.bypass_rls },
|
||||
{ label: "Connection limit", value: String(item.connection_limit) },
|
||||
{ label: "Valid until", value: item.valid_until ?? "Never" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Role</span>
|
||||
</div>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">Name</span>
|
||||
<span className="text-sm text-accent font-mono">{item.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">Attributes</span>
|
||||
</div>
|
||||
{attributes.map((attr) => (
|
||||
<div key={attr.label} className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center w-40 shrink-0">
|
||||
<span className="text-xs text-text-muted">{attr.label}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-1">
|
||||
<span className={`text-sm ${attr.highlight ? "text-emerald-400" : "text-text-muted"}`}>
|
||||
{attr.value}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={item.memberships.length} />
|
||||
{item.memberships.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">Not a member of any role.</p>
|
||||
)}
|
||||
{item.memberships.map((m, i) => (
|
||||
<div key={`${m.role}-${m.member}-${i}`} className="border-b border-border px-4 py-2 flex items-center">
|
||||
<span className="text-xs text-text-muted w-28 shrink-0">Role</span>
|
||||
<span className="text-sm text-accent font-mono">{m.member}</span>
|
||||
<span className="ml-2 text-xs text-text-muted">of {m.role}</span>
|
||||
{m.admin_option && (
|
||||
<span className="ml-2 text-[10px] text-amber-400 uppercase tracking-wider">admin</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<RoleGrantsEditor connectionId={connectionId} role={item.name} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleForm } from "./RoleForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["CREATE ROLE app;"]);
|
||||
vi.spyOn(cmd, "getRoles").mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "",
|
||||
name: mode === "edit" ? "app" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
login: mode === "edit",
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("RoleForm", () => {
|
||||
it("create role with options writes params", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={baseParams("create")}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("Role name"), { target: { value: "app" } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "app" }));
|
||||
|
||||
fireEvent.click(screen.getByLabelText("LOGIN"));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: expect.objectContaining({ login: true }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("edit mode password field is blank with keep hint", () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
mode="edit"
|
||||
params={baseParams("edit")}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText(/leave blank to keep/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stages a create role ddl via the queue", async () => {
|
||||
render(
|
||||
<RoleForm
|
||||
connectionId="c1"
|
||||
params={{
|
||||
schema: "",
|
||||
name: "app",
|
||||
action: {
|
||||
op: "create",
|
||||
login: true,
|
||||
superuser: false,
|
||||
createdb: false,
|
||||
createrole: false,
|
||||
inherit: true,
|
||||
replication: false,
|
||||
bypassrls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: "",
|
||||
password: "secret",
|
||||
members: [],
|
||||
},
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
expect(q[0].sql).toBe("CREATE ROLE app;");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { RoleInfo } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
interface RoleMember {
|
||||
name: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
interface RoleAction {
|
||||
op: "create" | "edit" | "drop";
|
||||
login: boolean;
|
||||
superuser: boolean;
|
||||
createdb: boolean;
|
||||
createrole: boolean;
|
||||
inherit: boolean;
|
||||
replication: boolean;
|
||||
bypassrls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string;
|
||||
password: string;
|
||||
members: RoleMember[];
|
||||
}
|
||||
|
||||
interface RoleParams {
|
||||
schema: string;
|
||||
name: string;
|
||||
action: RoleAction;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
mode?: "create" | "edit";
|
||||
params: Record<string, unknown>;
|
||||
onChange: (p: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
const OPTIONS: { key: keyof Omit<RoleAction, "op" | "connection_limit" | "valid_until" | "password" | "members">; label: string }[] = [
|
||||
{ key: "login", label: "LOGIN" },
|
||||
{ key: "superuser", label: "SUPERUSER" },
|
||||
{ key: "createdb", label: "CREATEDB" },
|
||||
{ key: "createrole", label: "CREATEROLE" },
|
||||
{ key: "inherit", label: "INHERIT" },
|
||||
{ key: "replication", label: "REPLICATION" },
|
||||
{ key: "bypassrls", label: "BYPASSRLS" },
|
||||
];
|
||||
|
||||
function patchAction(params: RoleParams, patch: Partial<RoleAction>): RoleParams {
|
||||
return { ...params, action: { ...params.action, ...patch } };
|
||||
}
|
||||
|
||||
export function RoleForm({ connectionId, mode = "create", params, onChange }: Props) {
|
||||
const typed = params as unknown as RoleParams;
|
||||
const action = typed.action;
|
||||
|
||||
const [view, setView] = useState<"visual" | "sql">("visual");
|
||||
const [preview, setPreview] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staging, setStaging] = useState(false);
|
||||
const [roles, setRoles] = useState<RoleInfo[]>([]);
|
||||
|
||||
const setParams = (next: RoleParams) => onChange(next as unknown as Record<string, unknown>);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
cmd
|
||||
.getRoles(connectionId)
|
||||
.then((list) => {
|
||||
if (active) setRoles(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setRoles([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setError(null);
|
||||
cmd
|
||||
.buildObjectDdl(connectionId, "role", params)
|
||||
.then((sqls) => {
|
||||
if (active) setPreview(Array.isArray(sqls) ? sqls.join("\n;\n") : (sqls as string));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) {
|
||||
setPreview("");
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, params]);
|
||||
|
||||
const availableRoles = useMemo(
|
||||
() => roles.filter((r) => r.name !== typed.name),
|
||||
[roles, typed.name],
|
||||
);
|
||||
|
||||
const toggleMember = (name: string, checked: boolean) => {
|
||||
const next = checked
|
||||
? [...action.members, { name, admin: false }]
|
||||
: action.members.filter((m) => m.name !== name);
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const setMemberAdmin = (name: string, admin: boolean) => {
|
||||
const next = action.members.map((m) => (m.name === name ? { ...m, admin } : m));
|
||||
setParams(patchAction(typed, { members: next }));
|
||||
};
|
||||
|
||||
const stage = async () => {
|
||||
setStaging(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", params);
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${mode === "edit" ? "Edit" : "Create"} role ${typed.name}`,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStaging(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-transparent">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{mode} role
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Visual"
|
||||
onClick={() => setView("visual")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "visual" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="SQL"
|
||||
onClick={() => setView("sql")}
|
||||
className={[
|
||||
"px-2 py-0.5 text-xs transition-colors cursor-pointer",
|
||||
view === "sql" ? "bg-surface-raised text-text" : "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
SQL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={stage}
|
||||
disabled={!!error || staging || !typed.name.trim()}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Stage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-3">
|
||||
{view === "visual" ? (
|
||||
<>
|
||||
<FormRow label="Name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Role name"
|
||||
value={typed.name}
|
||||
onChange={(e) => setParams({ ...typed, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Options" />
|
||||
<div className="px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{OPTIONS.map(({ key, label }) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-xs text-text cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={label}
|
||||
checked={!!action[key]}
|
||||
onChange={(e) =>
|
||||
setParams(patchAction(typed, { [key]: e.target.checked } as Partial<RoleAction>))
|
||||
}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormRow label="Connection limit">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="-1 for unlimited"
|
||||
value={action.connection_limit}
|
||||
onChange={(e) =>
|
||||
setParams(
|
||||
patchAction(typed, {
|
||||
connection_limit: e.target.value === "" ? -1 : Number(e.target.value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Valid until">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={action.valid_until}
|
||||
onChange={(e) => setParams(patchAction(typed, { valid_until: e.target.value }))}
|
||||
className={controlClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Password">
|
||||
<input
|
||||
type="password"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "leave blank to keep current password"
|
||||
: "Password"
|
||||
}
|
||||
value={action.password}
|
||||
onChange={(e) => setParams(patchAction(typed, { password: e.target.value }))}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<div className="border-b border-border">
|
||||
<FormSectionHeader label="Member of" count={action.members.length} />
|
||||
{availableRoles.length === 0 && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No other roles available.</p>
|
||||
)}
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
{availableRoles.map((r) => {
|
||||
const member = action.members.find((m) => m.name === r.name);
|
||||
return (
|
||||
<div key={r.name} className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!member}
|
||||
onChange={(e) => toggleMember(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="font-mono">{r.name}</span>
|
||||
</label>
|
||||
{member && (
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.admin}
|
||||
onChange={(e) => setMemberAdmin(r.name, e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
ADMIN
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<pre className="text-xs leading-6 font-mono whitespace-pre-wrap text-text">{preview}</pre>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { RoleGrantsEditor } from "./RoleGrantsEditor";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue(["GRANT SELECT ON TABLE public.users TO app;"]);
|
||||
});
|
||||
|
||||
describe("RoleGrantsEditor", () => {
|
||||
it("toggling a privilege stages a GRANT ddl change", async () => {
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Object class"), { target: { value: "table" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Schema"), { target: { value: "public" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Object name"), { target: { value: "users" } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText("SELECT"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /grant/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q.some((c) => c.type === "ddl" && c.sql.startsWith("GRANT"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows first 5 privileges while collapsed, expands to show all", async () => {
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue([
|
||||
{ object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false },
|
||||
]);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Fewer than 5 entries — visible immediately without expanding.
|
||||
expect(await screen.findByText("public.users")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("SELECT")).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("truncates long sections to 5 entries with a fade and Show more", async () => {
|
||||
const entries: PrivilegeEntry[] = Array.from({ length: 7 }, (_, i) => ({
|
||||
object_class: "table",
|
||||
schema: "public",
|
||||
name: `t${i + 1}`,
|
||||
privileges: ["SELECT"],
|
||||
grantable: false,
|
||||
}));
|
||||
vi.spyOn(cmd, "getRolePrivileges").mockResolvedValue(entries);
|
||||
render(<RoleGrantsEditor connectionId="c1" role="app" />);
|
||||
// Collapsed by default: first 5 visible, rest hidden.
|
||||
expect(await screen.findByText("public.t1")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t5")).toBeInTheDocument();
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /show 2 more/i }));
|
||||
expect(await screen.findByText("public.t6")).toBeInTheDocument();
|
||||
expect(screen.getByText("public.t7")).toBeInTheDocument();
|
||||
// Header chevron can collapse it back.
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle table privileges/i }));
|
||||
expect(screen.queryByText("public.t6")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import type { PrivilegeEntry } from "../../../lib/types";
|
||||
import { FormRow, FormSectionHeader, inputClass, controlClass } from "./formRow";
|
||||
|
||||
type ObjectClass = "table" | "sequence" | "routine" | "schema" | "database";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const CLASS_LABELS: Record<ObjectClass, string> = {
|
||||
table: "Table",
|
||||
sequence: "Sequence",
|
||||
routine: "Routine",
|
||||
schema: "Schema",
|
||||
database: "Database",
|
||||
};
|
||||
|
||||
const PRIVILEGES: Record<ObjectClass, string[]> = {
|
||||
table: ["SELECT", "INSERT", "UPDATE", "TRUNCATE", "REFERENCES", "TRIGGER"],
|
||||
sequence: ["USAGE", "SELECT", "UPDATE"],
|
||||
routine: ["EXECUTE"],
|
||||
schema: ["USAGE", "CREATE"],
|
||||
database: ["CONNECT", "CREATE", "TEMP"],
|
||||
};
|
||||
|
||||
export function RoleGrantsEditor({ connectionId, role }: Props) {
|
||||
const [privileges, setPrivileges] = useState<PrivilegeEntry[]>([]);
|
||||
const [objectClass, setObjectClass] = useState<ObjectClass>("table");
|
||||
const [objectSchema, setObjectSchema] = useState("");
|
||||
const [objectName, setObjectName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [grantOption, setGrantOption] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const PREVIEW_COUNT = 5;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cmd
|
||||
.getRolePrivileges(connectionId, role)
|
||||
.then((list) => {
|
||||
if (active) {
|
||||
setPrivileges(Array.isArray(list) ? list : []);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (active) setError(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [connectionId, role]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<ObjectClass, PrivilegeEntry[]>();
|
||||
privileges.forEach((p) => {
|
||||
const list = map.get(p.object_class) ?? [];
|
||||
list.push(p);
|
||||
map.set(p.object_class, list);
|
||||
});
|
||||
return map;
|
||||
}, [privileges]);
|
||||
|
||||
const togglePrivilege = (priv: string) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(priv)) next.delete(priv);
|
||||
else next.add(priv);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const submit = async (op: "grant" | "revoke") => {
|
||||
setError(null);
|
||||
try {
|
||||
const sqls = await cmd.buildObjectDdl(connectionId, "role", {
|
||||
schema: "",
|
||||
name: "",
|
||||
action: {
|
||||
op,
|
||||
object_class: objectClass,
|
||||
object_schema: objectSchema || null,
|
||||
object_name: objectName,
|
||||
privileges: Array.from(selected),
|
||||
grantee: role,
|
||||
grant_option: grantOption,
|
||||
},
|
||||
});
|
||||
const list = Array.isArray(sqls) ? sqls : [sqls];
|
||||
list.forEach((sql) =>
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql,
|
||||
description: `${op.toUpperCase()} ${Array.from(selected).join(",")} on ${objectClass} ${objectSchema ? `${objectSchema}.` : ""}${objectName} to ${role}`,
|
||||
}),
|
||||
);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FormSectionHeader label="Current privileges" count={privileges.length} />
|
||||
{loading && <p className="px-4 py-2 text-xs text-text-muted">Loading...</p>}
|
||||
{grouped.size === 0 && !loading && (
|
||||
<p className="px-4 py-2 text-xs text-text-muted">No privileges found for this role.</p>
|
||||
)}
|
||||
{Array.from(grouped.entries()).map(([cls, list]) => {
|
||||
const isExpanded = !!expanded[cls];
|
||||
const hasMore = list.length > PREVIEW_COUNT;
|
||||
const visible = !isExpanded && hasMore ? list.slice(0, PREVIEW_COUNT) : list;
|
||||
return (
|
||||
<div key={cls} className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${CLASS_LABELS[cls]} privileges`}
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: !e[cls] }))}
|
||||
className="w-full px-4 py-1 flex items-center justify-between hover:bg-surface-raised/40 cursor-pointer"
|
||||
>
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
{CLASS_LABELS[cls]}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-text-muted tabular-nums">
|
||||
{list.length}
|
||||
</span>
|
||||
{hasMore && (
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 text-text-muted transition-transform duration-150 ${
|
||||
isExpanded ? "-rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="relative overflow-hidden">
|
||||
{visible.map((entry) => (
|
||||
<div
|
||||
key={`${entry.object_class}:${entry.schema ?? ""}:${entry.name}`}
|
||||
className="px-4 py-1.5 flex items-center gap-2 text-xs text-text"
|
||||
>
|
||||
<span className="font-mono text-accent">
|
||||
{entry.schema ? `${entry.schema}.` : ""}
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-text-muted">
|
||||
{entry.privileges.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{!isExpanded && hasMore && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-canvas to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [cls]: true }))}
|
||||
className="px-4 pb-2 text-[11px] text-accent hover:text-accent-hover cursor-pointer"
|
||||
>
|
||||
Show {list.length - PREVIEW_COUNT} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<FormSectionHeader label="Edit privileges" />
|
||||
|
||||
<FormRow label="Object class">
|
||||
<select
|
||||
aria-label="Object class"
|
||||
value={objectClass}
|
||||
onChange={(e) => {
|
||||
setObjectClass(e.target.value as ObjectClass);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={controlClass}
|
||||
>
|
||||
{(Object.keys(CLASS_LABELS) as ObjectClass[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CLASS_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Schema">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Schema"
|
||||
value={objectSchema}
|
||||
onChange={(e) => setObjectSchema(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Object name">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Object name"
|
||||
value={objectName}
|
||||
onChange={(e) => setObjectName(e.target.value)}
|
||||
className={inputClass}
|
||||
/>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Privileges">
|
||||
<div className="flex-1 px-4 py-2 grid grid-cols-2 gap-2">
|
||||
{PRIVILEGES[objectClass].map((priv) => (
|
||||
<label key={priv} className="flex items-center gap-2 text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={priv}
|
||||
checked={selected.has(priv)}
|
||||
onChange={() => togglePrivilege(priv)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>{priv}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FormRow>
|
||||
|
||||
<FormRow label="Grant option">
|
||||
<label className="min-w-0 flex-1 flex items-center gap-2 px-3 font-heading text-xs text-text cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={grantOption}
|
||||
onChange={(e) => setGrantOption(e.target.checked)}
|
||||
className="rounded border-border bg-surface text-accent focus:ring-accent"
|
||||
/>
|
||||
<span>WITH GRANT OPTION</span>
|
||||
</label>
|
||||
</FormRow>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("grant")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Grant
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit("revoke")}
|
||||
disabled={selected.size === 0 || !objectName.trim()}
|
||||
className="rounded-lg bg-surface px-3 py-1.5 text-xs font-medium text-text border border-border hover:bg-surface-raised disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ describe("SequenceForm", () => {
|
||||
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-amber-400/20");
|
||||
expect(valueArea!.className).toContain("focus-within:outline-offset-[-2px]");
|
||||
|
||||
// The label cell must stay clean: no ancestor of the label may carry
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { TableForm } from "./TableForm";
|
||||
import { useDbViewerStore } from "../../../stores/dbViewerStore";
|
||||
import * as cmd from "../../../lib/commands";
|
||||
import * as objectCrud from "../../../lib/objectCrud";
|
||||
|
||||
beforeEach(() => {
|
||||
useDbViewerStore.getState().reset();
|
||||
vi.spyOn(cmd, "buildObjectDdl").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "buildRebuildScript").mockReset().mockResolvedValue("");
|
||||
vi.spyOn(cmd, "getTableRebuildReadiness").mockReset().mockResolvedValue({ ok: true, reasons: [] });
|
||||
vi.spyOn(cmd, "getTableColumns").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getTablespaces").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getConstraints").mockReset().mockResolvedValue([]);
|
||||
vi.spyOn(cmd, "getSchemaGraph").mockReset().mockResolvedValue({ tables: [], relationships: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function baseParams(mode: "create" | "edit") {
|
||||
return {
|
||||
schema: "public",
|
||||
name: mode === "edit" ? "users" : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: [],
|
||||
old_columns:
|
||||
mode === "edit"
|
||||
? [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }]
|
||||
: undefined,
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function seedFormTab(tab: any) {
|
||||
useDbViewerStore.setState({ tabs: [{ ...tab, tabType: tab.tabType ?? "objectForm" }], activeTabId: tab.id });
|
||||
}
|
||||
|
||||
describe("TableForm", () => {
|
||||
it("add/remove rows; composite PK allowed", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: baseParams("create"),
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
fireEvent.click(screen.getByLabelText("Add column"));
|
||||
const cols = () => (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
expect(cols()).toHaveLength(2);
|
||||
// the first added column auto-starts as the PK
|
||||
expect(cols()[0].is_pk).toBe(true);
|
||||
// marking a second column as PK keeps both (composite key)
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("PK"));
|
||||
expect(cols().filter((c: any) => c.is_pk)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("reorder triggers rebuild path and readiness refusal blocks staging", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: false, reasons: ["table has triggers"] });
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
// order differs => rebuild; readiness not ok => refusal copy + Stage disabled
|
||||
expect(await screen.findByText(/has triggers/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Stage" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("rebuild ok stages a single rebuild_table change", async () => {
|
||||
(cmd.getTableRebuildReadiness as any).mockResolvedValue({ ok: true, reasons: [] });
|
||||
(cmd.buildRebuildScript as any).mockResolvedValue("CREATE TABLE _t();");
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
],
|
||||
old_columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit Table",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/Rebuild/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("rebuild_table");
|
||||
expect(q[0].sql).toBe("CREATE TABLE _t();");
|
||||
});
|
||||
|
||||
it("edit diff (no reorder) stages ddl per statement after stale guard passes", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
]);
|
||||
(cmd.buildObjectDdl as any).mockResolvedValue([
|
||||
'ALTER TABLE "public"."users" ADD COLUMN "email" text',
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "email", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
await screen.findByText(/committed/i).catch(() => null); // staging is sync-ish
|
||||
const q = useDbViewerStore.getState().changesQueue;
|
||||
expect(q).toHaveLength(1);
|
||||
expect(q[0].type).toBe("ddl");
|
||||
});
|
||||
|
||||
it("stale guard blocks staging when live columns differ", async () => {
|
||||
(cmd.getTableColumns as any).mockResolvedValue([
|
||||
{
|
||||
name: "id",
|
||||
data_type: "bigint",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
]);
|
||||
render(
|
||||
<TableForm
|
||||
connectionId="c1"
|
||||
tab={{
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "users",
|
||||
action: {
|
||||
op: "edit",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
old_columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Edit Table",
|
||||
description: "Edit users",
|
||||
mode: "edit",
|
||||
},
|
||||
title: "Edit Table",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText(/SQL/i);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stage" }));
|
||||
expect(await screen.findByText(/changed since you opened/i)).toBeInTheDocument();
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("create-mode grid renders header and cog settings menus", () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const headers = screen.getAllByText("Name");
|
||||
expect(headers.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("Parameters")).toBeInTheDocument();
|
||||
expect(screen.getByText("Default Value")).toBeInTheDocument();
|
||||
// first row (id, integer): Auto-Increment is available
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Auto-Increment")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[0]); // close
|
||||
// second row (sku, text): no Auto-Increment
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
expect(screen.getByLabelText("PK")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Auto-Increment")).toBeNull();
|
||||
expect(screen.getByLabelText("Unique")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Nullable")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds auto_increment integer to serial in the DDL payload", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [{ name: "id", type: "integer", nullable: false, default: null, is_pk: true }],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Column settings"));
|
||||
fireEvent.click(screen.getByLabelText("Auto-Increment"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[0].auto_increment).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Column settings")); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.type === "serial"));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "id", type: "serial", nullable: false, is_pk: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("folds unique to the DDL payload column", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "sku", type: "text", nullable: false, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]);
|
||||
fireEvent.click(screen.getByLabelText("Unique"));
|
||||
await waitFor(() => {
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params.action as any).columns[1].unique).toBe(true);
|
||||
});
|
||||
fireEvent.click(screen.getAllByLabelText("Column settings")[1]); // close menu
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => expect(cmd.buildObjectDdl).toHaveBeenCalled());
|
||||
const calls = (cmd.buildObjectDdl as any).mock.calls;
|
||||
const stageCall = calls.find((call: any) => call[2].action.columns.some((c: any) => c.name === "sku" && c.unique === true));
|
||||
expect(stageCall).toBeTruthy();
|
||||
expect(stageCall[2].action.columns).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "sku", unique: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("schema select auto-selects the current schema in create mode", () => {
|
||||
useDbViewerStore.setState({ schemas: ["public", "audit"] });
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "audit",
|
||||
name: "",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const sel = screen.getByLabelText("Schema") as HTMLSelectElement;
|
||||
expect(sel.value).toBe("audit");
|
||||
fireEvent.change(sel, { target: { value: "public" } });
|
||||
expect((useDbViewerStore.getState().tabs[0].form?.params as any).schema).toBe("public");
|
||||
});
|
||||
|
||||
it("staging an FK applies the referenced type to the local column", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
vi.spyOn(objectCrud, "buildObjectDdl").mockResolvedValue(["ALTER TABLE \"public\".\"products\" ADD FOREIGN KEY (\"category_id\") REFERENCES \"public\".\"categories\" (\"id\")"]);
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
fireEvent.click(screen.getByLabelText("Set foreign key")); // category_id (id is PK → no FK icon)
|
||||
await screen.findByText("Foreign key");
|
||||
const refColSel = (await screen.findByLabelText("Referenced column 1")) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect([...refColSel.options].map((o) => o.value)).toContain("id");
|
||||
});
|
||||
const addFkButtons = screen.getAllByRole("button", { name: "Add FK" });
|
||||
fireEvent.click(addFkButtons[addFkButtons.length - 1]);
|
||||
await waitFor(() => {
|
||||
const cols = (useDbViewerStore.getState().tabs[0].form?.params.action as any).columns;
|
||||
const col = cols.find((c: any) => c.name === "category_id");
|
||||
expect(col.type).toBe("int");
|
||||
expect(col.fk).toBe(true);
|
||||
});
|
||||
// the FK is staged inline into the CREATE TABLE (single change), not as a separate ALTER
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Table" }));
|
||||
await waitFor(() => {
|
||||
const stageCall = (cmd.buildObjectDdl as any).mock.calls.find((call: any) =>
|
||||
call[1] === "table" &&
|
||||
call[2]?.action?.foreign_keys?.some((fk: any) => fk.columns[0] === "category_id"),
|
||||
);
|
||||
expect(stageCall).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows staged FK changes in the Foreign keys section", async () => {
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: { op: "create", columns: [] },
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "ddl",
|
||||
sql: 'ALTER TABLE "public"."products" ADD FOREIGN KEY ("category_id") REFERENCES "public"."categories" ("id") ON DELETE CASCADE',
|
||||
description: "Add FK category_id → public.categories",
|
||||
});
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
expect(await screen.findByText(/Foreign key relation to/)).toBeInTheDocument();
|
||||
expect(screen.getByText("public.categories")).toBeInTheDocument();
|
||||
expect(screen.getByText(/products.category_id → categories.id/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/· CASCADE/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the FK panel with the column preselected", async () => {
|
||||
(cmd.getSchemaGraph as any).mockResolvedValue({
|
||||
tables: [
|
||||
{ name: "products", schema: "public", table_type: "BASE TABLE", columns: [{ name: "category_id", data_type: "int", is_pk: false, is_fk: false, is_unique: false, is_nullable: true, fk_ref: null }] },
|
||||
{ name: "categories", schema: "public", table_type: "BASE TABLE", columns: [{ name: "id", data_type: "int", is_pk: true, is_fk: false, is_unique: true, is_nullable: false, fk_ref: null }] },
|
||||
],
|
||||
relationships: [],
|
||||
});
|
||||
const tab = {
|
||||
id: "t1",
|
||||
form: {
|
||||
kind: "table",
|
||||
params: {
|
||||
schema: "public",
|
||||
name: "products",
|
||||
action: {
|
||||
op: "create",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, default: null, is_pk: true },
|
||||
{ name: "category_id", type: "integer", nullable: true, default: null, is_pk: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
title: "Create Table",
|
||||
description: "Create Table",
|
||||
mode: "create",
|
||||
},
|
||||
title: "Create Table",
|
||||
} as any;
|
||||
seedFormTab(tab);
|
||||
render(<TableForm connectionId="c1" tab={tab} />);
|
||||
const fkButtons = screen.getAllByLabelText("Set foreign key");
|
||||
// id is the PK column → its FK icon is hidden; category_id is the only one
|
||||
expect(fkButtons).toHaveLength(1);
|
||||
fireEvent.click(fkButtons[0]);
|
||||
expect(await screen.findByText("Foreign key")).toBeInTheDocument();
|
||||
const local = (await screen.findByLabelText("Local column 1")) as HTMLSelectElement;
|
||||
expect(local.value).toBe("category_id");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ export function FormRow({ label, children, className, outline = true }: FormRowP
|
||||
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]"
|
||||
? "focus-within:outline focus-within:outline-2 focus-within:outline-amber-400/20 focus-within:outline-offset-[-2px]"
|
||||
: "",
|
||||
].join(" ")}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.6)", () => {
|
||||
describe("tauri bundle config (v0.7.7)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.6", () => {
|
||||
expect(tauriConf.version).toBe("0.7.6");
|
||||
it("version is 0.7.7", () => {
|
||||
expect(tauriConf.version).toBe("0.7.7");
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,23 @@ describe("buildChangeSql", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function rebuildItem(sql: string): QueueItem {
|
||||
return {
|
||||
id: "ch-rb1", type: "rebuild_table", sql, status: "pending",
|
||||
createdAt: 0,
|
||||
} as unknown as QueueItem;
|
||||
}
|
||||
|
||||
describe("rebuild_table payload", () => {
|
||||
it("builds a rebuild_table payload with the script", () => {
|
||||
const p = buildChangePayload(rebuildItem("CREATE TABLE _t();"));
|
||||
expect(p).toEqual({ id: "ch-rb1", type: "rebuild_table", sql: "CREATE TABLE _t();" });
|
||||
});
|
||||
it("buildChangeSql returns the script verbatim", () => {
|
||||
expect(buildChangeSql(rebuildItem("SELECT 1;"))).toBe("SELECT 1;");
|
||||
});
|
||||
});
|
||||
|
||||
const ddlItem: QueueItem = {
|
||||
id: "ch-1", type: "ddl",
|
||||
sql: "CREATE TYPE public.role AS ENUM ('admin')",
|
||||
|
||||
@@ -59,6 +59,8 @@ export function buildChangeSql(item: QueueItem): string {
|
||||
return `DELETE FROM ${t}`;
|
||||
case "ddl":
|
||||
return item.sql ?? "";
|
||||
case "rebuild_table":
|
||||
return item.sql ?? "";
|
||||
case "drop_table":
|
||||
return `DROP TABLE ${t}`;
|
||||
default:
|
||||
@@ -88,6 +90,8 @@ export function buildChangePayload(item: QueueItem): ChangePayload {
|
||||
return { id: item.id, type: "empty_table", schema, table };
|
||||
case "ddl":
|
||||
return { id: item.id, type: "ddl", sql: item.sql };
|
||||
case "rebuild_table":
|
||||
return { id: item.id, type: "rebuild_table", sql: item.sql };
|
||||
default:
|
||||
return { id: item.id, type: item.type, sql: item.sql };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
testConnection,
|
||||
getRoles,
|
||||
getRolePrivileges,
|
||||
getTableColumns,
|
||||
getTableRebuildReadiness,
|
||||
runMaintenance,
|
||||
getTablespaces,
|
||||
buildRebuildScript,
|
||||
dbConnect,
|
||||
dbDisconnect,
|
||||
getDatabases,
|
||||
@@ -332,4 +339,88 @@ describe("object management commands (v0.7.5)", () => {
|
||||
await getObjectDependencies("c1", "public", "table", "orders");
|
||||
expect(invoke).toHaveBeenCalledWith("get_object_dependencies", { connectionId: "c1", schema: "public", objectType: "table", name: "orders" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 command wrappers", () => {
|
||||
it("getRoles calls get_roles", async () => {
|
||||
const mockRoles = [
|
||||
{
|
||||
name: "postgres",
|
||||
superuser: true,
|
||||
inherit: true,
|
||||
create_db: true,
|
||||
create_role: true,
|
||||
can_login: true,
|
||||
replication: true,
|
||||
bypass_rls: false,
|
||||
connection_limit: -1,
|
||||
valid_until: null,
|
||||
memberships: [],
|
||||
},
|
||||
];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockRoles);
|
||||
|
||||
const result = await getRoles("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_roles", { connectionId: "c1" });
|
||||
expect(result).toEqual(mockRoles);
|
||||
});
|
||||
|
||||
it("getRolePrivileges calls get_role_privileges with role", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getRolePrivileges("c1", "app");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_role_privileges", { connectionId: "c1", role: "app" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableColumns calls get_table_columns", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTableColumns("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_columns", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("getTableRebuildReadiness calls get_table_rebuild_readiness", async () => {
|
||||
const mockReadiness = { ok: true, reasons: [] };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockReadiness);
|
||||
|
||||
const result = await getTableRebuildReadiness("c1", "public", "users");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_table_rebuild_readiness", { connectionId: "c1", schema: "public", table: "users" });
|
||||
expect(result).toEqual(mockReadiness);
|
||||
});
|
||||
|
||||
it("runMaintenance calls run_maintenance with action", async () => {
|
||||
const mockResult = { duration_ms: 5, message: "ok" };
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockResult);
|
||||
|
||||
const result = await runMaintenance("c1", "public", "users", "vacuum");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("run_maintenance", { connectionId: "c1", schema: "public", table: "users", action: "vacuum" });
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
it("getTablespaces calls get_tablespaces", async () => {
|
||||
vi.mocked(invoke).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getTablespaces("c1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("get_tablespaces", { connectionId: "c1" });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("buildRebuildScript calls build_rebuild_script with newColumns", async () => {
|
||||
const mockScript = "CREATE TABLE _t();";
|
||||
const cols = [{ name: "id", type: "int", nullable: false, default: null, is_pk: true }];
|
||||
vi.mocked(invoke).mockResolvedValueOnce(mockScript);
|
||||
|
||||
const result = await buildRebuildScript("c1", "public", "users", cols);
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("build_rebuild_script", { connectionId: "c1", schema: "public", table: "users", newColumns: cols });
|
||||
expect(result).toEqual(mockScript);
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -1,7 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo } from "./types";
|
||||
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo } from "./types";
|
||||
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
|
||||
import type { ChangePayload } from "./changePayload";
|
||||
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
|
||||
|
||||
export { type ObjectKind, type DdlParams };
|
||||
|
||||
/** Build one or more SQL statements for an object CRUD operation. */
|
||||
export function buildObjectDdl(connectionId: string, kind: ObjectKind, params: DdlParams): Promise<string[]> {
|
||||
return buildObjectDdlImpl(connectionId, kind, params);
|
||||
}
|
||||
|
||||
// NOTE on argument key naming:
|
||||
// Tauri v2's #[tauri::command] macro converts Rust snake_case parameter names
|
||||
@@ -319,4 +327,28 @@ export async function getObjectDdl(connectionId: string, schema: string, objectT
|
||||
}
|
||||
export async function getObjectDependencies(connectionId: string, schema: string, objectType: string, name: string): Promise<DependencyInfo[]> {
|
||||
return invoke<DependencyInfo[]>("get_object_dependencies", { connectionId, schema, objectType, name });
|
||||
}
|
||||
|
||||
// ─── v0.7.7: Roles / privileges / rebuild / maintenance ──────────
|
||||
|
||||
export async function getRoles(connectionId: string): Promise<RoleInfo[]> {
|
||||
return invoke<RoleInfo[]>("get_roles", { connectionId });
|
||||
}
|
||||
export async function getRolePrivileges(connectionId: string, role: string): Promise<PrivilegeEntry[]> {
|
||||
return invoke<PrivilegeEntry[]>("get_role_privileges", { connectionId, role });
|
||||
}
|
||||
export async function getTableColumns(connectionId: string, schema: string, table: string): Promise<ColumnInfo[]> {
|
||||
return invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
||||
}
|
||||
export async function getTableRebuildReadiness(connectionId: string, schema: string, table: string): Promise<RebuildReadiness> {
|
||||
return invoke<RebuildReadiness>("get_table_rebuild_readiness", { connectionId, schema, table });
|
||||
}
|
||||
export async function runMaintenance(connectionId: string, schema: string, table: string, action: "vacuum" | "analyze" | "reindex"): Promise<MaintenanceResult> {
|
||||
return invoke<MaintenanceResult>("run_maintenance", { connectionId, schema, table, action });
|
||||
}
|
||||
export async function getTablespaces(connectionId: string): Promise<TablespaceInfo[]> {
|
||||
return invoke<TablespaceInfo[]>("get_tablespaces", { connectionId });
|
||||
}
|
||||
export async function buildRebuildScript<C extends { name: string; type: string; nullable: boolean; default: string | null; is_pk: boolean }[]>(connectionId: string, schema: string, table: string, newColumns: C): Promise<string> {
|
||||
return invoke<string>("build_rebuild_script", { connectionId, schema, table, newColumns });
|
||||
}
|
||||
@@ -7,6 +7,7 @@ describe("dbCapabilities", () => {
|
||||
expect(c).toEqual<DbCapabilities>({
|
||||
explorer: true, queries: true, objects: true, visualizer: true,
|
||||
tools: true, editing: true, import: true, ddl: true, objectCrud: true,
|
||||
maintenance: true, roles: true, tableManagement: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +39,7 @@ describe("dbCapabilities", () => {
|
||||
expect(DB_CAPABILITIES.redis).toEqual<DbCapabilities>({
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,4 +71,22 @@ describe("objectCrud capability", () => {
|
||||
it("unknown types get objectCrud false", () => {
|
||||
expect(getCapabilities("oracle").objectCrud).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 capabilities", () => {
|
||||
it("postgresql has maintenance, roles, tableManagement", () => {
|
||||
expect(DB_CAPABILITIES.postgresql.maintenance).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.roles).toBe(true);
|
||||
expect(DB_CAPABILITIES.postgresql.tableManagement).toBe(true);
|
||||
});
|
||||
it("mysql/sqlite/redis disable maintenance, roles, tableManagement", () => {
|
||||
for (const t of ["mysql", "sqlite", "redis"] as const) {
|
||||
expect(DB_CAPABILITIES[t].maintenance).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].roles).toBe(false);
|
||||
expect(DB_CAPABILITIES[t].tableManagement).toBe(false);
|
||||
}
|
||||
});
|
||||
it("getCapabilities is safe for unknown types", () => {
|
||||
expect(getCapabilities("bogus").maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,15 +19,22 @@ export interface DbCapabilities {
|
||||
ddl: boolean;
|
||||
/** Create / edit / drop PostgreSQL objects via the changes queue. */
|
||||
objectCrud: boolean;
|
||||
/** Table maintenance (VACUUM/ANALYZE/REINDEX) + rebuild-table. */
|
||||
maintenance: boolean;
|
||||
/** Role browsing/management. */
|
||||
roles: boolean;
|
||||
/** Rebuild-table (rewrite in place) support. */
|
||||
tableManagement: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: DbCapabilities = {
|
||||
explorer: false, queries: false, objects: false, visualizer: false,
|
||||
tools: false, editing: false, import: false, ddl: false, objectCrud: false,
|
||||
maintenance: false, roles: false, tableManagement: false,
|
||||
};
|
||||
|
||||
export const DB_CAPABILITIES: Record<DbType, DbCapabilities> = {
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true },
|
||||
postgresql: { ...ALL_FALSE, explorer: true, queries: true, objects: true, visualizer: true, tools: true, editing: true, import: true, ddl: true, objectCrud: true, maintenance: true, roles: true, tableManagement: true },
|
||||
mysql: { ...ALL_FALSE, explorer: true, queries: true, editing: true, import: true, ddl: true },
|
||||
sqlite: { ...ALL_FALSE, explorer: true, queries: true, visualizer: true, editing: true, import: true, ddl: true },
|
||||
redis: { ...ALL_FALSE },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../AGENTS.md?raw";
|
||||
import readme from "../../README.md?raw";
|
||||
|
||||
describe("v0.7.6 docs coverage", () => {
|
||||
describe("v0.7.7 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.6 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.6", () => {
|
||||
expect(readme).toContain("0.7.6");
|
||||
it("README declares v0.7.7", () => {
|
||||
expect(readme).toContain("0.7.7");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -59,9 +59,9 @@ describe("v0.7.6 docs coverage", () => {
|
||||
it("AGENTS.md marks the Objects view tabbed workspace complete", () => {
|
||||
expect(agents).toMatch(/Objects view tabbed workspace \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.6 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.6/");
|
||||
expect(readme).toContain("Gridline_0.7.6_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.6-1.x86_64.rpm");
|
||||
it("README links to v0.7.7 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.7/");
|
||||
expect(readme).toContain("Gridline_0.7.7_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.7-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getAvailableExtensions,
|
||||
initialCrudParams,
|
||||
} from "./objectCrud";
|
||||
import type { CrudItem } from "./objectCrud";
|
||||
|
||||
describe("buildObjectDdl", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -460,4 +461,31 @@ describe("dropCrudParams", () => {
|
||||
action: { op: "drop", arg_types: ["int"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("table/role crud params", () => {
|
||||
it("initialCrudParams(table, create) starts empty with columns", () => {
|
||||
const p = initialCrudParams("table", { schema: "public", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "public", name: "", action: { op: "create", columns: [] } });
|
||||
});
|
||||
it("initialCrudParams(table, edit) prefills columns", () => {
|
||||
const item: CrudItem = { schema: "public", name: "users", columns: ["id"], columnMeta: [
|
||||
{ name: "id", type: "integer", nullable: false, is_pk: true, default: null },
|
||||
] };
|
||||
const p = initialCrudParams("table", item, "edit");
|
||||
expect((p as any).action.op).toBe("edit");
|
||||
expect((p as any).action.old_columns).toEqual(item.columnMeta);
|
||||
});
|
||||
it("initialCrudParams(role, create) starts empty with options", () => {
|
||||
const p = initialCrudParams("role", { schema: "", name: "" }, "create");
|
||||
expect(p).toEqual({ schema: "", name: "", action: { op: "create", login: false, superuser: false, createdb: false, createrole: false, inherit: true, replication: false, bypassrls: false, connection_limit: -1, valid_until: "", password: "", members: [] } });
|
||||
});
|
||||
it("dropCrudParams(role) drops by name", () => {
|
||||
const p = dropCrudParams("role", { schema: "", name: "app" });
|
||||
expect(p).toEqual({ schema: "", name: "app", action: { op: "drop" } });
|
||||
});
|
||||
it("dropCrudParams(table) drops the table", () => {
|
||||
const p = dropCrudParams("table", { schema: "public", name: "users" });
|
||||
expect(p).toEqual({ schema: "public", name: "users", action: { op: "drop" } });
|
||||
});
|
||||
});
|
||||
+52
-1
@@ -14,7 +14,9 @@ export type ObjectKind =
|
||||
| "constraint"
|
||||
| "function"
|
||||
| "procedure"
|
||||
| "trigger";
|
||||
| "trigger"
|
||||
| "table"
|
||||
| "role";
|
||||
|
||||
/// Opaque payload for a build: `{ schema, name, action: { op, ... } }`.
|
||||
/// The concrete shape is validated server-side by each kind's params struct.
|
||||
@@ -60,6 +62,18 @@ export interface CrudItem {
|
||||
columns?: string[];
|
||||
// constraint (ConstraintInfo)
|
||||
contype?: "CHECK" | "UNIQUE" | "EXCLUSION";
|
||||
// table (columnMeta carries the snapshot for edit diffs)
|
||||
columnMeta?: { name: string; type: string; nullable: boolean; is_pk: boolean; default: string | null }[];
|
||||
// role (RoleInfo shape used for prefill)
|
||||
superuser?: boolean;
|
||||
inherit?: boolean;
|
||||
create_db?: boolean;
|
||||
create_role?: boolean;
|
||||
can_login?: boolean;
|
||||
replication?: boolean;
|
||||
bypass_rls?: boolean;
|
||||
connection_limit?: number;
|
||||
valid_until?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +208,40 @@ export function initialCrudParams(
|
||||
when: null,
|
||||
},
|
||||
};
|
||||
case "table":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: mode === "edit" ? "edit" : "create",
|
||||
columns: mode === "edit"
|
||||
? (item.columnMeta ?? []).map((c) => ({
|
||||
name: c.name, type: c.type, nullable: c.nullable,
|
||||
default: c.default ?? null, is_pk: c.is_pk,
|
||||
}))
|
||||
: [],
|
||||
old_columns: mode === "edit" ? (item.columnMeta ?? []) : undefined,
|
||||
},
|
||||
};
|
||||
case "role":
|
||||
return {
|
||||
schema,
|
||||
name: mode === "edit" ? item.name : "",
|
||||
action: {
|
||||
op: "create",
|
||||
login: mode === "edit" ? (item.can_login ?? false) : false,
|
||||
superuser: mode === "edit" ? (item.superuser ?? false) : false,
|
||||
createdb: mode === "edit" ? (item.create_db ?? false) : false,
|
||||
createrole: mode === "edit" ? (item.create_role ?? false) : false,
|
||||
inherit: mode === "edit" ? (item.inherit ?? true) : true,
|
||||
replication: mode === "edit" ? (item.replication ?? false) : false,
|
||||
bypassrls: mode === "edit" ? (item.bypass_rls ?? false) : false,
|
||||
connection_limit: mode === "edit" ? (item.connection_limit ?? -1) : -1,
|
||||
valid_until: mode === "edit" ? (item.valid_until ?? "") : "",
|
||||
password: "",
|
||||
members: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +251,9 @@ export function initialCrudParams(
|
||||
*/
|
||||
export function dropCrudParams(kind: ObjectKind, item: CrudItem): DdlParams {
|
||||
const base = { schema: item.schema, name: item.name };
|
||||
if (kind === "table" || kind === "role") {
|
||||
return { ...base, action: { op: "drop" } };
|
||||
}
|
||||
if (kind === "trigger") {
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
RecentConnection,
|
||||
RoleInfo,
|
||||
PrivilegeEntry,
|
||||
RebuildReadiness,
|
||||
MaintenanceResult,
|
||||
ObjectType,
|
||||
} from "./types";
|
||||
|
||||
describe("ActiveView", () => {
|
||||
@@ -495,4 +500,31 @@ describe("v0.5.0 types", () => {
|
||||
const c = { favorite: true } as Connection;
|
||||
expectTypeOf(c.favorite).toEqualTypeOf<boolean>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("v0.7.7 types", () => {
|
||||
it("ObjectType includes roles", () => {
|
||||
const t: ObjectType = "roles";
|
||||
expect(t).toBe("roles");
|
||||
});
|
||||
it("ChangeItemType includes rebuild_table", () => {
|
||||
const t: ChangeItemType = "rebuild_table";
|
||||
expect(t).toBe("rebuild_table");
|
||||
});
|
||||
it("RoleInfo shape", () => {
|
||||
const r: RoleInfo = {
|
||||
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
|
||||
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
|
||||
valid_until: null, memberships: [],
|
||||
};
|
||||
expect(r.name).toBe("app");
|
||||
});
|
||||
it("PrivilegeEntry/RebuildReadiness/MaintenanceResult shapes", () => {
|
||||
const pe: PrivilegeEntry = { object_class: "table", schema: "public", name: "users", privileges: ["SELECT"], grantable: false };
|
||||
const rr: RebuildReadiness = { ok: true, reasons: [] };
|
||||
const mr: MaintenanceResult = { duration_ms: 1, message: "ok" };
|
||||
expect(pe.object_class).toBe("table");
|
||||
expect(rr.ok).toBe(true);
|
||||
expect(mr.duration_ms).toBe(1);
|
||||
});
|
||||
});
|
||||
+48
-1
@@ -202,6 +202,7 @@ export type ChangeItemType =
|
||||
| "drop_index"
|
||||
| "bulk_insert"
|
||||
| "empty_table"
|
||||
| "rebuild_table"
|
||||
| "ddl";
|
||||
|
||||
export interface ChangeItem {
|
||||
@@ -324,7 +325,8 @@ export type ObjectType =
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
| "procedures"
|
||||
| "roles";
|
||||
|
||||
export interface ObjectSearchHit {
|
||||
name: string;
|
||||
@@ -352,6 +354,51 @@ export interface BackupJob {
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
// ─── Roles / Privileges / Maintenance (v0.7.7) ──────────────────
|
||||
|
||||
export interface RoleMembership {
|
||||
role: string;
|
||||
member: string;
|
||||
grantor: string;
|
||||
admin_option: boolean;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
name: string;
|
||||
superuser: boolean;
|
||||
inherit: boolean;
|
||||
create_db: boolean;
|
||||
create_role: boolean;
|
||||
can_login: boolean;
|
||||
replication: boolean;
|
||||
bypass_rls: boolean;
|
||||
connection_limit: number;
|
||||
valid_until: string | null;
|
||||
memberships: RoleMembership[];
|
||||
}
|
||||
|
||||
export interface PrivilegeEntry {
|
||||
object_class: "table" | "sequence" | "routine" | "schema" | "database";
|
||||
schema: string | null;
|
||||
name: string;
|
||||
privileges: string[];
|
||||
grantable: boolean;
|
||||
}
|
||||
|
||||
export interface RebuildReadiness {
|
||||
ok: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceResult {
|
||||
duration_ms: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TablespaceInfo {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ─── Schema Visualizer Types ────────────────────────────────────
|
||||
|
||||
export interface GraphColumn {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.6 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.6");
|
||||
it("declares v0.7.7 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.7");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useDbViewerStore } from "./dbViewerStore";
|
||||
import type { QueryResult, TableInfo } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, RoleInfo } from "../lib/types";
|
||||
import * as commands from "../lib/commands";
|
||||
|
||||
vi.mock("../lib/commands", () => ({
|
||||
@@ -598,3 +598,33 @@ describe("openFormTab", () => {
|
||||
expect(after?.kind).toBe(before?.kind);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roles slice", () => {
|
||||
it("setRoles stores roles", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
const r: RoleInfo = {
|
||||
name: "app", superuser: false, inherit: true, create_db: false, create_role: false,
|
||||
can_login: true, replication: false, bypass_rls: false, connection_limit: -1,
|
||||
valid_until: null, memberships: [],
|
||||
};
|
||||
useDbViewerStore.getState().setRoles([r]);
|
||||
expect(useDbViewerStore.getState().roles).toEqual([r]);
|
||||
});
|
||||
|
||||
it("openFormTab accepts kind table and role", () => {
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table", schema: "public", name: "", title: "Create Table",
|
||||
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
const t = useDbViewerStore.getState().tabs[0];
|
||||
expect(t.tabType).toBe("objectForm");
|
||||
expect(t.form?.kind).toBe("table");
|
||||
// dedup: second create of same kind focuses existing
|
||||
useDbViewerStore.getState().openFormTab({
|
||||
kind: "table", schema: "public", name: "", title: "Create Table",
|
||||
description: "Create Table", mode: "create", params: { schema: "public", name: "", action: { op: "create", columns: [] } },
|
||||
});
|
||||
expect(useDbViewerStore.getState().tabs.filter((x) => x.form?.kind === "table").length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType } from "../lib/types";
|
||||
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
|
||||
import { getDatabases, getSchemas, getTables } from "../lib/commands";
|
||||
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
|
||||
|
||||
@@ -120,6 +120,7 @@ interface DbViewerState {
|
||||
indexes: IndexInfo[] | null;
|
||||
setSchemaTreeLoading: (loading: boolean) => void;
|
||||
constraints: ConstraintInfo[] | null;
|
||||
roles: RoleInfo[];
|
||||
|
||||
// Actions
|
||||
openTab: (schema: string, table: string, forceNew?: boolean) => void;
|
||||
@@ -182,6 +183,7 @@ interface DbViewerState {
|
||||
setExtensions: (extensions: ExtensionInfo[]) => void;
|
||||
setIndexes: (indexes: IndexInfo[]) => void;
|
||||
setConstraints: (constraints: ConstraintInfo[]) => void;
|
||||
setRoles: (roles: RoleInfo[]) => void;
|
||||
stageCellEdit: (input: {
|
||||
tabId: string;
|
||||
schema: string;
|
||||
@@ -223,6 +225,7 @@ const initialState = {
|
||||
extensions: null as ExtensionInfo[] | null,
|
||||
indexes: null as IndexInfo[] | null,
|
||||
constraints: null as ConstraintInfo[] | null,
|
||||
roles: [] as RoleInfo[],
|
||||
schemaTreeLoading: false,
|
||||
};
|
||||
|
||||
@@ -577,6 +580,7 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
|
||||
setExtensions: (extensions) => set({ extensions }),
|
||||
setIndexes: (indexes) => set({ indexes }),
|
||||
setConstraints: (constraints) => set({ constraints }),
|
||||
setRoles: (roles) => set({ roles }),
|
||||
setSchemaTreeLoading: (loading) => set({ schemaTreeLoading: loading }),
|
||||
|
||||
stageCellEdit: (input) => {
|
||||
|
||||
Reference in New Issue
Block a user