feat(frontend): results panel (AGGrid + preview/export)

Add ResultsPanel component with AGGrid v36 for tabular preview results,
bold scalar display for 1×1 results, limit selector (10 / tutti), and
Esporta CSV button delegating export to the backend. MSW-tested with 3
passing scenarios (grid cells, scalar, export endpoint call).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 12:57:08 +02:00
co-authored by Claude Sonnet 4.6
parent 38032877e9
commit b4ab7b005a
4 changed files with 244 additions and 1 deletions
+109
View File
@@ -0,0 +1,109 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { ResultsPanel } from "./ResultsPanel";
const BASE = "http://localhost:8787";
function makeClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}
function wrap(ui: React.ReactElement) {
return (
<QueryClientProvider client={makeClient()}>{ui}</QueryClientProvider>
);
}
// (a) 2-col × 2-row preview → AGGrid renders cell values
test("(a) renders grid cells for a 2×2 preview result", async () => {
server.use(
http.post(`${BASE}/sessions/sess-1/sql/preview`, () =>
HttpResponse.json({
columns: ["name", "age"],
rows: [
["Alice", 30],
["Bob", 25],
],
execution_ms: 5,
truncated: false,
limit: 10,
offset: 0,
})
)
);
render(wrap(<ResultsPanel sessionId="sess-1" />));
// Wait for data to load; check known cell values
await screen.findByText("Alice");
expect(screen.getByText("Bob")).toBeInTheDocument();
expect(screen.getByText("30")).toBeInTheDocument();
expect(screen.getByText("25")).toBeInTheDocument();
// Grid must be present, not bold scalar
expect(screen.queryByRole("strong")).not.toBeInTheDocument();
});
// (b) 1×1 preview → bold scalar shown, no grid
test("(b) renders bold scalar for a 1×1 result", async () => {
server.use(
http.post(`${BASE}/sessions/sess-2/sql/preview`, () =>
HttpResponse.json({
columns: ["count"],
rows: [[42]],
execution_ms: 2,
truncated: false,
limit: 10,
offset: 0,
})
)
);
render(wrap(<ResultsPanel sessionId="sess-2" />));
// Should show the scalar value in bold
const bold = await screen.findByRole("strong");
expect(bold).toHaveTextContent("42");
// No grid
expect(screen.queryByRole("treegrid")).not.toBeInTheDocument();
expect(screen.queryByRole("grid")).not.toBeInTheDocument();
});
// (c) clicking "Esporta CSV" calls the export endpoint
test("(c) Esporta CSV button calls the export endpoint", async () => {
let exportCalled = false;
server.use(
http.post(`${BASE}/sessions/sess-3/sql/preview`, () =>
HttpResponse.json({
columns: ["id"],
rows: [[1], [2]],
execution_ms: 3,
truncated: false,
limit: 10,
offset: 0,
})
),
http.post(`${BASE}/sessions/sess-3/sql/export`, () => {
exportCalled = true;
return HttpResponse.json({ path: "/tmp/export.csv" });
})
);
const user = userEvent.setup();
render(wrap(<ResultsPanel sessionId="sess-3" />));
// Wait for preview to load (use role to avoid ambiguity with paging panel)
await waitFor(() => expect(screen.getAllByRole("gridcell")).not.toHaveLength(0));
const btn = screen.getByRole("button", { name: /esporta csv/i });
await user.click(btn);
await waitFor(() => expect(exportCalled).toBe(true));
});