feat: redesign persisted session summaries

This commit is contained in:
2026-07-23 15:35:31 +02:00
parent 12c7a976d7
commit 0db85e4e4e
12 changed files with 1090 additions and 71 deletions
+36 -1
View File
@@ -22,6 +22,16 @@ const blocks = [
},
];
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
});
test("(a) renders headers with name, field count, and test-status badge", async () => {
render(<SqlViewer blocks={blocks} />);
// Drain the pending highlightSql microtasks inside act() before asserting,
@@ -51,7 +61,11 @@ test("(b) bodies start expanded and clicking header collapses then re-expands",
});
// Collapse the first block by clicking its header
const firstHeader = screen.getByRole("button", { name: /base_orders/i });
const firstHeader = screen
.getAllByRole("button", { name: /base_orders/i })
.find((button) => button.hasAttribute("aria-expanded"));
expect(firstHeader).toBeDefined();
if (!firstHeader) throw new Error("Missing SQL collapse control");
await user.click(firstHeader);
// Now only one highlighted block visible
@@ -92,3 +106,24 @@ test("a single SQL block hides the multi-block layout toggle", async () => {
expect(screen.queryByRole("button", { name: /horizontal/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /vertical/i })).not.toBeInTheDocument();
});
test("copies the exact SQL from an always-available accessible control", async () => {
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(writeText).toHaveBeenCalledWith("SELECT id, amount FROM orders");
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
test("reports clipboard rejection and keeps the SQL visible", async () => {
writeText.mockRejectedValueOnce(new Error("clipboard unavailable"));
render(<SqlViewer blocks={[blocks[0]]} />);
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
await userEvent.click(screen.getByRole("button", { name: /copy sql for base_orders/i }));
expect(await screen.findByText("Copy failed")).toBeInTheDocument();
expect(screen.getByTestId("hl")).toBeInTheDocument();
});
+51 -19
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from "react";
import { Check, Copy, TriangleAlert } from "lucide-react";
import { highlightSql } from "./highlight";
import { statusBadgeClass } from "./statusBadge";
@@ -21,6 +22,7 @@ const STATUS_LABELS: Record<string, string> = {
function Block({ block }: { block: SqlBlock }) {
const [open, setOpen] = useState(true);
const [html, setHtml] = useState<string>("");
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
useEffect(() => {
let cancelled = false;
@@ -37,28 +39,58 @@ function Block({ block }: { block: SqlBlock }) {
? statusBadgeClass(block.testStatus)
: "";
async function copySql() {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(block.sql);
setCopyState("copied");
} catch {
setCopyState("failed");
}
}
return (
<div className="sql-block rounded-lg border border-border/70 mb-2">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="text-muted-foreground text-xs">
{block.fields} campi
<div className="flex items-stretch">
<button
className="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left font-mono text-sm hover:bg-muted/30"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<span className="select-none">{open ? "▾" : "▸"}</span>
<span className="truncate font-semibold">{block.name}</span>
{block.fields !== undefined && (
<span className="shrink-0 text-xs text-muted-foreground">
{block.fields} campi
</span>
)}
{badge && (
<span
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-xs ${badgeClass}`}
>
{badge}
</span>
)}
</button>
<button
type="button"
aria-label={`Copy SQL for ${block.name}`}
title="Copy SQL"
onClick={copySql}
className="flex shrink-0 items-center gap-1.5 border-l border-border/70 px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{copyState === "copied" ? (
<Check className="size-3.5 text-[oklch(0.47_0.12_165)]" aria-hidden="true" />
) : copyState === "failed" ? (
<TriangleAlert className="size-3.5 text-destructive" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
<span role="status" aria-live="polite">
{copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : ""}
</span>
)}
{badge && (
<span
className={`ml-auto text-xs px-1.5 py-0.5 rounded-md ${badgeClass}`}
>
{badge}
</span>
)}
</button>
</button>
</div>
{open && (
<div className="sql-block-body px-3 pb-3">