v0.5.0 — Grid Interactivity, Home Polish, Deeper PostgreSQL (#8)
* chore: bump version to 0.5.0 (Task 1) * feat(store): v7 migration — favorites + recent_connections (Task 2) * feat(models): add favorite to Connection + Store CRUD (Task 3) * feat(types): ColumnInfo editability + IndexInfo/ConstraintInfo/RecentConnection (Task 4) * chore: bump version to 0.5.0 (Task 1) — lockfile * feat(commands): typed wrappers for favorites/recents/indexes/constraints (Task 5) * feat(db): PG indexes/constraints queries + matview UNION in tables (Task 6) * feat(db): get_table_data editability flags + ctid/rowid locator (Task 7) * feat(db): execute_change no-PK locator guard + affected-count check (Task 8) * feat(db): preserve bigint precision as string on PG read path (Task 9) * feat(db): get_indexes / get_constraints commands (Task 10) * feat(store): favorites + recents Store methods (Task 11) * feat(commands): favorites/recents IPC + register indexes/constraints (Task 12) * feat(store): connectionStore favorites/recents/move-selection (Task 13) * feat(lib): recent-connections pure helpers (Task 14) * feat(store): dbViewerStore indexes/constraints + stageCellEdit (Task 15) * feat(grid): pure editability + filter-operator + cell transform (Task 16) * feat(grid): pure keyboard-nav helper (Task 17) * feat(grid): CellEditor inline editor (Task 18) * feat(grid): CellContextMenu + RowDetailDrawer (Task 19) * feat(grid): focus model + keyboard nav + inline edit + copy + context menu (Task 20) * feat(db-viewer): FilterBuilder drag-and-drop + type-aware operators (Task 21) * feat(db-viewer): ObjectExplorer indexes/constraints/procedures + matview icon (Task 22) * feat(home): ConnectionCard favorite star + on-demand StatusDot (Task 23) * feat(home): move-to-folder + recents strip + status wiring (Task 24) * feat(db-viewer): grid wiring + matview read-only + post-commit refetch (Task 25) * docs: v0.5.0 status + roadmap updates (Task 26) * polish: empty/error/loading states for v0.5.0 surfaces (Task 28) * feat(home): duplicateConnection + useConnectionStatus hook, drop StatusDot (FEAT-A) * feat(home): connection card kebab menu — favorite/test/manage (FEAT-B) * fix(home): populate server_version/latency_ms in test_connection + clean online display * style(home): swap grab handle and kebab positions on connection card * style(home): nudge kebab menu to right-1 * style(home): nudge kebab menu to right-0.5 * feat(home): Escape clears + exits focused search * feat(grid): context-menu View/Select Row, outside-click close, Esc cancels edit, FK reference (GRID-A) * feat(grid): smart CellEditor — enum select, FK searchable dropdown, textarea height (GRID-B) * feat(grid): enums + FK options fed into CellEditor (GRID-C) * feat(grid): FK dropdown display-column labels + placeholder + empty state * fix(grid): FK dropdown renders as fixed overlay to avoid clipping * fix(grid): portal FK dropdown to body + FK reference icon instead of click-to-open * style(grid): move FK reference icon to the start of the cell * feat(grid): optimistic staged cell values + pending dot, cleared on refetch * fix(grid): queue is source of truth for staged values — value diff, Clear All clears dots, same-cell edits replace * fix(db-viewer): type getLocator for staged-value matching * test(db-viewer): unit-test deriveStagedValues; fix activeTab null guard * fix(db-viewer): pass table prop to VirtualDataGrid — staged edits now carry the table name * test(db-viewer): use index access instead of .at() for TS lib target * feat(grid): FK dropdown options as one-row column cells (FK-reference style, cap 5) * style(grid): FK dropdown — values only, fixed 360px width, FK-viewer surface styling * style(grid): harden FK dropdown minWidth to 360px * style(grid): cap FK dropdown cells at 3 * style(grid): cap FK dropdown cells at 4 * fix(grid): pending dot clears on commit — values stay until refetch * fix(db): deserialize pg_attribute char columns as i8 — no more panic on get_table_data * docs: reflect grid interactivity, smart editors, optimistic queue, FK reference, kebab status
This commit is contained in:
@@ -34,6 +34,22 @@ describe("ChangesQueuePanel", () => {
|
||||
expect(screen.getByText(/public.users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the old → new value diff on update cards", () => {
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "update",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
primaryKey: { id: 1 },
|
||||
oldData: { name: "Bob" },
|
||||
newData: { name: "Alice" },
|
||||
description: "Update row in users",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel />);
|
||||
// the diff renders old (struck) → new (accent) as separate spans
|
||||
expect(screen.getByText(/name: Bob/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("revert removes the change from the queue", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.getState().addChange({
|
||||
@@ -156,6 +172,21 @@ describe("ChangesQueuePanel", () => {
|
||||
expect(useDbViewerStore.getState().changesQueue).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("calls onCommitted after a successful commit cycle", async () => {
|
||||
const onCommitted = vi.fn();
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "t",
|
||||
newData: { a: 1 },
|
||||
description: "Insert row into t",
|
||||
} as any);
|
||||
render(<ChangesQueuePanel onCommitted={onCommitted} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
await waitFor(() => expect(onCommitted).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("shows a green check on committed changes after Commit All", async () => {
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any);
|
||||
|
||||
@@ -30,6 +30,22 @@ function formatChangeLabel(change: QueueItem): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the old → new value change for update queue items. */
|
||||
function formatValueDiff(change: QueueItem): string | null {
|
||||
if (change.type !== "update" || !change.newData) return null;
|
||||
const colName = Object.keys(change.newData)[0];
|
||||
if (!colName) return null;
|
||||
const oldVal =
|
||||
change.oldData && change.oldData[colName] !== undefined
|
||||
? String(change.oldData[colName])
|
||||
: "NULL";
|
||||
const newVal =
|
||||
change.newData[colName] === null || change.newData[colName] === undefined
|
||||
? "NULL"
|
||||
: String(change.newData[colName]);
|
||||
return `${colName}: ${oldVal} → ${newVal}`;
|
||||
}
|
||||
|
||||
function capitalizeType(type: string) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
@@ -39,7 +55,7 @@ function tableRef(change: QueueItem): string {
|
||||
return change.table ?? "-";
|
||||
}
|
||||
|
||||
export function ChangesQueuePanel() {
|
||||
export function ChangesQueuePanel({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const changesQueue = useDbViewerStore((state) => state.changesQueue);
|
||||
const removeChange = useDbViewerStore((state) => state.removeChange);
|
||||
const clearChanges = useDbViewerStore((state) => state.clearChanges);
|
||||
@@ -84,6 +100,7 @@ export function ChangesQueuePanel() {
|
||||
}
|
||||
|
||||
if (committedCount > 0) {
|
||||
onCommitted?.();
|
||||
notify(`${committedCount} change(s) committed`, "success");
|
||||
}
|
||||
|
||||
@@ -183,6 +200,17 @@ export function ChangesQueuePanel() {
|
||||
<div className="mt-1 text-xs text-text-muted truncate">
|
||||
{formatChangeLabel(change)}
|
||||
</div>
|
||||
{formatValueDiff(change) && (
|
||||
<div className="mt-0.5 font-mono text-xs text-text">
|
||||
<span className="text-text-muted line-through">
|
||||
{formatValueDiff(change)!.split(" → ")[0]}
|
||||
</span>
|
||||
<span className="mx-1 text-text-muted">→</span>
|
||||
<span className="text-accent">
|
||||
{formatValueDiff(change)!.split(" → ")[1]}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { DbViewerScreen } from "./DbViewerScreen";
|
||||
import {
|
||||
DbViewerScreen,
|
||||
deriveStagedValues,
|
||||
derivePendingCellKeys,
|
||||
pickDisplayColumn,
|
||||
} from "./DbViewerScreen";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
vi.mock("../../hooks/useDbConnection", () => ({
|
||||
@@ -12,9 +18,17 @@ vi.mock("../../hooks/useDbConnection", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
useVirtualizer: () => ({
|
||||
getVirtualItems: () => [],
|
||||
getTotalSize: () => 0,
|
||||
useVirtualizer: ({ count }: any) => ({
|
||||
getVirtualItems: () =>
|
||||
count > 0
|
||||
? Array.from({ length: count }, (_, i) => ({
|
||||
key: i,
|
||||
index: i,
|
||||
start: i * 36,
|
||||
size: 36,
|
||||
}))
|
||||
: [],
|
||||
getTotalSize: () => count * 36,
|
||||
measureElement: () => {},
|
||||
}),
|
||||
}));
|
||||
@@ -64,6 +78,8 @@ const mockQueryResult = {
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1]],
|
||||
@@ -99,6 +115,126 @@ describe("DbViewerScreen", () => {
|
||||
expect(screen.getByLabelText(/home/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("full flow: editing a cell shows the staged value + pending dot in the grid", async () => {
|
||||
const store = useDbViewerStore.getState();
|
||||
store.openTab("public", "users");
|
||||
const tabId = useDbViewerStore.getState().activeTabId!;
|
||||
store.setTabData(tabId, {
|
||||
columns: [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
],
|
||||
rows: [[1, "Alice"]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
const cell = await waitFor(() => screen.getByText("Alice"));
|
||||
fireEvent.click(cell);
|
||||
fireEvent.keyDown(cell, { key: "Enter" });
|
||||
// the editor's textarea is the last textbox (toolbar filter input is first)
|
||||
const textboxes = screen.getAllByRole("textbox");
|
||||
const input = textboxes[textboxes.length - 1]!;
|
||||
fireEvent.change(input, { target: { value: "Alicia" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
// staged change carries the correct table (was the root-cause bug)
|
||||
const staged = useDbViewerStore.getState().changesQueue[0];
|
||||
expect(staged?.table).toBe("users");
|
||||
expect(staged?.schema).toBe("public");
|
||||
// grid cell shows the optimistic value + the pending dot (2nd match is the queue panel diff)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
expect(screen.getByTestId("pending-edit-dot")).toBeInTheDocument();
|
||||
// committing the change clears the pending dot but keeps the value until refetch
|
||||
act(() => {
|
||||
useDbViewerStore
|
||||
.getState()
|
||||
.markChangeCommitted(
|
||||
useDbViewerStore.getState().changesQueue[0].id,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("pending-edit-dot")).toBeNull();
|
||||
});
|
||||
expect(screen.getAllByText("Alicia").length).toBeGreaterThanOrEqual(2);
|
||||
// clearing the queue clears the optimistic display
|
||||
act(() => {
|
||||
useDbViewerStore.getState().clearChanges();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Alicia")).toBeNull();
|
||||
});
|
||||
|
||||
it("deriveStagedValues maps queue updates to optimistic cell values", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": "Alicia",
|
||||
});
|
||||
});
|
||||
|
||||
it("deriveStagedValues ignores failed/other-table changes and handles NULL", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: null },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "orders",
|
||||
primaryKey: { id: 1 }, oldData: { x: 1 }, newData: { x: 2 },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-3", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "X" },
|
||||
status: "failed" as const, error: "boom", createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(deriveStagedValues(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("derivePendingCellKeys only includes pending updates (dot clears on commit)", () => {
|
||||
const queue = [
|
||||
{
|
||||
id: "ch-1", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 1 }, oldData: { name: "Alice" }, newData: { name: "Alicia" },
|
||||
status: "pending" as const, createdAt: 0,
|
||||
},
|
||||
{
|
||||
id: "ch-2", type: "update" as const, sql: "", schema: "public", table: "users",
|
||||
primaryKey: { id: 2 }, oldData: { name: "Bob" }, newData: { name: "Bobby" },
|
||||
status: "committed" as const, createdAt: 0,
|
||||
},
|
||||
];
|
||||
const rows: unknown[][] = [[1, "Alice"], [2, "Bob"]];
|
||||
const loc = (r: unknown[]) => ({ id: r[0] });
|
||||
expect(derivePendingCellKeys(queue as any, "public", "users", rows, loc)).toEqual({
|
||||
"0:name": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the New Query button", () => {
|
||||
render(
|
||||
<DbViewerScreen
|
||||
@@ -459,4 +595,230 @@ describe("DbViewerScreen", () => {
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables Insert Row for a materialized-view tab", async () => {
|
||||
useDbViewerStore.setState({
|
||||
tables: [
|
||||
{ name: "mat_users", schema: "public", table_type: "MATERIALIZED VIEW" },
|
||||
],
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-mv",
|
||||
schema: "public",
|
||||
table: "mat_users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: mockQueryResult,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-mv",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("refetches the active tab after a successful Commit All", async () => {
|
||||
useUiStore.setState({ activeConnectionId: "c1" });
|
||||
vi.spyOn(commands, "executeChange").mockResolvedValue(undefined);
|
||||
const getTableData = vi
|
||||
.spyOn(commands, "getTableData")
|
||||
.mockResolvedValue({
|
||||
columns: mockQueryResult.columns,
|
||||
rows: [[2]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
|
||||
useDbViewerStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: mockQueryResult,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-1",
|
||||
changesQueue: [],
|
||||
changesPanelExpanded: true,
|
||||
});
|
||||
useDbViewerStore.getState().addChange({
|
||||
type: "insert",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
newData: { id: 2, name: "Alice" },
|
||||
description: "Insert row into users",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /commit all/i }));
|
||||
|
||||
await waitFor(() => expect(getTableData).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("fetches enum labels and FK reference rows for the active table tab", async () => {
|
||||
const getEnums = vi
|
||||
.spyOn(commands, "getEnums")
|
||||
.mockResolvedValue([
|
||||
{
|
||||
name: "user_role",
|
||||
schema: "public",
|
||||
labels: ["admin", "user"],
|
||||
},
|
||||
]);
|
||||
const getTableData = vi
|
||||
.spyOn(commands, "getTableData")
|
||||
.mockResolvedValue({
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: false,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1], [2]],
|
||||
total_rows: 2,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any);
|
||||
|
||||
useDbViewerStore.setState({
|
||||
tabs: [
|
||||
{
|
||||
id: "tab-1",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
loading: false,
|
||||
error: null,
|
||||
data: {
|
||||
columns: [
|
||||
{
|
||||
name: "id",
|
||||
data_type: "integer",
|
||||
is_nullable: false,
|
||||
is_pk: true,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: false,
|
||||
is_generated: false,
|
||||
},
|
||||
{
|
||||
name: "user_id",
|
||||
data_type: "integer",
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: true,
|
||||
fk_ref: ["users", "id"],
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
data_type: "user_role",
|
||||
is_nullable: true,
|
||||
is_pk: false,
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
],
|
||||
rows: [[1, 2, "admin"]],
|
||||
total_rows: 1,
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
} as any,
|
||||
filterRules: [],
|
||||
sortRules: [],
|
||||
hiddenColumns: [],
|
||||
smartSortApplied: true,
|
||||
tabType: "table",
|
||||
},
|
||||
],
|
||||
activeTabId: "tab-1",
|
||||
});
|
||||
|
||||
render(
|
||||
<DbViewerScreen
|
||||
connectionId="c1"
|
||||
onHome={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enum labels are fetched for the tab's schema (cached per schema).
|
||||
await waitFor(() =>
|
||||
expect(getEnums).toHaveBeenCalledWith("c1", "public"),
|
||||
);
|
||||
// FK reference rows are fetched from the referenced table (page 1, 50).
|
||||
await waitFor(() =>
|
||||
expect(getTableData).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
"public",
|
||||
"users",
|
||||
1,
|
||||
50,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("pickDisplayColumn prefers name-like columns over the ref column", () => {
|
||||
const cols = [
|
||||
{ name: "id", data_type: "integer" },
|
||||
{ name: "email", data_type: "text" },
|
||||
{ name: "name", data_type: "text" },
|
||||
];
|
||||
expect(pickDisplayColumn(cols, "id")).toBe("name");
|
||||
expect(pickDisplayColumn(cols, "id", "email")).toBe("email");
|
||||
});
|
||||
|
||||
it("pickDisplayColumn falls back to the ref column when nothing is name-like", () => {
|
||||
const cols = [{ name: "id", data_type: "integer" }];
|
||||
expect(pickDisplayColumn(cols, "id")).toBe("id");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import { TableTree } from "./TableTree";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||
import { TableControls } from "./TableControls";
|
||||
import { EditConnectionModal } from "./EditConnectionModal";
|
||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||
@@ -29,6 +30,124 @@ import { SchemaVisualizerPage } from "./SchemaVisualizerPage";
|
||||
import { QueriesPanel } from "../queries/QueriesPanel";
|
||||
import { useQueryStore } from "../../stores/queryStore";
|
||||
import * as cmd from "../../lib/commands";
|
||||
import type { EnumInfo } from "../../lib/types";
|
||||
import type { FkOption } from "../grid/CellEditor";
|
||||
import type { QueueItem } from "../../stores/dbViewerStore";
|
||||
|
||||
/**
|
||||
* Derive optimistic staged cell values from the changes queue for a table
|
||||
* tab, keyed `${rowIndex}:${colName}` → the staged value (null = NULL).
|
||||
* Rows are matched to queue items via the row locator (PK or ctid/rowid).
|
||||
* Pending + committed updates count (survive until refetch); Clear All
|
||||
* empties the queue so the optimistic display vanishes.
|
||||
*/
|
||||
export function deriveStagedValues(
|
||||
changesQueue: QueueItem[],
|
||||
schema: string,
|
||||
table: string,
|
||||
rows: unknown[][],
|
||||
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||
): Record<string, string | null> {
|
||||
const map: Record<string, string | null> = {};
|
||||
const updates = changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "update" &&
|
||||
(c.status === "pending" || c.status === "committed") &&
|
||||
c.schema === schema &&
|
||||
c.table === table &&
|
||||
c.primaryKey &&
|
||||
c.newData,
|
||||
);
|
||||
if (updates.length === 0) return map;
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const loc = getLocator(row);
|
||||
for (const c of updates) {
|
||||
const pk = c.primaryKey!;
|
||||
const matches = Object.entries(pk).every(
|
||||
([k, v]) => String(loc[k]) === String(v),
|
||||
);
|
||||
if (!matches) continue;
|
||||
const colName = Object.keys(c.newData!)[0];
|
||||
if (!colName) continue;
|
||||
map[`${rowIdx}:${colName}`] =
|
||||
(c.newData![colName] as string | null) ?? null;
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys of cells with a PENDING update only — drives the amber pending dot.
|
||||
* Once a change is committed the dot clears even though the optimistic value
|
||||
* (from `deriveStagedValues`) stays until the refetch lands.
|
||||
*/
|
||||
export function derivePendingCellKeys(
|
||||
changesQueue: QueueItem[],
|
||||
schema: string,
|
||||
table: string,
|
||||
rows: unknown[][],
|
||||
getLocator: (row: unknown[]) => Record<string, unknown>,
|
||||
): Record<string, boolean> {
|
||||
const keys: Record<string, boolean> = {};
|
||||
const updates = changesQueue.filter(
|
||||
(c) =>
|
||||
c.type === "update" &&
|
||||
c.status === "pending" &&
|
||||
c.schema === schema &&
|
||||
c.table === table &&
|
||||
c.primaryKey &&
|
||||
c.newData,
|
||||
);
|
||||
if (updates.length === 0) return keys;
|
||||
rows.forEach((row, rowIdx) => {
|
||||
const loc = getLocator(row);
|
||||
for (const c of updates) {
|
||||
const pk = c.primaryKey!;
|
||||
const matches = Object.entries(pk).every(
|
||||
([k, v]) => String(loc[k]) === String(v),
|
||||
);
|
||||
if (!matches) continue;
|
||||
const colName = Object.keys(c.newData!)[0];
|
||||
if (!colName) continue;
|
||||
keys[`${rowIdx}:${colName}`] = true;
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a human-friendly display column for FK option labels from the
|
||||
* referenced table's columns: prefer name-like columns, else the first
|
||||
* text-ish column that isn't the ref column, else the ref column itself.
|
||||
*/
|
||||
export function pickDisplayColumn(
|
||||
columns: { name: string; data_type: string }[],
|
||||
refCol: string,
|
||||
preferred?: string,
|
||||
): string {
|
||||
if (preferred && columns.some((c) => c.name === preferred)) return preferred;
|
||||
const nameLike = [
|
||||
"name",
|
||||
"title",
|
||||
"label",
|
||||
"username",
|
||||
"email",
|
||||
"full_name",
|
||||
"display_name",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"description",
|
||||
];
|
||||
for (const n of nameLike) {
|
||||
if (columns.some((c) => c.name === n)) return n;
|
||||
}
|
||||
const textish = columns.find(
|
||||
(c) =>
|
||||
c.name !== refCol &&
|
||||
/text|char|name|uuid/i.test(c.data_type),
|
||||
);
|
||||
return textish ? textish.name : refCol;
|
||||
}
|
||||
|
||||
export interface DbViewerScreenProps {
|
||||
connectionId: string;
|
||||
@@ -48,6 +167,7 @@ export function DbViewerScreen({
|
||||
const [queriesPanelWidth, setQueriesPanelWidth] = useState(280);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||
const connections = useConnectionStore((s) => s.connections);
|
||||
@@ -84,6 +204,19 @@ export function DbViewerScreen({
|
||||
const filterRules = activeTab?.filterRules ?? [];
|
||||
const sortRules = activeTab?.sortRules ?? [];
|
||||
const hiddenColumns = new Set(activeTab?.hiddenColumns ?? []);
|
||||
const changesQueue = useDbViewerStore((s) => s.changesQueue);
|
||||
const tables = useDbViewerStore((s) => s.tables);
|
||||
const stageCellEdit = useDbViewerStore((s) => s.stageCellEdit);
|
||||
|
||||
const isMatview =
|
||||
activeTab && activeTab.tabType === "table"
|
||||
? tables.some(
|
||||
(t) =>
|
||||
t.schema === activeTab.schema &&
|
||||
t.name === activeTab.table &&
|
||||
t.table_type === "MATERIALIZED VIEW",
|
||||
)
|
||||
: false;
|
||||
|
||||
const setTabData = useDbViewerStore((s) => s.setTabData);
|
||||
const setTabError = useDbViewerStore((s) => s.setTabError);
|
||||
@@ -96,6 +229,18 @@ export function DbViewerScreen({
|
||||
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
|
||||
const fetchingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// CellEditor options for the active table tab: PG enum labels (cached per
|
||||
// connection+schema) + FK reference rows (page 1, 50 per FK column).
|
||||
const [editorOptions, setEditorOptions] = useState<{
|
||||
enums: Record<string, string[]>;
|
||||
fks: Record<string, FkOption[]>;
|
||||
fkPlaceholders: Record<string, string>;
|
||||
} | null>(null);
|
||||
const enumCacheRef = useRef<Map<string, EnumInfo[]>>(new Map());
|
||||
// Key identifying the (connection, tab, schema) the options were fetched for;
|
||||
// guards against refetching on every render while data updates in place.
|
||||
const editorOptionsKeyRef = useRef<string>("");
|
||||
|
||||
const fetchData = useCallback(
|
||||
async (tab: NonNullable<typeof activeTab>) => {
|
||||
if (fetchingRef.current.has(tab.id)) return;
|
||||
@@ -139,6 +284,31 @@ export function DbViewerScreen({
|
||||
}
|
||||
}
|
||||
|
||||
const handleStageEdit = useCallback(
|
||||
(payload: {
|
||||
type: "update";
|
||||
schema: string;
|
||||
table: string;
|
||||
primaryKey: Record<string, unknown>;
|
||||
oldData: Record<string, unknown>;
|
||||
newData: Record<string, unknown>;
|
||||
}) => {
|
||||
if (!activeTab) return;
|
||||
const { type: _, ...rest } = payload;
|
||||
stageCellEdit({ tabId: activeTab.id, ...rest });
|
||||
},
|
||||
[activeTab, stageCellEdit],
|
||||
);
|
||||
|
||||
const handleOpenRowDetail = useCallback((rowIndex: number) => {
|
||||
setRowDetailIdx(rowIndex);
|
||||
}, []);
|
||||
|
||||
const handleCommitted = useCallback(() => {
|
||||
if (!activeTab) return;
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Read the active tab from the store directly so the Monaco keybinding action
|
||||
// (which keeps the first onRun closure) always sees the latest query text.
|
||||
const handleRunQuery = useCallback(() => {
|
||||
@@ -243,6 +413,108 @@ export function DbViewerScreen({
|
||||
fetchData(activeTab);
|
||||
}, [activeTab, fetchData]);
|
||||
|
||||
// Feed the grid's CellEditor with enum labels + FK reference rows for the
|
||||
// active table tab. Fetched once per tab/schema (enums additionally cached
|
||||
// per connection+schema across tabs); a failed fetch for one FK column is
|
||||
// skipped without breaking the tab. Never refetches on in-place data updates.
|
||||
useEffect(() => {
|
||||
const key =
|
||||
activeTab && activeTab.tabType === "table" && activeTab.data
|
||||
? `${connectionId}:${activeTab.id}:${activeTab.schema}`
|
||||
: "";
|
||||
if (key === editorOptionsKeyRef.current) return;
|
||||
editorOptionsKeyRef.current = key;
|
||||
if (!key || !activeTab || !activeTab.data) {
|
||||
setEditorOptions(null);
|
||||
return;
|
||||
}
|
||||
const cols = activeTab.data.columns;
|
||||
const tab = activeTab;
|
||||
void (async () => {
|
||||
const enums: Record<string, string[]> = {};
|
||||
const fks: Record<string, FkOption[]> = {};
|
||||
const fkPlaceholders: Record<string, string> = {};
|
||||
|
||||
// PG enums: fetched once per connection+schema, reused across tabs.
|
||||
const cacheKey = `${connectionId}:${tab.schema}`;
|
||||
let enumList = enumCacheRef.current.get(cacheKey);
|
||||
if (!enumList) {
|
||||
try {
|
||||
enumList = await cmd.getEnums(connectionId, tab.schema);
|
||||
enumCacheRef.current.set(cacheKey, enumList);
|
||||
} catch {
|
||||
enumList = [];
|
||||
}
|
||||
}
|
||||
for (const col of cols) {
|
||||
const match = enumList.find((e) => e.name === col.data_type);
|
||||
if (match) enums[col.name] = match.labels;
|
||||
}
|
||||
|
||||
// FK options: referenced rows (page 1, 50) per FK column.
|
||||
const fkCols = cols.filter((c) => c.is_fk && c.fk_ref);
|
||||
await Promise.all(
|
||||
fkCols.map(async (col) => {
|
||||
const [refTable, refCol] = col.fk_ref!;
|
||||
try {
|
||||
const result = await cmd.getTableData(
|
||||
connectionId,
|
||||
tab.schema,
|
||||
refTable,
|
||||
1,
|
||||
50,
|
||||
);
|
||||
const refIdx = result.columns.findIndex(
|
||||
(c) => c.name === refCol,
|
||||
);
|
||||
if (refIdx >= 0) {
|
||||
const displayCol = pickDisplayColumn(
|
||||
result.columns,
|
||||
refCol,
|
||||
);
|
||||
const displayIdx =
|
||||
displayCol === refCol
|
||||
? refIdx
|
||||
: result.columns.findIndex(
|
||||
(c) => c.name === displayCol,
|
||||
);
|
||||
fks[col.name] = result.rows.map((row) => {
|
||||
const refValue = String(row[refIdx]);
|
||||
const dispValue =
|
||||
displayIdx >= 0 && displayIdx !== refIdx
|
||||
? String(row[displayIdx])
|
||||
: "";
|
||||
return {
|
||||
value: refValue,
|
||||
label:
|
||||
dispValue && dispValue !== refValue
|
||||
? `${refValue} — ${dispValue}`
|
||||
: refValue,
|
||||
// Referenced-row cells for the FK-reference-style
|
||||
// one-row dropdown (first 5 columns).
|
||||
cells: result.columns
|
||||
.slice(0, 5)
|
||||
.map((c, i) => ({
|
||||
name: c.name,
|
||||
value: String(row[i] ?? ""),
|
||||
})),
|
||||
};
|
||||
});
|
||||
fkPlaceholders[col.name] = `Search ${refTable}…`;
|
||||
}
|
||||
} catch {
|
||||
// Skip this FK column; the cell keeps the plain editor.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Apply only if no newer fetch superseded this one (tab/schema switched).
|
||||
if (editorOptionsKeyRef.current === key) {
|
||||
setEditorOptions({ enums, fks, fkPlaceholders });
|
||||
}
|
||||
})();
|
||||
}, [activeTab, connectionId]);
|
||||
|
||||
// Smart default sort: apply once when data first loads for a tab
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
@@ -630,9 +902,48 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
const activeTable = activeTab?.table ?? "";
|
||||
|
||||
function renderQueryWorkspace() {
|
||||
const getLocator = (row: unknown[]) => {
|
||||
const pkCol = columns.find((c) => c.is_pk);
|
||||
if (pkCol) {
|
||||
const pkIndex = columns.findIndex(
|
||||
(c) => c.name === pkCol.name,
|
||||
);
|
||||
return { [pkCol.name]: row[pkIndex] };
|
||||
}
|
||||
const dbType = currentConnection?.db_type ?? "postgresql";
|
||||
const locatorIndex = columns.length;
|
||||
if (dbType === "sqlite") {
|
||||
return { rowid: row[locatorIndex] };
|
||||
}
|
||||
return { ctid: row[locatorIndex] };
|
||||
};
|
||||
|
||||
// Staged cell values derived from the changes queue (single source of
|
||||
// truth): keyed `${rowIndex}:${colName}` → optimistic value. Pending +
|
||||
// committed updates survive until refetch; Clear All empties the queue
|
||||
// so the optimistic display and pending dots vanish immediately.
|
||||
const stagedValues = activeTab?.data
|
||||
? deriveStagedValues(
|
||||
changesQueue,
|
||||
activeTab.schema,
|
||||
activeTab.table,
|
||||
activeTab.data.rows,
|
||||
getLocator,
|
||||
)
|
||||
: {};
|
||||
const pendingKeys = activeTab?.data
|
||||
? derivePendingCellKeys(
|
||||
changesQueue,
|
||||
activeTab.schema,
|
||||
activeTab.table,
|
||||
activeTab.data.rows,
|
||||
getLocator,
|
||||
)
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
|
||||
<TabBar />
|
||||
<TabBar onCommitted={handleCommitted} />
|
||||
{!activeTab ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-text-muted">
|
||||
{currentView === "queries" ? (
|
||||
@@ -794,16 +1105,29 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
)
|
||||
}
|
||||
variant="query"
|
||||
isMatview={isMatview}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows(
|
||||
(prev) => {
|
||||
@@ -934,16 +1258,29 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
onClearSelection={() =>
|
||||
setSelectedRows(new Set())
|
||||
}
|
||||
isMatview={isMatview}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<VirtualDataGrid
|
||||
connectionId={connectionId}
|
||||
schema={activeSchema}
|
||||
table={activeTable}
|
||||
rows={processedRows}
|
||||
columns={columns}
|
||||
hiddenColumns={hiddenColumns}
|
||||
selectedRows={selectedRows}
|
||||
dbType={currentConnection?.db_type ?? "postgresql"}
|
||||
tabType={activeTab?.tabType ?? "table"}
|
||||
getLocator={getLocator}
|
||||
onStageEdit={isMatview ? undefined : handleStageEdit}
|
||||
onOpenRowDetail={handleOpenRowDetail}
|
||||
readOnly={isMatview}
|
||||
enumValues={editorOptions?.enums}
|
||||
fkOptions={editorOptions?.fks}
|
||||
fkPlaceholders={editorOptions?.fkPlaceholders}
|
||||
stagedValues={stagedValues}
|
||||
pendingKeys={pendingKeys}
|
||||
onToggleRow={(rowIndex) => {
|
||||
setSelectedRows((prev) => {
|
||||
const next = new Set(
|
||||
@@ -978,6 +1315,16 @@ const onQueriesPanelResizeStart = useCallback(
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{rowDetailIdx !== null && activeTab?.data && (
|
||||
<RowDetailDrawer
|
||||
columns={activeTab.data.columns}
|
||||
row={activeTab.data.rows[rowDetailIdx]}
|
||||
onClose={() => setRowDetailIdx(null)}
|
||||
onCopy={(value) =>
|
||||
navigator.clipboard.writeText(value).catch(() => {})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
|
||||
const cols: ColumnInfo[] = [
|
||||
{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: false, is_generated: false },
|
||||
{ name: "name", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false },
|
||||
];
|
||||
|
||||
describe("FilterBuilder", () => {
|
||||
it("drops a column chip into the drop zone to create a rule with a type-aware operator", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FilterBuilder columns={cols} rules={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByText("name"));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ column: "name", operator: "contains" }),
|
||||
]);
|
||||
});
|
||||
it("removing a rule calls onChange without it", () => {
|
||||
const onChange = vi.fn();
|
||||
const rules = [{ id: "r1", column: "name", operator: "contains" as const, value: "Al" }];
|
||||
render(<FilterBuilder columns={cols} rules={rules} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByLabelText("Remove filter name"));
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { DndContext, useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { X } from "lucide-react";
|
||||
import { defaultFilterOperator } from "../grid/gridEditability";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
import type { FilterRule, FilterOperator } from "../../stores/dbViewerStore";
|
||||
|
||||
interface Props {
|
||||
columns: ColumnInfo[];
|
||||
rules: FilterRule[];
|
||||
onChange: (rules: FilterRule[]) => void;
|
||||
}
|
||||
|
||||
function Chip({ col, onAdd }: { col: ColumnInfo; onAdd: () => void }) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: `col-${col.name}`,
|
||||
data: { column: col },
|
||||
});
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={onAdd}
|
||||
className={`px-2 py-1 text-xs rounded border border-border bg-surface text-text hover:border-accent ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{col.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const OPERATORS: FilterOperator[] = [
|
||||
"eq",
|
||||
"neq",
|
||||
"contains",
|
||||
"starts",
|
||||
"ends",
|
||||
"gt",
|
||||
"lt",
|
||||
"null",
|
||||
"notnull",
|
||||
];
|
||||
|
||||
export function FilterBuilder({ columns, rules, onChange }: Props) {
|
||||
const [val, setVal] = useState<Record<string, string>>({});
|
||||
|
||||
const addRule = (col: ColumnInfo) => {
|
||||
const op = defaultFilterOperator(col.data_type);
|
||||
onChange([
|
||||
...rules,
|
||||
{
|
||||
id: `f-${Date.now()}-${col.name}`,
|
||||
column: col.name,
|
||||
operator: op,
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "filter-dropzone" });
|
||||
|
||||
const remove = (id: string) => onChange(rules.filter((r) => r.id !== id));
|
||||
const update = (id: string, patch: Partial<FilterRule>) =>
|
||||
onChange(rules.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragEnd={(e) => {
|
||||
const id = e.active.id as string;
|
||||
const colName = id.replace(/^col-/, "");
|
||||
const col = columns.find((c) => c.name === colName);
|
||||
if (col && e.over?.id === "filter-dropzone") addRule(col);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{columns.map((c) => (
|
||||
<Chip key={c.name} col={c} onAdd={() => addRule(c)} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`min-h-[40px] border border-dashed rounded p-2 space-y-1 ${
|
||||
isOver ? "border-accent bg-surface" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{rules.length === 0 && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Drop columns here to add filters
|
||||
</span>
|
||||
)}
|
||||
{rules.map((r) => (
|
||||
<div key={r.id} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text font-semibold">{r.column}</span>
|
||||
<select
|
||||
value={r.operator}
|
||||
onChange={(e) =>
|
||||
update(r.id, { operator: e.target.value as FilterOperator })
|
||||
}
|
||||
className="bg-surface border border-border rounded px-1"
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!["null", "notnull"].includes(r.operator) && (
|
||||
<input
|
||||
value={val[r.id] ?? r.value}
|
||||
onChange={(e) => {
|
||||
setVal({ ...val, [r.id]: e.target.value });
|
||||
update(r.id, { value: e.target.value });
|
||||
}}
|
||||
className="bg-surface border border-border rounded px-1 flex-1"
|
||||
placeholder="value"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove filter ${r.column}`}
|
||||
onClick={() => remove(r.id)}
|
||||
className="text-text-muted hover:text-red-400"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ObjectExplorerPage } from "./ObjectExplorerPage";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import * as commands from "../../lib/commands";
|
||||
|
||||
describe("ObjectExplorerPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useDbViewerStore.getState().reset();
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
});
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getConstraints").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getTriggers").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getSequences").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getEnums").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getExtensions").mockResolvedValue([]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("renders functions by default", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "add_one",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: ["int"],
|
||||
argument_names: ["x"],
|
||||
argument_modes: ["IN"],
|
||||
language: "sql",
|
||||
source: "SELECT $1 + 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("add_one(int)")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("functions type filters to kind === 'f'", async () => {
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "do_thing",
|
||||
schema: "public",
|
||||
return_type: "void",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN END",
|
||||
kind: "p",
|
||||
},
|
||||
{
|
||||
name: "calc",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "sql",
|
||||
source: "SELECT 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await waitFor(() => expect(screen.getByText("calc")).toBeInTheDocument());
|
||||
expect(screen.queryByText("do_thing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("indexes type fetches getIndexes and renders the index name + detail", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getIndexes = vi.spyOn(commands, "getIndexes").mockResolvedValue([
|
||||
{
|
||||
name: "idx_users_email",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
definition: "CREATE INDEX idx_users_email ON users USING btree (email);",
|
||||
is_unique: true,
|
||||
method: "btree",
|
||||
columns: ["email"],
|
||||
size_bytes: 8192,
|
||||
tablespace: null,
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("idx_users_email")).toBeInTheDocument(),
|
||||
);
|
||||
expect(getIndexes).toHaveBeenCalledWith("c1", "public");
|
||||
await user.click(screen.getByText("idx_users_email"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Index")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("btree").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("8192")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("constraints type fetches getConstraints and renders the constraint name + detail", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getConstraints = vi.spyOn(commands, "getConstraints").mockResolvedValue([
|
||||
{
|
||||
name: "chk_users_positive",
|
||||
schema: "public",
|
||||
table: "users",
|
||||
contype: "CHECK",
|
||||
definition: "CHECK (age > 0)",
|
||||
deferrable: false,
|
||||
validated: true,
|
||||
columns: ["age"],
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Constraints"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("chk_users_positive")).toBeInTheDocument(),
|
||||
);
|
||||
expect(getConstraints).toHaveBeenCalledWith("c1", "public");
|
||||
await user.click(screen.getByText("chk_users_positive"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Constraint")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("CHECK").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Deferrable")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No indexes found' when getIndexes returns []", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getIndexes").mockResolvedValue([]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("No indexes found")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows an error message when getIndexes rejects", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getIndexes").mockRejectedValue(new Error("boom"));
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Indexes"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("boom")).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("procedures type filters getFunctions to kind === 'p'", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(commands, "getFunctions").mockResolvedValue([
|
||||
{
|
||||
name: "do_thing",
|
||||
schema: "public",
|
||||
return_type: "void",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "plpgsql",
|
||||
source: "BEGIN END",
|
||||
kind: "p",
|
||||
},
|
||||
{
|
||||
name: "calc",
|
||||
schema: "public",
|
||||
return_type: "int",
|
||||
argument_types: [],
|
||||
argument_names: [],
|
||||
argument_modes: [],
|
||||
language: "sql",
|
||||
source: "SELECT 1",
|
||||
kind: "f",
|
||||
},
|
||||
]);
|
||||
render(<ObjectExplorerPage connectionId="c1" />);
|
||||
await user.click(screen.getByLabelText("Object type"));
|
||||
await user.click(screen.getByText("Procedures"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("do_thing")).toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByText("calc")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState, useMemo, useCallback, useRef, cloneElement } from "react";
|
||||
import {
|
||||
BookMarked,
|
||||
ChevronRight,
|
||||
FunctionSquare,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
SquareFunction,
|
||||
Tag,
|
||||
Puzzle,
|
||||
Search,
|
||||
@@ -19,6 +22,8 @@ import type {
|
||||
SequenceInfo,
|
||||
EnumInfo,
|
||||
ExtensionInfo,
|
||||
IndexInfo,
|
||||
ConstraintInfo,
|
||||
} from "../../lib/types";
|
||||
|
||||
export type ObjectType =
|
||||
@@ -26,7 +31,10 @@ export type ObjectType =
|
||||
| "triggers"
|
||||
| "sequences"
|
||||
| "enums"
|
||||
| "extensions";
|
||||
| "extensions"
|
||||
| "indexes"
|
||||
| "constraints"
|
||||
| "procedures";
|
||||
|
||||
interface ObjectExplorerPageProps {
|
||||
connectionId: string;
|
||||
@@ -38,6 +46,9 @@ const TYPE_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "Sequences",
|
||||
enums: "Enums",
|
||||
extensions: "Extensions",
|
||||
indexes: "Indexes",
|
||||
constraints: "Constraints",
|
||||
procedures: "Procedures",
|
||||
};
|
||||
|
||||
const OBJECT_TYPE_OPTIONS = (Object.keys(TYPE_LABELS) as ObjectType[]).map(
|
||||
@@ -50,8 +61,21 @@ const SINGULAR_LABELS: Record<ObjectType, string> = {
|
||||
sequences: "sequence",
|
||||
enums: "enum",
|
||||
extensions: "extension",
|
||||
indexes: "index",
|
||||
constraints: "constraint",
|
||||
procedures: "procedure",
|
||||
};
|
||||
|
||||
/** Natural plural for empty-state copy, derived from SINGULAR_LABELS with known irregulars mapped explicitly. */
|
||||
function emptyPlural(type: ObjectType): string {
|
||||
const singular = SINGULAR_LABELS[type];
|
||||
const irregulars: Record<string, string> = {
|
||||
index: "indexes",
|
||||
constraint: "constraints",
|
||||
};
|
||||
return irregulars[singular] ?? `${singular}s`;
|
||||
}
|
||||
|
||||
const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
functions: (
|
||||
<FunctionSquare size={14} className="text-text-muted shrink-0" />
|
||||
@@ -60,6 +84,9 @@ const ICONS: Record<ObjectType, React.ReactNode> = {
|
||||
sequences: <ListOrdered size={14} className="text-text-muted shrink-0" />,
|
||||
enums: <Tag size={14} className="text-text-muted shrink-0" />,
|
||||
extensions: <Puzzle size={14} className="text-text-muted shrink-0" />,
|
||||
indexes: <BookMarked size={14} className="text-text-muted shrink-0" />,
|
||||
constraints: <ListChecks size={14} className="text-text-muted shrink-0" />,
|
||||
procedures: <SquareFunction size={14} className="text-text-muted shrink-0" />,
|
||||
};
|
||||
|
||||
type AnyObject =
|
||||
@@ -67,7 +94,9 @@ type AnyObject =
|
||||
| TriggerInfo
|
||||
| SequenceInfo
|
||||
| EnumInfo
|
||||
| ExtensionInfo;
|
||||
| ExtensionInfo
|
||||
| IndexInfo
|
||||
| ConstraintInfo;
|
||||
|
||||
/** Build a unique key per item. Functions use their signature to disambiguate overloads. */
|
||||
function itemKey(item: AnyObject): string {
|
||||
@@ -487,105 +516,287 @@ function SyntaxCode({
|
||||
);
|
||||
}
|
||||
|
||||
function renderFunctionDetail(f: FunctionInfo) {
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Signature
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Returns
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{f.return_type || "void"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Language
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.language}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Kind
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.kind === "f" ? "Function" : "Procedure"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{f.schema}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{f.argument_names.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Arguments
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.argument_names.length} total
|
||||
</span>
|
||||
</div>
|
||||
{f.argument_names.map((name, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<div className="w-24 shrink-0">
|
||||
<span className="text-xs text-text-muted">
|
||||
{f.argument_modes?.[i] &&
|
||||
f.argument_modes[i] !==
|
||||
"IN" && (
|
||||
<span className="text-amber-400 font-medium mr-1">
|
||||
{f.argument_modes[i]}
|
||||
</span>
|
||||
)}
|
||||
#{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{name}
|
||||
</span>
|
||||
<span className="mx-2 text-border">:</span>
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{f.argument_types?.[i] || "unknown"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{f.source && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Source
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.language}
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode
|
||||
source={f.source}
|
||||
language={f.language}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDetail(type: ObjectType, item: AnyObject) {
|
||||
switch (type) {
|
||||
case "functions": {
|
||||
const f = item as FunctionInfo;
|
||||
case "functions":
|
||||
return renderFunctionDetail(item as FunctionInfo);
|
||||
case "procedures":
|
||||
return renderFunctionDetail(item as FunctionInfo);
|
||||
case "indexes": {
|
||||
const idx = item as IndexInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Signature
|
||||
Index
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Returns
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{f.return_type || "void"}
|
||||
<span className="text-sm text-text font-mono">
|
||||
{idx.table}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Language
|
||||
Method
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.language}
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{idx.method}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Kind
|
||||
Unique
|
||||
</span>
|
||||
<span className="text-sm text-text">
|
||||
{f.kind === "f" ? "Function" : "Procedure"}
|
||||
<span
|
||||
className={`text-sm ${idx.is_unique ? "text-emerald-400" : "text-text-muted"}`}
|
||||
>
|
||||
{idx.is_unique ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Schema
|
||||
Size
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{f.schema}
|
||||
{idx.size_bytes ?? "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{f.argument_names.length > 0 && (
|
||||
{idx.columns.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Arguments
|
||||
Columns
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.argument_names.length} total
|
||||
{idx.columns.length}
|
||||
</span>
|
||||
</div>
|
||||
{f.argument_names.map((name, i) => (
|
||||
{idx.columns.map((col, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<div className="w-24 shrink-0">
|
||||
<span className="text-xs text-text-muted">
|
||||
{f.argument_modes?.[i] &&
|
||||
f.argument_modes[i] !==
|
||||
"IN" && (
|
||||
<span className="text-amber-400 font-medium mr-1">
|
||||
{f.argument_modes[i]}
|
||||
</span>
|
||||
)}
|
||||
#{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{name}
|
||||
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="mx-2 text-border">:</span>
|
||||
<span className="text-sm text-text-muted font-mono">
|
||||
{f.argument_types?.[i] || "unknown"}
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{f.source && (
|
||||
{idx.definition && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Source
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{f.language}
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode
|
||||
source={f.source}
|
||||
language={f.language}
|
||||
/>
|
||||
<SyntaxCode source={idx.definition} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "constraints": {
|
||||
const c = item as ConstraintInfo;
|
||||
return (
|
||||
<div>
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Constraint
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Type
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{c.contype}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Table
|
||||
</span>
|
||||
<span className="text-sm text-text font-mono">
|
||||
{c.table}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-border flex flex-row">
|
||||
<div className="border-r border-border px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Deferrable
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${c.deferrable ? "text-amber-400" : "text-text-muted"}`}
|
||||
>
|
||||
{c.deferrable ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center flex-2">
|
||||
<span className="text-xs text-text-muted w-24 shrink-0">
|
||||
Validated
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm ${c.validated ? "text-emerald-400" : "text-text-muted"}`}
|
||||
>
|
||||
{c.validated ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.columns.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Columns
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
{c.columns.length}
|
||||
</span>
|
||||
</div>
|
||||
{c.columns.map((col, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b border-border px-4 py-2 flex items-center"
|
||||
>
|
||||
<span className="text-xs text-text-muted w-12 shrink-0 font-mono">
|
||||
#{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{col}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{c.definition && (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-[11px] font-semibold text-text-muted uppercase tracking-wider">
|
||||
Definition
|
||||
</span>
|
||||
<span className="text-[10px] text-text-subtle">
|
||||
SQL
|
||||
</span>
|
||||
</div>
|
||||
<SyntaxCode source={c.definition} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -867,10 +1078,15 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
if (type === "extensions") {
|
||||
result = await cmd.getExtensions(connectionId);
|
||||
} else if (type === "functions") {
|
||||
result = await cmd.getFunctions(
|
||||
result = (await cmd.getFunctions(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
)).filter((f) => f.kind === "f");
|
||||
} else if (type === "procedures") {
|
||||
result = (await cmd.getFunctions(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
)).filter((f) => f.kind === "p");
|
||||
} else if (type === "triggers") {
|
||||
result = await cmd.getTriggers(
|
||||
connectionId,
|
||||
@@ -886,6 +1102,16 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "indexes") {
|
||||
result = await cmd.getIndexes(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else if (type === "constraints") {
|
||||
result = await cmd.getConstraints(
|
||||
connectionId,
|
||||
currentSchema ?? undefined,
|
||||
);
|
||||
} else {
|
||||
result = [];
|
||||
}
|
||||
@@ -1087,11 +1313,9 @@ export function ObjectExplorerPage({ connectionId }: ObjectExplorerPageProps) {
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-muted">
|
||||
{items === null
|
||||
? `No ${label.toLowerCase()} found`
|
||||
: searchQuery
|
||||
? `No ${label.toLowerCase()} matching "${searchQuery}"`
|
||||
: `No ${label.toLowerCase()} found in ${currentSchema || "current schema"}`}
|
||||
{searchQuery
|
||||
? `No ${emptyPlural(type)} matching "${searchQuery}"`
|
||||
: `No ${emptyPlural(type)} found`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ListChecks, Play, Table2, Terminal, X } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { ChangesQueuePanel } from "./ChangesQueuePanel";
|
||||
|
||||
export function TabBar() {
|
||||
export function TabBar({ onCommitted }: { onCommitted?: () => void } = {}) {
|
||||
const tabs = useDbViewerStore((state) => state.tabs);
|
||||
const activeTabId = useDbViewerStore((state) => state.activeTabId);
|
||||
const closeTab = useDbViewerStore((state) => state.closeTab);
|
||||
@@ -129,7 +129,7 @@ export function TabBar() {
|
||||
</button>
|
||||
{changesPanelExpanded && (
|
||||
<div className="absolute right-0 top-full mt-1.5 z-30 w-[380px] max-w-[calc(100vw-2rem)] rounded-xl bg-surface border border-border shadow-lg overflow-hidden">
|
||||
<ChangesQueuePanel />
|
||||
<ChangesQueuePanel onCommitted={onCommitted} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ const columns = [
|
||||
is_fk: false,
|
||||
fk_ref: null,
|
||||
default_value: null,
|
||||
editable: true,
|
||||
is_generated: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -314,4 +316,19 @@ describe("TableControls", () => {
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the Insert Row button for a materialized view", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls({ isMatview: true });
|
||||
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the FilterBuilder inside the filter popover", () => {
|
||||
seed([makeTab()], "tab-1");
|
||||
renderControls();
|
||||
fireEvent.click(screen.getByLabelText(/column filters/i));
|
||||
expect(
|
||||
screen.getByText("Drop columns here to add filters"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
Columns, Check, ChevronLeft, ChevronRight, X, Trash2,
|
||||
ChevronDown, FileJson, FileText, Terminal,
|
||||
} from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useDbViewerStore, type FilterRule, type SortRule } from "../../stores/dbViewerStore";
|
||||
import { FilterBuilder } from "./FilterBuilder";
|
||||
import { Tooltip } from "../ui/Tooltip";
|
||||
import { exportData } from "../../lib/exportData";
|
||||
import type { ColumnInfo } from "../../lib/types";
|
||||
@@ -27,19 +28,6 @@ const EXPORT_FORMATS = [
|
||||
{ label: "Markdown", ext: "md" },
|
||||
] as const;
|
||||
|
||||
type FilterRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
operator: "eq" | "neq" | "contains" | "starts" | "ends" | "gt" | "lt" | "null" | "notnull";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type SortRule = {
|
||||
id: string;
|
||||
column: string;
|
||||
order: "asc" | "desc";
|
||||
};
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -136,6 +124,7 @@ function FilterModal({
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<FilterBuilder columns={columns} rules={rules} onChange={onChange} />
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center gap-1.5 mb-1.5">
|
||||
<select
|
||||
@@ -414,6 +403,8 @@ interface TableControlsProps {
|
||||
selectedRows: unknown[][];
|
||||
onClearSelection: () => void;
|
||||
defaultRefreshRate?: number;
|
||||
/** Hide data-modifying affordances (e.g. for materialized views). */
|
||||
isMatview?: boolean;
|
||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||
variant?: "table" | "query";
|
||||
}
|
||||
@@ -435,6 +426,7 @@ export function TableControls({
|
||||
selectedRows,
|
||||
onClearSelection,
|
||||
defaultRefreshRate = 0,
|
||||
isMatview = false,
|
||||
variant = "table",
|
||||
}: TableControlsProps) {
|
||||
const isQuery = variant === "query";
|
||||
@@ -631,17 +623,21 @@ export function TableControls({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Insert Row */}
|
||||
<Tooltip content="Insert row" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsertRow}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Insert row"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!isMatview && (
|
||||
<>
|
||||
{/* Insert Row */}
|
||||
<Tooltip content="Insert row" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsertRow}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 hover:bg-surface-raised hover:text-text transition-colors cursor-pointer"
|
||||
aria-label="Insert row"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{refreshControl}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("TableOverflowMenu", () => {
|
||||
|
||||
it("Export data calls exportData when rows and columns are provided", async () => {
|
||||
const spy = vi.spyOn(exportData, "exportData");
|
||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null }];
|
||||
const columns = [{ name: "id", data_type: "integer", is_nullable: false, is_pk: true, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }];
|
||||
render(<TableOverflowMenu schema="public" table="t" onOpenTab={() => "tab-1"} columns={columns} rows={[[1]]} />);
|
||||
fireEvent.click(screen.getByLabelText(/table options/i));
|
||||
fireEvent.click(screen.getByText(/export data \(csv\)/i));
|
||||
|
||||
@@ -23,6 +23,23 @@ describe("TableTree", () => {
|
||||
expect(screen.getByText("orders")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a distinct icon and label for materialized views", () => {
|
||||
useDbViewerStore.setState({
|
||||
schemas: ["public"],
|
||||
currentSchema: "public",
|
||||
tables: [
|
||||
{
|
||||
name: "mv_products",
|
||||
schema: "public",
|
||||
table_type: "MATERIALIZED VIEW" as any,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(<TableTree />);
|
||||
expect(screen.getByText("mv_products")).toBeInTheDocument();
|
||||
expect(screen.getByText("Materialized View")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a tab when table is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useDbViewerStore.setState({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, ChevronDown, Table2, Key, Type } from "lucide-react";
|
||||
import { ChevronRight, ChevronDown, Table2, Layers, Key, Type } from "lucide-react";
|
||||
import { useDbViewerStore } from "../../stores/dbViewerStore";
|
||||
import { useUiStore } from "../../stores/uiStore";
|
||||
import { TableOverflowMenu } from "./TableOverflowMenu";
|
||||
@@ -70,6 +70,9 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
const key = `${table.schema}.${table.name}`;
|
||||
const isExpanded = expanded.has(key);
|
||||
const cols = columnCache[key] ?? table.columns ?? [];
|
||||
const isMatView = table.table_type === "MATERIALIZED VIEW";
|
||||
const TypeIcon = isMatView ? Layers : Table2;
|
||||
const typeLabel = isMatView ? "Materialized View" : null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
@@ -90,10 +93,15 @@ export function TableTree({ searchQuery }: { searchQuery?: string }) {
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
</button>
|
||||
<Table2 size={14} className="text-text-muted" />
|
||||
<TypeIcon size={14} className="text-text-muted" />
|
||||
<span className="flex-1 text-left text-sm text-text group-hover:text-accent truncate">
|
||||
{table.name}
|
||||
</span>
|
||||
{typeLabel && (
|
||||
<span className="text-[10px] text-text-subtle shrink-0">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<TableOverflowMenu
|
||||
schema={table.schema}
|
||||
|
||||
Reference in New Issue
Block a user