From 0975a8a72beb9e6f89cd7473ca1f6e58397f06bd Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 3 Jul 2026 13:13:51 +0200 Subject: [PATCH] feat(viewer): ArtifactView routes artifact.data by kind (schema/sql/cte/question) --- frontend/src/viewers/ArtifactView.test.tsx | 39 ++++++++ frontend/src/viewers/ArtifactView.tsx | 107 +++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 frontend/src/viewers/ArtifactView.test.tsx create mode 100644 frontend/src/viewers/ArtifactView.tsx diff --git a/frontend/src/viewers/ArtifactView.test.tsx b/frontend/src/viewers/ArtifactView.test.tsx new file mode 100644 index 00000000..03342f4c --- /dev/null +++ b/frontend/src/viewers/ArtifactView.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { ArtifactView } from "./ArtifactView"; + +vi.mock("./mermaid", () => ({ + renderMermaid: vi.fn().mockResolvedValue(''), +})); + +test("sql artifact renders the SQL text", () => { + render(); + expect(screen.getByText("SELECT 1")).toBeInTheDocument(); +}); + +test("cte_plan renders an ordered list of names", () => { + render(); + expect(screen.getByText("a_cte")).toBeInTheDocument(); + expect(screen.getByText("b_cte")).toBeInTheDocument(); +}); + +test("question renders markdown headings", () => { + render(); + expect(screen.getByText("Domanda")).toBeInTheDocument(); +}); + +test("unknown kind falls back to formatted JSON", () => { + render(); + expect(screen.getByText(/"a": 1/)).toBeInTheDocument(); +}); + +test("schema_linking renders the schema viewer", async () => { + render( + , + ); + expect(await screen.findByTestId("mm")).toBeInTheDocument(); +}); diff --git a/frontend/src/viewers/ArtifactView.tsx b/frontend/src/viewers/ArtifactView.tsx new file mode 100644 index 00000000..d1b00643 --- /dev/null +++ b/frontend/src/viewers/ArtifactView.tsx @@ -0,0 +1,107 @@ +import type { ReactElement } from "react"; +import { SqlViewer, type SqlBlock } from "./SqlViewer"; +import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer"; +import { MarkdownView } from "./MarkdownView"; + +type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown }; + +function asRecord(v: unknown): Record | null { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; +} + +// The payload lives in `data` (harness contract); fall back to `content`, then the +// artifact object itself, so older/other producers still render something. +function payload(artifact: ArtifactData): unknown { + if (artifact.data !== undefined) return artifact.data; + if (artifact.content !== undefined) return artifact.content; + return artifact; +} + +function toSchemaLinking(raw: unknown): SchemaLinking | null { + let obj: unknown = raw; + if (typeof raw === "string") { + try { obj = JSON.parse(raw); } catch { return null; } + } + const rec = asRecord(obj); + if (!rec || !Array.isArray(rec.candidates)) return null; + return { + candidates: rec.candidates as SchemaLinking["candidates"], + joins: Array.isArray(rec.joins) ? (rec.joins as SchemaLinking["joins"]) : [], + excluded: Array.isArray(rec.excluded) ? (rec.excluded as SchemaLinking["excluded"]) : [], + open_questions: Array.isArray(rec.open_questions) ? (rec.open_questions as string[]) : [], + question: typeof rec.question === "string" ? rec.question : undefined, + }; +} + +function toSqlBlocks(raw: unknown): SqlBlock[] | null { + if (typeof raw === "string") return [{ name: "SQL", sql: raw }]; + const rec = asRecord(raw); + if (!rec) return null; + if (typeof rec.sql === "string") return [{ name: "SQL", sql: rec.sql }]; + if (Array.isArray(rec.ctes)) { + const blocks = rec.ctes + .map((c) => asRecord(c)) + .filter((c): c is Record => !!c && typeof c.sql === "string") + .map((c) => ({ name: typeof c.name === "string" ? c.name : "cte", sql: c.sql as string })); + if (typeof rec.final === "string") blocks.push({ name: "final", sql: rec.final }); + return blocks.length ? blocks : null; + } + return null; +} + +function toMarkdown(raw: unknown): string | null { + if (typeof raw === "string") return raw; + const rec = asRecord(raw); + if (!rec) return null; + for (const k of ["markdown", "question", "text"]) { + if (typeof rec[k] === "string") return rec[k] as string; + } + return null; +} + +function cteNames(raw: unknown): string[] | null { + if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[]; + const rec = asRecord(raw); + if (rec && Array.isArray(rec.names) && rec.names.every((x) => typeof x === "string")) { + return rec.names as string[]; + } + return null; +} + +function JsonFallback({ value }: { value: unknown }): ReactElement { + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2); + return ( +
{text}
+ ); +} + +export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactElement { + const kind = artifact.kind ?? ""; + const data = payload(artifact); + + if (kind === "schema_linking") { + const linking = toSchemaLinking(data); + if (linking) return ; + } + if (kind === "sql" || kind === "cte_result") { + const blocks = toSqlBlocks(data); + if (blocks) return ; + } + if (kind === "cte_plan") { + const names = cteNames(data); + if (names) { + return ( +
    + {names.map((n, i) => ( +
  1. {n}
  2. + ))} +
+ ); + } + } + if (kind === "question" || kind === "phase") { + const md = toMarkdown(data); + if (md !== null) return ; + } + return ; +}