feat(frontend): v2 artifact viewers for cte_plan/cte_result/phase
Add CtePlanViewer, CteResultViewer, and PhaseSummaryViewer to render the structured v2 payloads (schema_version: 2) the harness now emits for artifact-gate widgets, per contracts.md. Extract PreviewGrid from ResultsPanel as a reusable AG Grid component shared by CteResultViewer. ArtifactView dispatches to the new viewers on a schema_version/shape guard, falling back to the existing legacy renderers unchanged for older sessions and replay fixtures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,20 @@ vi.mock("./mermaid", () => ({
|
||||
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
|
||||
}));
|
||||
|
||||
test("sql artifact renders the SQL text", () => {
|
||||
vi.mock("./highlight", () => ({
|
||||
highlightSql: vi.fn().mockResolvedValue('<pre data-testid="hl">SQL</pre>'),
|
||||
}));
|
||||
|
||||
test("sql artifact renders the SQL text", async () => {
|
||||
render(<ArtifactView artifact={{ kind: "sql", data: "SELECT 1" }} />);
|
||||
expect(screen.getByText("SELECT 1")).toBeInTheDocument();
|
||||
await screen.findByTestId("hl");
|
||||
});
|
||||
|
||||
test("sql artifact accepts a { content } wrapper", () => {
|
||||
test("sql artifact accepts a { content } wrapper", async () => {
|
||||
render(<ArtifactView artifact={{ kind: "sql", data: { content: "SELECT 7" } }} />);
|
||||
expect(screen.getByText("SELECT 7")).toBeInTheDocument();
|
||||
await screen.findByTestId("hl");
|
||||
});
|
||||
|
||||
test("cte_plan renders an ordered list of names", () => {
|
||||
@@ -56,3 +62,85 @@ test("schema_linking renders the schema viewer", async () => {
|
||||
);
|
||||
expect(await screen.findByTestId("mm")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_plan v2 dispatches to CtePlanViewer", () => {
|
||||
render(
|
||||
<ArtifactView
|
||||
artifact={{
|
||||
kind: "cte_plan",
|
||||
data: {
|
||||
schema_version: 2,
|
||||
strategy: "Filtriamo prima i pazienti idonei.",
|
||||
ctes: [{ name: "pazienti_idonei", index: 1, purpose: "Seleziona pazienti" }],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Filtriamo prima i pazienti idonei.")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTE 1/1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_plan v1 (legacy names list) still renders via the legacy path", () => {
|
||||
render(<ArtifactView artifact={{ kind: "cte_plan", data: { names: ["a_cte", "b_cte"] } }} />);
|
||||
expect(screen.getByText("a_cte")).toBeInTheDocument();
|
||||
expect(screen.getByText("b_cte")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_result v2 dispatches to CteResultViewer", async () => {
|
||||
render(
|
||||
<ArtifactView
|
||||
artifact={{
|
||||
kind: "cte_result",
|
||||
data: {
|
||||
schema_version: 2,
|
||||
name: "pazienti_idonei",
|
||||
index: 1,
|
||||
total: 1,
|
||||
sql: "SELECT 1",
|
||||
status: "ok",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText("CTE 1/1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_result v1 (bare sql string) still renders via toSqlBlocks legacy path", async () => {
|
||||
render(<ArtifactView artifact={{ kind: "cte_result", data: "SELECT 42" }} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.queryByText("CTE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("phase v2 dispatches to PhaseSummaryViewer", () => {
|
||||
render(
|
||||
<ArtifactView
|
||||
artifact={{
|
||||
kind: "phase",
|
||||
data: {
|
||||
schema_version: 2,
|
||||
phase: { id: "F5", num: 5, name: "sintesi" },
|
||||
summary: "Riassunto della fase.",
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("F5 — sintesi")).toBeInTheDocument();
|
||||
expect(screen.getByText("Riassunto della fase.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("phase v1 (no schema_version) still renders via legacy markdown/structured path", () => {
|
||||
render(
|
||||
<ArtifactView
|
||||
artifact={{
|
||||
kind: "phase",
|
||||
data: {
|
||||
recap: "Domanda: lista pazienti.",
|
||||
chiarimenti_risolti: ["finestra_temporale — 5 anni"],
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Recap")).toBeInTheDocument();
|
||||
expect(screen.getByText("Chiarimenti risolti")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { ReactElement } from "react";
|
||||
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
||||
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
import { CtePlanViewer } from "./CtePlanViewer";
|
||||
import { CteResultViewer } from "./CteResultViewer";
|
||||
import { PhaseSummaryViewer } from "./PhaseSummaryViewer";
|
||||
import type { CtePlanV2, CteResultV2, PhaseSummaryV2 } from "./artifactV2";
|
||||
|
||||
type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
|
||||
|
||||
@@ -60,6 +64,26 @@ function toMarkdown(raw: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toCtePlanV2(raw: unknown): CtePlanV2 | null {
|
||||
const rec = asRecord(raw);
|
||||
if (!rec || rec.schema_version !== 2 || !Array.isArray(rec.ctes)) return null;
|
||||
return rec as unknown as CtePlanV2;
|
||||
}
|
||||
|
||||
function toCteResultV2(raw: unknown): CteResultV2 | null {
|
||||
const rec = asRecord(raw);
|
||||
if (!rec || rec.schema_version !== 2 || typeof rec.sql !== "string" || typeof rec.name !== "string") {
|
||||
return null;
|
||||
}
|
||||
return rec as unknown as CteResultV2;
|
||||
}
|
||||
|
||||
function toPhaseSummaryV2(raw: unknown): PhaseSummaryV2 | null {
|
||||
const rec = asRecord(raw);
|
||||
if (!rec || rec.schema_version !== 2) return null;
|
||||
return rec as unknown as PhaseSummaryV2;
|
||||
}
|
||||
|
||||
function cteNames(raw: unknown): string[] | null {
|
||||
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
|
||||
const rec = asRecord(raw);
|
||||
@@ -140,11 +164,17 @@ export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactEle
|
||||
const linking = toSchemaLinking(data);
|
||||
if (linking) return <SchemaLinkingViewer linking={linking} />;
|
||||
}
|
||||
if (kind === "cte_result") {
|
||||
const resultV2 = toCteResultV2(data);
|
||||
if (resultV2) return <CteResultViewer result={resultV2} />;
|
||||
}
|
||||
if (kind === "sql" || kind === "cte_result") {
|
||||
const blocks = toSqlBlocks(data);
|
||||
if (blocks) return <SqlViewer blocks={blocks} />;
|
||||
}
|
||||
if (kind === "cte_plan") {
|
||||
const planV2 = toCtePlanV2(data);
|
||||
if (planV2) return <CtePlanViewer plan={planV2} />;
|
||||
const names = cteNames(data);
|
||||
if (names) {
|
||||
return (
|
||||
@@ -156,6 +186,10 @@ export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactEle
|
||||
);
|
||||
}
|
||||
}
|
||||
if (kind === "phase") {
|
||||
const phaseV2 = toPhaseSummaryV2(data);
|
||||
if (phaseV2) return <PhaseSummaryViewer phase={phaseV2} />;
|
||||
}
|
||||
if (kind === "question" || kind === "phase") {
|
||||
const md = toMarkdown(data);
|
||||
if (md !== null) return <MarkdownView source={md} />;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CtePlanViewer } from "./CtePlanViewer";
|
||||
import type { CtePlanV2 } from "./artifactV2";
|
||||
|
||||
const plan: CtePlanV2 = {
|
||||
schema_version: 2,
|
||||
question: "Quanti pazienti hanno avuto un impianto negli ultimi 5 anni?",
|
||||
strategy: "Filtriamo prima i pazienti idonei, poi joiniamo gli impianti.",
|
||||
ctes: [
|
||||
{
|
||||
name: "pazienti_idonei",
|
||||
index: 1,
|
||||
purpose: "Seleziona i pazienti con follow-up attivo",
|
||||
rationale: "Base della catena: riduce il volume prima dei join",
|
||||
depends_on: [],
|
||||
tables: [{ name: "pazienti", description: "Anagrafica pazienti" }],
|
||||
keys: ["paziente_id"],
|
||||
filters: [
|
||||
{
|
||||
column: "pazienti.stato",
|
||||
op: "IS",
|
||||
value: "TRUE",
|
||||
description: "Flag attivo",
|
||||
rationale: "Solo pazienti in follow-up",
|
||||
},
|
||||
],
|
||||
output_columns: ["paziente_id", "data_nascita"],
|
||||
},
|
||||
{
|
||||
name: "impianti_recenti",
|
||||
index: 2,
|
||||
purpose: "Filtra gli impianti recenti",
|
||||
depends_on: ["pazienti_idonei"],
|
||||
tables: [{ name: "impianti" }],
|
||||
output_columns: ["impianto_id"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("renders strategy, question, and a chain strip with CTE names in order", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
|
||||
expect(screen.getByText(plan.question!)).toBeInTheDocument();
|
||||
expect(screen.getByText(plan.strategy!)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders one card per CTE with index badge, purpose, and filter value", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
|
||||
expect(screen.getByText("CTE 1/2")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTE 2/2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Seleziona i pazienti con follow-up attivo")).toBeInTheDocument();
|
||||
expect(screen.getByText(/pazienti\.stato/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/TRUE/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders depends_on chips for a CTE that has dependencies", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
// "pazienti_idonei" appears both as chain-strip entry and as a depends_on chip
|
||||
// for impianti_recenti — assert at least one occurrence beyond the strip.
|
||||
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("omits sections for missing optional fields without rendering 'undefined'", () => {
|
||||
render(<CtePlanViewer plan={plan} />);
|
||||
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
|
||||
// impianti_recenti has no rationale/keys/filters — no crash, no stray text
|
||||
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
|
||||
|
||||
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
|
||||
function Chip({ children }: { children: ReactNode }) {
|
||||
return <span className={CHIP}>{children}</span>;
|
||||
}
|
||||
|
||||
function FilterChip({ filter }: { filter: CtePlanFilter }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className={CHIP}>
|
||||
{filter.column} {filter.op} {filter.value}
|
||||
</span>
|
||||
{filter.description && <p className="text-xs text-muted-foreground">{filter.description}</p>}
|
||||
{filter.rationale && <p className="text-xs text-muted-foreground">{filter.rationale}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
const dependsOn = cte.depends_on ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
CTE {cte.index}/{total}
|
||||
</Badge>
|
||||
<CardTitle className="font-mono text-sm">{cte.name}</CardTitle>
|
||||
</div>
|
||||
{cte.purpose && <p className="text-sm text-muted-foreground">{cte.purpose}</p>}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Depends on</h4>
|
||||
{dependsOn.length ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{dependsOn.map((d) => (
|
||||
<Chip key={d}>{d}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">no dependencies</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cte.tables && cte.tables.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Tables</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{cte.tables.map((t) => (
|
||||
<div key={t.name}>
|
||||
<span className="font-mono text-sm">{t.name}</span>
|
||||
{t.description && (
|
||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cte.keys && cte.keys.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Keys</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cte.keys.map((k) => (
|
||||
<Chip key={k}>{k}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cte.filters && cte.filters.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Filters</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{cte.filters.map((f, i) => (
|
||||
<FilterChip key={i} filter={f} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cte.output_columns && cte.output_columns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Output columns</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cte.output_columns.map((c) => (
|
||||
<Chip key={c}>{c}</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cte.rationale && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Rationale</h4>
|
||||
<p className="text-sm text-foreground/90">{cte.rationale}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{plan.question && <p className="text-sm text-muted-foreground">{plan.question}</p>}
|
||||
{plan.strategy && <p className="thot-prose">{plan.strategy}</p>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||
{plan.ctes.map((c, i) => (
|
||||
<span key={c.name} className="flex items-center gap-1.5">
|
||||
{i > 0 && <span aria-hidden="true">→</span>}
|
||||
<span className="text-foreground/90">{c.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{plan.ctes.map((cte) => (
|
||||
<CteCard key={cte.name} cte={cte} total={plan.ctes.length} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CteResultViewer } from "./CteResultViewer";
|
||||
import type { CteResultV2 } from "./artifactV2";
|
||||
|
||||
vi.mock("./highlight", () => ({
|
||||
highlightSql: vi.fn().mockResolvedValue('<pre data-testid="hl">SQL</pre>'),
|
||||
}));
|
||||
|
||||
const okResult: CteResultV2 = {
|
||||
schema_version: 2,
|
||||
name: "pazienti_idonei",
|
||||
index: 1,
|
||||
total: 2,
|
||||
purpose: "Seleziona i pazienti con follow-up attivo",
|
||||
rationale: "Base della catena",
|
||||
depends_on: [],
|
||||
sql: "WITH pazienti_idonei AS (SELECT 1)",
|
||||
status: "ok",
|
||||
execution_ms: 142,
|
||||
row_sample: 10,
|
||||
warnings: [],
|
||||
columns: [{ name: "paziente_id", description: "Identificativo paziente" }],
|
||||
preview: { columns: ["paziente_id"], rows: [[1], [2]] },
|
||||
note: "Nota del modello",
|
||||
};
|
||||
|
||||
test("renders header meta: index/total badge, mono name, status badge, execution_ms, row_sample", async () => {
|
||||
render(<CteResultViewer result={okResult} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText("CTE 1/2")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/success|ok/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/142 ms/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10 rows/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders warnings as badges", async () => {
|
||||
const withWarnings: CteResultV2 = { ...okResult, warnings: ["colonna troncata"] };
|
||||
render(<CteResultViewer result={withWarnings} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText("colonna troncata")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes sql to SqlViewer with mapped test status", async () => {
|
||||
render(<CteResultViewer result={okResult} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText(/passed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("error status maps to failed test status and destructive badge", async () => {
|
||||
const errored: CteResultV2 = { ...okResult, status: "error" };
|
||||
render(<CteResultViewer result={errored} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText(/failed/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders PreviewGrid rows when preview is present", async () => {
|
||||
render(<CteResultViewer result={okResult} />);
|
||||
await screen.findByTestId("hl");
|
||||
const cells = screen.getAllByRole("gridcell").map((c) => c.textContent);
|
||||
expect(cells).toContain("1");
|
||||
expect(cells).toContain("2");
|
||||
});
|
||||
|
||||
test("shows a discreet empty-state when preview is absent", async () => {
|
||||
const noPreview: CteResultV2 = { ...okResult, preview: undefined };
|
||||
render(<CteResultViewer result={noPreview} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows empty-state when preview has no rows", async () => {
|
||||
const emptyPreview: CteResultV2 = { ...okResult, preview: { columns: ["paziente_id"], rows: [] } };
|
||||
render(<CteResultViewer result={emptyPreview} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText(/no preview rows/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders columns definition list with descriptions", async () => {
|
||||
const { container } = render(<CteResultViewer result={okResult} />);
|
||||
await screen.findByTestId("hl");
|
||||
const dt = container.querySelector("dl dt");
|
||||
expect(dt).toHaveTextContent("paziente_id");
|
||||
expect(screen.getByText("Identificativo paziente")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("omits the columns definition list when all descriptions are empty", async () => {
|
||||
const noDescriptions: CteResultV2 = {
|
||||
...okResult,
|
||||
columns: [{ name: "paziente_id", description: "" }],
|
||||
};
|
||||
const { container } = render(<CteResultViewer result={noDescriptions} />);
|
||||
await screen.findByTestId("hl");
|
||||
// No <dl> definition list should render when all descriptions are empty.
|
||||
expect(container.querySelector("dl")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders note at the end", async () => {
|
||||
render(<CteResultViewer result={okResult} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText("Nota del modello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders depends_on chips when present", async () => {
|
||||
const withDeps: CteResultV2 = { ...okResult, depends_on: ["cte_base"] };
|
||||
render(<CteResultViewer result={withDeps} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.getByText("cte_base")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render 'undefined' strings for missing optional fields", async () => {
|
||||
const minimal: CteResultV2 = {
|
||||
schema_version: 2,
|
||||
name: "final",
|
||||
index: 2,
|
||||
total: 2,
|
||||
sql: "SELECT 1",
|
||||
status: "ok",
|
||||
};
|
||||
render(<CteResultViewer result={minimal} />);
|
||||
await screen.findByTestId("hl");
|
||||
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
||||
import { PreviewGrid } from "./PreviewGrid";
|
||||
import type { CteResultV2 } from "./artifactV2";
|
||||
|
||||
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
|
||||
function statusBadgeVariant(status: string): "default" | "destructive" | "outline" {
|
||||
if (status === "ok") return "default";
|
||||
if (status === "error") return "destructive";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === "ok") return "success";
|
||||
if (status === "error") return "error";
|
||||
return status;
|
||||
}
|
||||
|
||||
function testStatus(status: string): SqlBlock["testStatus"] {
|
||||
if (status === "ok") return "passed";
|
||||
if (status === "error") return "failed";
|
||||
return "untested";
|
||||
}
|
||||
|
||||
export function CteResultViewer({ result }: { result: CteResultV2 }) {
|
||||
const hasPreviewRows = !!result.preview && result.preview.rows.length > 0;
|
||||
const descriptions = (result.columns ?? []).map((c) => c.description ?? "");
|
||||
const showColumns = descriptions.some((d) => d.trim() !== "");
|
||||
const dependsOn = result.depends_on ?? [];
|
||||
|
||||
const block: SqlBlock = {
|
||||
name: result.name,
|
||||
sql: result.sql,
|
||||
testStatus: testStatus(result.status),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
CTE {result.index}/{result.total}
|
||||
</Badge>
|
||||
<span className="font-mono text-sm font-semibold">{result.name}</span>
|
||||
<Badge variant={statusBadgeVariant(result.status)}>{statusLabel(result.status)}</Badge>
|
||||
{result.execution_ms !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">{result.execution_ms} ms</span>
|
||||
)}
|
||||
{result.row_sample !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">{result.row_sample} rows</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{result.warnings.map((w, i) => (
|
||||
<Badge key={i} variant="outline" className="border-[oklch(var(--warning)/0.4)] text-[oklch(0.5_0.12_79)]">
|
||||
{w}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(result.purpose || result.rationale || dependsOn.length > 0) && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{result.purpose && <p className="text-sm text-muted-foreground">{result.purpose}</p>}
|
||||
{result.rationale && <p className="text-sm text-foreground/90">{result.rationale}</p>}
|
||||
{dependsOn.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{dependsOn.map((d) => (
|
||||
<span key={d} className={CHIP}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SqlViewer blocks={[block]} />
|
||||
|
||||
{hasPreviewRows ? (
|
||||
<PreviewGrid columns={result.preview!.columns} rows={result.preview!.rows} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No preview rows</p>
|
||||
)}
|
||||
|
||||
{showColumns && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{result.columns!.map((c) => (
|
||||
<div key={c.name} className="contents">
|
||||
<dt className="font-mono font-medium">{c.name}</dt>
|
||||
<dd className="text-muted-foreground">{c.description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{result.note && <p className="thot-prose">{result.note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PhaseSummaryViewer } from "./PhaseSummaryViewer";
|
||||
import type { PhaseSummaryV2 } from "./artifactV2";
|
||||
|
||||
const phase: PhaseSummaryV2 = {
|
||||
schema_version: 2,
|
||||
phase: { id: "F5", num: 5, name: "sintesi" },
|
||||
summary: "## Recap\nLa catena copre pazienti e impianti.",
|
||||
checks: [
|
||||
{ label: "Schema linking completo", status: "ok" },
|
||||
{ label: "Join validati", status: "warn", detail: "Un join manuale non testato" },
|
||||
{ label: "SQL eseguibile", status: "fail", detail: "Errore di sintassi" },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
title: "Criteri di selezione",
|
||||
items: [
|
||||
{
|
||||
label: "Pazienti attivi",
|
||||
table: "pazienti",
|
||||
column: "stato",
|
||||
value: "IS TRUE",
|
||||
kind: "filter",
|
||||
description: "Flag di stato attivo",
|
||||
rationale: "Solo pazienti in follow-up",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
name: "pazienti",
|
||||
role: "promoted",
|
||||
description: "Anagrafica pazienti",
|
||||
columns: [{ name: "stato", description: "Flag attivo", value_filter: "IS TRUE" }],
|
||||
},
|
||||
{ name: "log_accessi", role: "excluded", description: "Non pertinente" },
|
||||
],
|
||||
open_questions: ["Serve confermare la finestra temporale?"],
|
||||
};
|
||||
|
||||
test("renders phase id/name header and markdown summary", () => {
|
||||
render(<PhaseSummaryViewer phase={phase} />);
|
||||
expect(screen.getByText(/F5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/sintesi/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Recap")).toBeInTheDocument();
|
||||
expect(screen.getByText(/La catena copre pazienti e impianti/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders checks with status label and detail", () => {
|
||||
render(<PhaseSummaryViewer phase={phase} />);
|
||||
expect(screen.getByText("Schema linking completo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Join validati")).toBeInTheDocument();
|
||||
expect(screen.getByText("Un join manuale non testato")).toBeInTheDocument();
|
||||
expect(screen.getByText("SQL eseguibile")).toBeInTheDocument();
|
||||
expect(screen.getByText("Errore di sintassi")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders sections with item label, table.column code-chip, and value badge", () => {
|
||||
render(<PhaseSummaryViewer phase={phase} />);
|
||||
expect(screen.getByText("Criteri di selezione")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pazienti attivi")).toBeInTheDocument();
|
||||
expect(screen.getByText("pazienti.stato")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("IS TRUE").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Flag di stato attivo")).toBeInTheDocument();
|
||||
expect(screen.getByText("Solo pazienti in follow-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders tables recap with promoted/excluded roles and columns", () => {
|
||||
render(<PhaseSummaryViewer phase={phase} />);
|
||||
expect(screen.getByText("pazienti")).toBeInTheDocument();
|
||||
expect(screen.getByText(/promoted/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("log_accessi")).toBeInTheDocument();
|
||||
expect(screen.getByText(/excluded/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("stato")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("IS TRUE").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders open_questions as a bullet list", () => {
|
||||
render(<PhaseSummaryViewer phase={phase} />);
|
||||
expect(screen.getByText("Serve confermare la finestra temporale?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("omits open_questions section when empty and shows no 'undefined' text", () => {
|
||||
const minimal: PhaseSummaryV2 = {
|
||||
schema_version: 2,
|
||||
phase: { id: "F1", num: 1, name: "domanda" },
|
||||
};
|
||||
render(<PhaseSummaryViewer phase={minimal} />);
|
||||
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/F1/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
import type { PhaseCheck, PhaseSection, PhaseSectionItem, PhaseSummaryV2, PhaseTable } from "./artifactV2";
|
||||
|
||||
const CODE_CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
|
||||
function checkBadgeVariant(status: string): "default" | "destructive" | "outline" {
|
||||
if (status === "ok") return "default";
|
||||
if (status === "fail") return "destructive";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
function Check({ check }: { check: PhaseCheck }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant={checkBadgeVariant(check.status)}>{check.status}</Badge>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-foreground/90">{check.label}</span>
|
||||
{check.detail && <span className="text-xs text-muted-foreground">{check.detail}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionItemRow({ item }: { item: PhaseSectionItem }) {
|
||||
const tableColumn = item.table && item.column ? `${item.table}.${item.column}` : item.table ?? item.column;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-foreground/90">{item.label}</span>
|
||||
{tableColumn && <span className={CODE_CHIP}>{tableColumn}</span>}
|
||||
{item.value && <Badge variant="outline">{item.value}</Badge>}
|
||||
</div>
|
||||
{item.description && <p className="text-xs text-muted-foreground">{item.description}</p>}
|
||||
{item.rationale && <p className="text-xs italic text-muted-foreground">{item.rationale}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ section }: { section: PhaseSection }) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1.5">{section.title}</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{section.items.map((item, i) => (
|
||||
<SectionItemRow key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRecap({ table }: { table: PhaseTable }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{table.name}</span>
|
||||
<Badge variant={table.role === "promoted" ? "default" : "outline"}>{table.role}</Badge>
|
||||
</div>
|
||||
{table.description && <p className="text-xs text-muted-foreground">{table.description}</p>}
|
||||
{table.columns && table.columns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{table.columns.map((c) => (
|
||||
<div key={c.name} className="flex flex-col">
|
||||
<span className={CODE_CHIP}>{c.name}</span>
|
||||
{c.description && <span className="text-xs text-muted-foreground">{c.description}</span>}
|
||||
{c.value_filter && <span className={CODE_CHIP}>{c.value_filter}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhaseSummaryViewer({ phase }: { phase: PhaseSummaryV2 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3 className="thot-label">
|
||||
{phase.phase.id} — {phase.phase.name}
|
||||
</h3>
|
||||
|
||||
{phase.summary && <MarkdownView source={phase.summary} />}
|
||||
|
||||
{phase.checks && phase.checks.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{phase.checks.map((c, i) => (
|
||||
<Check key={i} check={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.sections && phase.sections.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{phase.sections.map((s, i) => (
|
||||
<Section key={i} section={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.tables && phase.tables.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{phase.tables.map((t) => (
|
||||
<TableRecap key={t.name} table={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase.open_questions && phase.open_questions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Open questions</h4>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-foreground/90 marker:text-muted-foreground">
|
||||
{phase.open_questions.map((q, i) => (
|
||||
<li key={i}>{q}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PreviewGrid } from "./PreviewGrid";
|
||||
|
||||
test("renders grid cells for the given columns and rows", () => {
|
||||
render(
|
||||
<PreviewGrid columns={["name", "age"]} rows={[["Alice", 30], ["Bob", 25]]} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bob")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
expect(screen.getByText("25")).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
|
||||
// Register AG Grid modules once at module load time
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
interface Props {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
}
|
||||
|
||||
/** Reusable read-only preview grid: columns + row tuples → AG Grid. */
|
||||
export function PreviewGrid({ columns, rows }: Props) {
|
||||
const columnDefs = columns.map((col) => ({ field: col, headerName: col }));
|
||||
|
||||
const rowData = rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 shadow-xs">
|
||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact columnDefs={columnDefs} rowData={rowData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
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]);
|
||||
import { PreviewGrid } from "./PreviewGrid";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
@@ -26,20 +22,6 @@ export function ResultsPanel({ sessionId }: Props) {
|
||||
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 */}
|
||||
@@ -76,11 +58,7 @@ export function ResultsPanel({ sessionId }: Props) {
|
||||
{/* Grid for everything else */}
|
||||
{data && !isScalar && (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-xl border border-border/70 shadow-xs">
|
||||
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact columnDefs={columnDefs} rowData={rowData} />
|
||||
</div>
|
||||
</div>
|
||||
<PreviewGrid columns={data.columns} rows={data.rows} />
|
||||
{data.truncated && <div>(results truncated)</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// TypeScript mirrors of the v2 artifact payload contracts (see
|
||||
// .superpowers/sdd/review-gates-v2/contracts.md). Detection everywhere is
|
||||
// `data.schema_version === 2` + a shape-guard; anything else falls back to the
|
||||
// legacy renderers in ArtifactView.
|
||||
|
||||
// --- A. cte_plan v2 ---------------------------------------------------------
|
||||
|
||||
export interface CtePlanTable {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CtePlanFilter {
|
||||
column: string;
|
||||
op: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
rationale?: string;
|
||||
}
|
||||
|
||||
export interface CtePlanCte {
|
||||
name: string;
|
||||
index: number;
|
||||
purpose?: string;
|
||||
rationale?: string;
|
||||
depends_on?: string[];
|
||||
tables?: CtePlanTable[];
|
||||
keys?: string[];
|
||||
filters?: CtePlanFilter[];
|
||||
output_columns?: string[];
|
||||
}
|
||||
|
||||
export interface CtePlanV2 {
|
||||
schema_version: 2;
|
||||
question?: string;
|
||||
strategy?: string;
|
||||
ctes: CtePlanCte[];
|
||||
}
|
||||
|
||||
// --- B. cte_result v2 --------------------------------------------------------
|
||||
|
||||
export interface CteResultColumn {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CteResultPreview {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
}
|
||||
|
||||
export interface CteResultV2 {
|
||||
schema_version: 2;
|
||||
name: string;
|
||||
index: number;
|
||||
total: number;
|
||||
purpose?: string;
|
||||
rationale?: string;
|
||||
depends_on?: string[];
|
||||
sql: string;
|
||||
status: "ok" | "error" | string;
|
||||
execution_ms?: number;
|
||||
row_sample?: number;
|
||||
warnings?: string[];
|
||||
sql_hash?: string;
|
||||
columns?: CteResultColumn[];
|
||||
preview?: CteResultPreview;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
// --- C. phase summary v2 -----------------------------------------------------
|
||||
|
||||
export interface PhaseMeta {
|
||||
id: string;
|
||||
num: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PhaseCheck {
|
||||
label: string;
|
||||
status: "ok" | "warn" | "fail" | string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface PhaseSectionItem {
|
||||
label: string;
|
||||
table?: string;
|
||||
column?: string;
|
||||
value?: string;
|
||||
kind?: "table" | "column" | "filter" | "join" | "metric" | "note" | string;
|
||||
description?: string;
|
||||
rationale?: string;
|
||||
}
|
||||
|
||||
export interface PhaseSection {
|
||||
title: string;
|
||||
items: PhaseSectionItem[];
|
||||
}
|
||||
|
||||
export interface PhaseTableColumn {
|
||||
name: string;
|
||||
description?: string;
|
||||
value_filter?: string;
|
||||
}
|
||||
|
||||
export interface PhaseTable {
|
||||
name: string;
|
||||
role: "promoted" | "excluded" | string;
|
||||
description?: string;
|
||||
columns?: PhaseTableColumn[];
|
||||
}
|
||||
|
||||
export interface PhaseSummaryV2 {
|
||||
schema_version: 2;
|
||||
phase: PhaseMeta;
|
||||
summary?: string;
|
||||
checks?: PhaseCheck[];
|
||||
sections?: PhaseSection[];
|
||||
tables?: PhaseTable[];
|
||||
open_questions?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user