From 1c97289ed894316eece97508c56f7071728395f2 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 7 Jul 2026 00:57:20 +0200 Subject: [PATCH] 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 --- frontend/src/viewers/ArtifactView.test.tsx | 92 +++++++++++- frontend/src/viewers/ArtifactView.tsx | 34 +++++ frontend/src/viewers/CtePlanViewer.test.tsx | 76 ++++++++++ frontend/src/viewers/CtePlanViewer.tsx | 134 ++++++++++++++++++ frontend/src/viewers/CteResultViewer.test.tsx | 123 ++++++++++++++++ frontend/src/viewers/CteResultViewer.tsx | 100 +++++++++++++ .../src/viewers/PhaseSummaryViewer.test.tsx | 92 ++++++++++++ frontend/src/viewers/PhaseSummaryViewer.tsx | 121 ++++++++++++++++ frontend/src/viewers/PreviewGrid.test.tsx | 13 ++ frontend/src/viewers/PreviewGrid.tsx | 31 ++++ frontend/src/viewers/ResultsPanel.tsx | 26 +--- frontend/src/viewers/artifactV2.ts | 121 ++++++++++++++++ 12 files changed, 937 insertions(+), 26 deletions(-) create mode 100644 frontend/src/viewers/CtePlanViewer.test.tsx create mode 100644 frontend/src/viewers/CtePlanViewer.tsx create mode 100644 frontend/src/viewers/CteResultViewer.test.tsx create mode 100644 frontend/src/viewers/CteResultViewer.tsx create mode 100644 frontend/src/viewers/PhaseSummaryViewer.test.tsx create mode 100644 frontend/src/viewers/PhaseSummaryViewer.tsx create mode 100644 frontend/src/viewers/PreviewGrid.test.tsx create mode 100644 frontend/src/viewers/PreviewGrid.tsx create mode 100644 frontend/src/viewers/artifactV2.ts diff --git a/frontend/src/viewers/ArtifactView.test.tsx b/frontend/src/viewers/ArtifactView.test.tsx index 2fe33ffc..f86dd405 100644 --- a/frontend/src/viewers/ArtifactView.test.tsx +++ b/frontend/src/viewers/ArtifactView.test.tsx @@ -5,14 +5,20 @@ vi.mock("./mermaid", () => ({ renderMermaid: vi.fn().mockResolvedValue(''), })); -test("sql artifact renders the SQL text", () => { +vi.mock("./highlight", () => ({ + highlightSql: vi.fn().mockResolvedValue('
SQL
'), +})); + +test("sql artifact renders the SQL text", async () => { render(); 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(); 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( + , + ); + 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(); + expect(screen.getByText("a_cte")).toBeInTheDocument(); + expect(screen.getByText("b_cte")).toBeInTheDocument(); +}); + +test("cte_result v2 dispatches to CteResultViewer", async () => { + render( + , + ); + 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(); + await screen.findByTestId("hl"); + expect(screen.queryByText("CTE")).not.toBeInTheDocument(); +}); + +test("phase v2 dispatches to PhaseSummaryViewer", () => { + render( + , + ); + 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( + , + ); + expect(screen.getByText("Recap")).toBeInTheDocument(); + expect(screen.getByText("Chiarimenti risolti")).toBeInTheDocument(); +}); diff --git a/frontend/src/viewers/ArtifactView.tsx b/frontend/src/viewers/ArtifactView.tsx index 0241c574..5f6efa41 100644 --- a/frontend/src/viewers/ArtifactView.tsx +++ b/frontend/src/viewers/ArtifactView.tsx @@ -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 ; } + if (kind === "cte_result") { + const resultV2 = toCteResultV2(data); + if (resultV2) return ; + } if (kind === "sql" || kind === "cte_result") { const blocks = toSqlBlocks(data); if (blocks) return ; } if (kind === "cte_plan") { + const planV2 = toCtePlanV2(data); + if (planV2) return ; 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 ; + } if (kind === "question" || kind === "phase") { const md = toMarkdown(data); if (md !== null) return ; diff --git a/frontend/src/viewers/CtePlanViewer.test.tsx b/frontend/src/viewers/CtePlanViewer.test.tsx new file mode 100644 index 00000000..78d38b3f --- /dev/null +++ b/frontend/src/viewers/CtePlanViewer.test.tsx @@ -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(); + + 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(); + + 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(); + expect(screen.getByText(/no dependencies/i)).toBeInTheDocument(); +}); + +test("renders depends_on chips for a CTE that has dependencies", () => { + render(); + // "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(); + 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); +}); diff --git a/frontend/src/viewers/CtePlanViewer.tsx b/frontend/src/viewers/CtePlanViewer.tsx new file mode 100644 index 00000000..674d1372 --- /dev/null +++ b/frontend/src/viewers/CtePlanViewer.tsx @@ -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 {children}; +} + +function FilterChip({ filter }: { filter: CtePlanFilter }) { + return ( +
+ + {filter.column} {filter.op} {filter.value} + + {filter.description &&

{filter.description}

} + {filter.rationale &&

{filter.rationale}

} +
+ ); +} + +function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) { + const dependsOn = cte.depends_on ?? []; + + return ( + + +
+ + CTE {cte.index}/{total} + + {cte.name} +
+ {cte.purpose &&

{cte.purpose}

} +
+ +
+

Depends on

+ {dependsOn.length ? ( +
+ {dependsOn.map((d) => ( + {d} + ))} +
+ ) : ( +

no dependencies

+ )} +
+ + {cte.tables && cte.tables.length > 0 && ( +
+

Tables

+
+ {cte.tables.map((t) => ( +
+ {t.name} + {t.description && ( +

{t.description}

+ )} +
+ ))} +
+
+ )} + + {cte.keys && cte.keys.length > 0 && ( +
+

Keys

+
+ {cte.keys.map((k) => ( + {k} + ))} +
+
+ )} + + {cte.filters && cte.filters.length > 0 && ( +
+

Filters

+
+ {cte.filters.map((f, i) => ( + + ))} +
+
+ )} + + {cte.output_columns && cte.output_columns.length > 0 && ( +
+

Output columns

+
+ {cte.output_columns.map((c) => ( + {c} + ))} +
+
+ )} + + {cte.rationale && ( +
+

Rationale

+

{cte.rationale}

+
+ )} +
+
+ ); +} + +export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) { + return ( +
+ {plan.question &&

{plan.question}

} + {plan.strategy &&

{plan.strategy}

} + +
+ {plan.ctes.map((c, i) => ( + + {i > 0 && } + {c.name} + + ))} +
+ +
+ {plan.ctes.map((cte) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/viewers/CteResultViewer.test.tsx b/frontend/src/viewers/CteResultViewer.test.tsx new file mode 100644 index 00000000..e56508c1 --- /dev/null +++ b/frontend/src/viewers/CteResultViewer.test.tsx @@ -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('
SQL
'), +})); + +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(); + 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(); + await screen.findByTestId("hl"); + expect(screen.getByText("colonna troncata")).toBeInTheDocument(); +}); + +test("passes sql to SqlViewer with mapped test status", async () => { + render(); + 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(); + await screen.findByTestId("hl"); + expect(screen.getByText(/failed/i)).toBeInTheDocument(); +}); + +test("renders PreviewGrid rows when preview is present", async () => { + render(); + 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(); + 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(); + await screen.findByTestId("hl"); + expect(screen.getByText(/no preview rows/i)).toBeInTheDocument(); +}); + +test("renders columns definition list with descriptions", async () => { + const { container } = render(); + 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(); + await screen.findByTestId("hl"); + // No
definition list should render when all descriptions are empty. + expect(container.querySelector("dl")).not.toBeInTheDocument(); +}); + +test("renders note at the end", async () => { + render(); + 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(); + 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(); + await screen.findByTestId("hl"); + expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument(); +}); diff --git a/frontend/src/viewers/CteResultViewer.tsx b/frontend/src/viewers/CteResultViewer.tsx new file mode 100644 index 00000000..23f8f2a7 --- /dev/null +++ b/frontend/src/viewers/CteResultViewer.tsx @@ -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 ( +
+
+ + CTE {result.index}/{result.total} + + {result.name} + {statusLabel(result.status)} + {result.execution_ms !== undefined && ( + {result.execution_ms} ms + )} + {result.row_sample !== undefined && ( + {result.row_sample} rows + )} +
+ + {result.warnings && result.warnings.length > 0 && ( +
+ {result.warnings.map((w, i) => ( + + {w} + + ))} +
+ )} + + {(result.purpose || result.rationale || dependsOn.length > 0) && ( +
+ {result.purpose &&

{result.purpose}

} + {result.rationale &&

{result.rationale}

} + {dependsOn.length > 0 && ( +
+ {dependsOn.map((d) => ( + {d} + ))} +
+ )} +
+ )} + + + + {hasPreviewRows ? ( + + ) : ( +

No preview rows

+ )} + + {showColumns && ( +
+ {result.columns!.map((c) => ( +
+
{c.name}
+
{c.description}
+
+ ))} +
+ )} + + {result.note &&

{result.note}

} +
+ ); +} diff --git a/frontend/src/viewers/PhaseSummaryViewer.test.tsx b/frontend/src/viewers/PhaseSummaryViewer.test.tsx new file mode 100644 index 00000000..d3380f1a --- /dev/null +++ b/frontend/src/viewers/PhaseSummaryViewer.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument(); + expect(screen.getByText(/F1/)).toBeInTheDocument(); +}); diff --git a/frontend/src/viewers/PhaseSummaryViewer.tsx b/frontend/src/viewers/PhaseSummaryViewer.tsx new file mode 100644 index 00000000..51fc8a35 --- /dev/null +++ b/frontend/src/viewers/PhaseSummaryViewer.tsx @@ -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 ( +
+ {check.status} +
+ {check.label} + {check.detail && {check.detail}} +
+
+ ); +} + +function SectionItemRow({ item }: { item: PhaseSectionItem }) { + const tableColumn = item.table && item.column ? `${item.table}.${item.column}` : item.table ?? item.column; + return ( +
+
+ {item.label} + {tableColumn && {tableColumn}} + {item.value && {item.value}} +
+ {item.description &&

{item.description}

} + {item.rationale &&

{item.rationale}

} +
+ ); +} + +function Section({ section }: { section: PhaseSection }) { + return ( +
+

{section.title}

+
+ {section.items.map((item, i) => ( + + ))} +
+
+ ); +} + +function TableRecap({ table }: { table: PhaseTable }) { + return ( +
+
+ {table.name} + {table.role} +
+ {table.description &&

{table.description}

} + {table.columns && table.columns.length > 0 && ( +
+ {table.columns.map((c) => ( +
+ {c.name} + {c.description && {c.description}} + {c.value_filter && {c.value_filter}} +
+ ))} +
+ )} +
+ ); +} + +export function PhaseSummaryViewer({ phase }: { phase: PhaseSummaryV2 }) { + return ( +
+

+ {phase.phase.id} — {phase.phase.name} +

+ + {phase.summary && } + + {phase.checks && phase.checks.length > 0 && ( +
+ {phase.checks.map((c, i) => ( + + ))} +
+ )} + + {phase.sections && phase.sections.length > 0 && ( +
+ {phase.sections.map((s, i) => ( +
+ ))} +
+ )} + + {phase.tables && phase.tables.length > 0 && ( +
+ {phase.tables.map((t) => ( + + ))} +
+ )} + + {phase.open_questions && phase.open_questions.length > 0 && ( +
+

Open questions

+
    + {phase.open_questions.map((q, i) => ( +
  • {q}
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/viewers/PreviewGrid.test.tsx b/frontend/src/viewers/PreviewGrid.test.tsx new file mode 100644 index 00000000..8251ce2d --- /dev/null +++ b/frontend/src/viewers/PreviewGrid.test.tsx @@ -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( + + ); + + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + expect(screen.getByText("30")).toBeInTheDocument(); + expect(screen.getByText("25")).toBeInTheDocument(); +}); diff --git a/frontend/src/viewers/PreviewGrid.tsx b/frontend/src/viewers/PreviewGrid.tsx new file mode 100644 index 00000000..14a173aa --- /dev/null +++ b/frontend/src/viewers/PreviewGrid.tsx @@ -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 = {}; + columns.forEach((col, i) => { + obj[col] = row[i]; + }); + return obj; + }); + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/viewers/ResultsPanel.tsx b/frontend/src/viewers/ResultsPanel.tsx index a942f688..482c039d 100644 --- a/frontend/src/viewers/ResultsPanel.tsx +++ b/frontend/src/viewers/ResultsPanel.tsx @@ -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 = {}; - data.columns.forEach((col, i) => { - obj[col] = row[i]; - }); - return obj; - }) - : []; - return (
{/* Limit selector */} @@ -76,11 +58,7 @@ export function ResultsPanel({ sessionId }: Props) { {/* Grid for everything else */} {data && !isScalar && ( <> -
-
- -
-
+ {data.truncated &&
(results truncated)
} )} diff --git a/frontend/src/viewers/artifactV2.ts b/frontend/src/viewers/artifactV2.ts new file mode 100644 index 00000000..4dee0963 --- /dev/null +++ b/frontend/src/viewers/artifactV2.ts @@ -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[]; +}