feat: redesign persisted session summaries
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user