Files
gridline/src/components/db-viewer/TableControls.test.tsx
T
adrianbonpin 32a7b852ec v0.7.8: MySQL/SQLite backup-sync, Excel export, query cancel, settings import/export, Windows title-bar fix, SQLite table editor (#16)
* [P1-T1] feat(backup): MySQL/SQLite backup models + db_type on SyncOptions (Task 1.1)

* [P1-T2] feat: enable tools(mysql,sqlite) + tableManagement(sqlite) + add fflate (Task 1.2)

* [P1-T3] feat(cancel): CancelHandle enum + CancelRegistry (Task 1.3)

* [P2-T1] feat(export): hand-rolled XLSX writer with inline-string cells (Task 2.1)

* [P2-T2] feat(backup): SQLite .dump/restore/sync core, fail-closed virtual tables (Task 2.2)

* [P2-T3] feat(backup): MySQL dump/restore/sync arg builders + tool resolution (Task 2.3)

* [P2-T4] feat(settings): pure import validator + SettingsExport envelope + Store.apply_settings (Task 2.4)

* [P2-T5] feat(table-editor): SQLite create/diff/rebuild SQL generation, fail-closed AUTOINCREMENT (Task 2.5)

* [P3-T1] feat(commands): MySQL/SQLite backup + settings export/import commands + wrappers (Task 3.1)

* [P3-T2] feat(cancel): capture cancel primitives at connect; cancel_query command; SQLite interrupt test (Task 3.2)

* [P3-T3] feat(table-editor): SQLite object-change dispatch + execute_change Ddl/RebuildTable (Task 3.3)

* [P4-T1] feat(tools): DB-aware backup/restore/sync pages (Task 4.1)

* [P4-T2] feat(export): xlsx export in grid toolbar + overflow menu (Task 4.2)

* [P4-T3] feat(query): cancel button wired to cancelQuery (Task 4.3)

* [P4-T4] feat(settings): export/import buttons + validation gate (Task 4.4)

* [P4-T5] fix(ui): gate macOS overlay drag strip to macOS only (Task 4.5)

* [P4-T6] feat(table-editor): SQLite Create/Edit Table mode (Task 4.6)

* [P5-T1] chore: bump 0.7.7 -> 0.7.8 + README/AGENTS/ROADMAP status (Task 5.1)

* [P5-T2] build(release): bundle mariadb-dump + mariadb client (system-first fallback) (Task 5.2)

* fix(cancel): propagate cancellations past wrapped->raw fallback (SQLite/PG/MySQL) + MySQL CONNECTION_ID cast

* fix(export): Excel export from overflow menu did nothing + add export success/error toasts

* fix(export): tree kebab export fetches table data when rows not loaded

* docs(readme): surface v0.7.8 features (MySQL/SQLite backup-sync, Excel export, query cancel, SQLite table editor, settings import/export)
2026-08-08 00:09:36 +08:00

360 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import type { ComponentProps } from "react";
import { TableControls, formatDuration } from "./TableControls";
import { TooltipProvider } from "../ui/Tooltip";
import { useDbViewerStore } from "../../stores/dbViewerStore";
import { useNotificationStore } from "../../stores/notificationStore";
import * as exportData from "../../lib/exportData";
import type { ViewerTab } from "../../stores/dbViewerStore";
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,
},
];
function makeTab(overrides: Partial<ViewerTab> = {}): ViewerTab {
return {
id: "tab-1",
schema: "public",
table: "users",
page: 1,
pageSize: 50,
loading: false,
error: null,
data: { columns, rows: [[1]], total_rows: 1, page: 1, page_size: 50 },
filterRules: [],
sortRules: [],
hiddenColumns: [],
smartSortApplied: true,
tabType: "table",
...overrides,
};
}
function seed(tabs: ViewerTab[], activeTabId: string) {
useDbViewerStore.getState().reset();
useDbViewerStore.setState({ tabs, activeTabId });
}
function renderControls(
props: Partial<ComponentProps<typeof TableControls>> = {},
) {
return render(
<TooltipProvider>
<TableControls
connectionId="c1"
schema="public"
table="users"
columns={columns}
rows={[[1]]}
hiddenColumns={new Set()}
onToggleColumn={() => {}}
onRefresh={() => {}}
filterRules={[]}
onFilterChange={() => {}}
sortRules={[]}
onSortChange={() => {}}
selectedCount={0}
selectedRows={[]}
onClearSelection={() => {}}
{...props}
/>
</TooltipProvider>,
);
}
describe("formatDuration", () => {
it("formats milliseconds with two decimals", () => {
expect(formatDuration(15)).toBe("15.00ms");
});
it("formats seconds with one decimal once past a second", () => {
expect(formatDuration(1500)).toBe("1.5s");
expect(formatDuration(3200)).toBe("3.2s");
});
it("formats minutes for long-running queries", () => {
expect(formatDuration(90000)).toBe("1.5m");
});
it("returns an empty string when there is no timing", () => {
expect(formatDuration(null)).toBe("");
expect(formatDuration(undefined)).toBe("");
});
});
describe("TableControls query variant", () => {
it("shows Export, Re-run, and Columns on the left", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.getByLabelText(/export/i)).toBeInTheDocument();
expect(screen.getByLabelText(/re-run query/i)).toBeInTheDocument();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
});
it("hides table-only controls and the queue", () => {
seed([makeTab({ tabType: "query" })], "tab-1");
renderControls({ variant: "query" });
expect(screen.queryByLabelText(/insert row/i)).toBeNull();
expect(screen.queryByLabelText(/auto-refresh/i)).toBeNull();
expect(screen.queryByLabelText(/column filters/i)).toBeNull();
expect(screen.queryByLabelText(/sort rules/i)).toBeNull();
expect(screen.queryByRole("button", { name: /action queue/i })).toBeNull();
});
it("shows the execution time from the result with a clock", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByLabelText(/execution time/i)).toBeInTheDocument();
expect(screen.getByText("15.00ms")).toBeInTheDocument();
});
it("keeps the row count and pagination", () => {
seed(
[
makeTab({
tabType: "query",
data: {
columns,
rows: [[1]],
total_rows: 42,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({ variant: "query" });
expect(screen.getByText(/of 42/)).toBeInTheDocument();
expect(screen.getByLabelText(/next page/i)).toBeInTheDocument();
});
it("table variant has no queue button (moved to tab bar) and no execution time", () => {
seed(
[
makeTab({
data: {
columns,
rows: [[1]],
total_rows: 1,
page: 1,
page_size: 50,
execution_time_ms: 15,
},
}),
],
"tab-1",
);
renderControls({});
expect(
screen.queryByRole("button", { name: /action queue/i }),
).toBeNull();
expect(screen.getByLabelText(/toggle columns/i)).toBeInTheDocument();
expect(screen.queryByText("15.00ms")).toBeNull();
});
});
describe("TableControls", () => {
beforeEach(() => {
useDbViewerStore.getState().reset();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("calls onRefresh when the refresh button is clicked", () => {
seed([makeTab()], "tab-1");
const onRefresh = vi.fn();
renderControls({ onRefresh });
fireEvent.click(screen.getByLabelText(/refresh table/i));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("does not spin the refresh icon or show the pulse when idle", () => {
seed([makeTab()], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("spins the refresh icon and shows the pulse overlay while the tab is loading", () => {
seed([makeTab({ loading: true })], "tab-1");
const { container } = renderControls();
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
});
it("shows the refreshing indicators when auto-refresh fires and clears them when done", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
const { container } = renderControls({
onRefresh,
defaultRefreshRate: 5000,
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
expect(container.querySelector(".animate-spin")).not.toBeNull();
expect(screen.getByTestId("refresh-pulse")).toBeInTheDocument();
// Once the fetch completes the indicators disappear
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
expect(container.querySelector(".animate-spin")).toBeNull();
expect(screen.queryByTestId("refresh-pulse")).not.toBeInTheDocument();
});
it("waits for an in-flight refresh to complete before restarting the timer", () => {
vi.useFakeTimers();
seed([makeTab()], "tab-1");
const onRefresh = vi.fn(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: true } : t,
),
}));
});
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// First interval fires the refresh
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// While the refresh is still in flight, the timer must NOT fire again
act(() => {
vi.advanceTimersByTime(15000);
});
expect(onRefresh).toHaveBeenCalledTimes(1);
// Once the refresh completes, a fresh countdown starts
act(() => {
useDbViewerStore.setState((s) => ({
tabs: s.tabs.map((t) =>
t.id === "tab-1" ? { ...t, loading: false } : t,
),
}));
});
act(() => {
vi.advanceTimersByTime(5000);
});
expect(onRefresh).toHaveBeenCalledTimes(2);
});
it("defers auto-refresh when the user switches tabs (resets the timer)", () => {
vi.useFakeTimers();
const onRefresh = vi.fn();
seed([makeTab(), makeTab({ id: "tab-2", table: "orders" })], "tab-1");
renderControls({ onRefresh, defaultRefreshRate: 5000 });
// Not yet a full interval
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// User switches to another tab → countdown restarts
act(() => {
useDbViewerStore.setState({ activeTabId: "tab-2" });
});
act(() => {
vi.advanceTimersByTime(4000);
});
expect(onRefresh).not.toHaveBeenCalled();
// Full interval after the switch finally fires
act(() => {
vi.advanceTimersByTime(1000);
});
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();
});
it("export dropdown includes the Excel option", () => {
seed([makeTab()], "tab-1");
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
expect(screen.getByText("JSON")).toBeInTheDocument();
expect(screen.getByText("CSV")).toBeInTheDocument();
expect(screen.getByText("SQL")).toBeInTheDocument();
expect(screen.getByText("Markdown")).toBeInTheDocument();
expect(screen.getByText("Excel")).toBeInTheDocument();
});
it("notifies after a successful export", () => {
seed([makeTab()], "tab-1");
useNotificationStore.getState().notifications.length = 0;
vi.spyOn(exportData, "exportData").mockImplementation(() => {});
renderControls();
fireEvent.click(screen.getByLabelText(/export/i));
fireEvent.click(screen.getByText("Excel"));
const st = useNotificationStore.getState();
expect(
st.notifications.some((n) => n.message.toLowerCase().includes("exported")),
).toBe(true);
});
});