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:
2026-08-03 02:42:24 +08:00
committed by GitHub
parent e0c0db8352
commit 16888460b7
77 changed files with 5671 additions and 265 deletions
@@ -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");
});
});