diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b6e30945..1540eed5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 16aa81c7..2fba4bf5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/viewers/ResultsPanel.test.tsx b/frontend/src/viewers/ResultsPanel.test.tsx new file mode 100644 index 00000000..002c2e0a --- /dev/null +++ b/frontend/src/viewers/ResultsPanel.test.tsx @@ -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 ( + {ui} + ); +} + +// (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()); + + // 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()); + + // 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()); + + // 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)); +}); diff --git a/frontend/src/viewers/ResultsPanel.tsx b/frontend/src/viewers/ResultsPanel.tsx new file mode 100644 index 00000000..af1b6fb2 --- /dev/null +++ b/frontend/src/viewers/ResultsPanel.tsx @@ -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(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 = {}; + data.columns.forEach((col, i) => { + obj[col] = row[i]; + }); + return obj; + }) + : []; + + return ( +
+ {/* Limit selector */} +
+ + + +
+ + {/* Loading / error states */} + {isLoading &&
Caricamento...
} + {error &&
Errore: {String(error)}
} + + {/* Scalar result: 1 col × 1 row → bold value */} + {data && isScalar && ( + {String(data.rows[0][0])} + )} + + {/* Grid for everything else */} + {data && !isScalar && ( +
+ +
+ )} +
+ ); +}