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:
Generated
+55
-1
@@ -9,6 +9,8 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"ag-grid-community": "^36.0.0",
|
||||
"ag-grid-react": "^36.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
@@ -2988,6 +2990,42 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.0.0.tgz",
|
||||
"integrity": "sha512-89X4J/OeOTzTp505126JayGEn3k6QUKz3UHSR0/ggoVZYzVqtMN13k/B136S1+YBwgnzyJ/NARnnIRTPWsWG9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "36.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.0.0.tgz",
|
||||
"integrity": "sha512-73PeuoG1Pofi6o1o2dYd092m5AdCt0uq/iCaVTCxLw9eRJGLZu0I+HICReolUmUH7A5X+CnYDXiE+P9S8dUqCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.0.0",
|
||||
"ag-stack": "36.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "36.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.0.0.tgz",
|
||||
"integrity": "sha512-ShmynyRQDoxuXBso+kPliZJnivQKwBG/EGclCBV3dg98ou2kq7yxdgf8DhrW4uM5SekNDaqor4Gn9OAGtH2o3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "36.0.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-stack": {
|
||||
"version": "36.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.0.0.tgz",
|
||||
"integrity": "sha512-ciXqjZygVLk3tnZWOAQyOxP48Ju2ZmaSAtIebekXJwHqA7vB+NJ8ZME/O4XTRpInTSHn2KSu/KTxgWJO8dduiw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -7785,7 +7823,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8509,6 +8546,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"ag-grid-community": "^36.0.0",
|
||||
"ag-grid-react": "^36.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
import { sqlPreview, sqlExport } from "../api/sql";
|
||||
|
||||
// Register AG Grid modules once at module load time
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function ResultsPanel({ sessionId }: Props) {
|
||||
const [limit, setLimit] = useState<number>(10);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["preview", sessionId, limit],
|
||||
queryFn: () => sqlPreview(sessionId, { limit, offset: 0 }),
|
||||
});
|
||||
|
||||
async function handleExport() {
|
||||
await sqlExport(sessionId);
|
||||
}
|
||||
|
||||
const isScalar =
|
||||
data != null && data.columns.length === 1 && data.rows.length === 1;
|
||||
|
||||
const columnDefs = data
|
||||
? data.columns.map((col) => ({ field: col, headerName: col }))
|
||||
: [];
|
||||
|
||||
const rowData = data
|
||||
? data.rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
data.columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Limit selector */}
|
||||
<div style={{ marginBottom: 8, display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) =>
|
||||
setLimit(e.target.value === "tutti" ? 1_000_000 : Number(e.target.value))
|
||||
}
|
||||
aria-label="Righe da visualizzare"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value="tutti">tutti</option>
|
||||
</select>
|
||||
|
||||
<button onClick={handleExport}>Esporta CSV</button>
|
||||
</div>
|
||||
|
||||
{/* Loading / error states */}
|
||||
{isLoading && <div>Caricamento...</div>}
|
||||
{error && <div>Errore: {String(error)}</div>}
|
||||
|
||||
{/* Scalar result: 1 col × 1 row → bold value */}
|
||||
{data && isScalar && (
|
||||
<strong role="strong">{String(data.rows[0][0])}</strong>
|
||||
)}
|
||||
|
||||
{/* Grid for everything else */}
|
||||
{data && !isScalar && (
|
||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact columnDefs={columnDefs} rowData={rowData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user