Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
||||
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
@@ -105,6 +106,7 @@ function humanizeKey(k: string): string {
|
||||
* visual hierarchy: top-level sections are split by hairline rules, nested fields
|
||||
* sit tighter, and object-arrays become grouped cards. */
|
||||
function StructuredValue({ value, depth = 0 }: { value: unknown; depth?: number }): ReactElement {
|
||||
const { t } = useI18n();
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return <p className="text-sm text-muted-foreground">—</p>;
|
||||
}
|
||||
@@ -136,7 +138,7 @@ function StructuredValue({ value, depth = 0 }: { value: unknown; depth?: number
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No artifact content available.</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("No artifact content available.")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "../components/ui/card";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
@@ -12,27 +13,28 @@ function Chip({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`Filter ${index}`}
|
||||
aria-label={t("Filter {index}", { index })}
|
||||
className="min-w-0 space-y-2 px-3 py-1 sm:px-4"
|
||||
>
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2 md:grid-cols-[minmax(0,1fr)_minmax(5rem,auto)_minmax(0,1fr)]">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className={FIELD_LABEL}>Column</p>
|
||||
<p className={FIELD_LABEL}>{t("Column")}</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-4 text-foreground">
|
||||
{filter.column}
|
||||
</code>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 md:text-center">
|
||||
<p className={FIELD_LABEL}>Operator</p>
|
||||
<p className={FIELD_LABEL}>{t("Operator")}</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs font-semibold leading-4 text-primary">
|
||||
{filter.op}
|
||||
</code>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className={FIELD_LABEL}>Value</p>
|
||||
<p className={FIELD_LABEL}>{t("Value")}</p>
|
||||
<code className="block min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-4 text-foreground">
|
||||
{filter.value}
|
||||
</code>
|
||||
@@ -42,7 +44,7 @@ function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number })
|
||||
<div className="grid min-w-0 gap-x-4 gap-y-1 border-t border-border/50 pt-1 md:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||
{filter.description && (
|
||||
<>
|
||||
<span className={FIELD_LABEL}>Description</span>
|
||||
<span className={FIELD_LABEL}>{t("Description")}</span>
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-4 text-muted-foreground">
|
||||
{filter.description}
|
||||
</p>
|
||||
@@ -50,7 +52,7 @@ function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number })
|
||||
)}
|
||||
{filter.rationale && (
|
||||
<>
|
||||
<span className={FIELD_LABEL}>Rationale</span>
|
||||
<span className={FIELD_LABEL}>{t("Rationale")}</span>
|
||||
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-4 text-muted-foreground">
|
||||
{filter.rationale}
|
||||
</p>
|
||||
@@ -63,6 +65,7 @@ function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number })
|
||||
}
|
||||
|
||||
function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
const { t } = useI18n();
|
||||
const dependsOn = cte.depends_on ?? [];
|
||||
|
||||
return (
|
||||
@@ -85,7 +88,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
<CardContent className="flex min-w-0 flex-col gap-3 px-3 py-2 sm:px-4">
|
||||
<div className="grid min-w-0 gap-3 md:grid-cols-2">
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-1">Depends on</h4>
|
||||
<h4 className="thot-label mb-1">{t("Depends on")}</h4>
|
||||
{dependsOn.length ? (
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{dependsOn.map((dependency) => (
|
||||
@@ -93,13 +96,13 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No dependencies</p>
|
||||
<p className="text-xs text-muted-foreground">{t("No dependencies")}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{cte.keys && cte.keys.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-1">Keys</h4>
|
||||
<h4 className="thot-label mb-1">{t("Keys")}</h4>
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{cte.keys.map((key) => (
|
||||
<Chip key={key}>{key}</Chip>
|
||||
@@ -111,7 +114,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.tables && cte.tables.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-1">Tables</h4>
|
||||
<h4 className="thot-label mb-1">{t("Tables")}</h4>
|
||||
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
|
||||
{cte.tables.map((table) => (
|
||||
<div
|
||||
@@ -134,7 +137,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.filters && cte.filters.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-1">Filters</h4>
|
||||
<h4 className="thot-label mb-1">{t("Filters")}</h4>
|
||||
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
|
||||
{cte.filters.map((f, i) => (
|
||||
<FilterRow key={`${f.column}-${i}`} filter={f} index={i + 1} />
|
||||
@@ -145,7 +148,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.output_columns && cte.output_columns.length > 0 && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="thot-label mb-1">Output columns</h4>
|
||||
<h4 className="thot-label mb-1">{t("Output columns")}</h4>
|
||||
<div className="flex min-w-0 flex-wrap gap-1.5">
|
||||
{cte.output_columns.map((column) => (
|
||||
<Chip key={column}>{column}</Chip>
|
||||
@@ -156,7 +159,7 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
|
||||
{cte.rationale && (
|
||||
<section className="min-w-0 border-t border-border/60 pt-1">
|
||||
<h4 className="thot-label mb-1">Rationale</h4>
|
||||
<h4 className="thot-label mb-1">{t("Rationale")}</h4>
|
||||
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-5 text-foreground/90">
|
||||
{cte.rationale}
|
||||
</p>
|
||||
@@ -168,12 +171,13 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
||||
}
|
||||
|
||||
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{plan.question && (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="thot-label">Question</p>
|
||||
<p className="thot-label">{t("Question")}</p>
|
||||
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-muted-foreground">
|
||||
{plan.question}
|
||||
</p>
|
||||
@@ -181,7 +185,7 @@ export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||
)}
|
||||
{plan.strategy && (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="thot-label">Strategy</p>
|
||||
<p className="thot-label">{t("Strategy")}</p>
|
||||
<p className="thot-prose whitespace-pre-wrap [overflow-wrap:anywhere] leading-relaxed">
|
||||
{plan.strategy}
|
||||
</p>
|
||||
@@ -189,7 +193,7 @@ export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||
)}
|
||||
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="thot-label">Execution order</p>
|
||||
<p className="thot-label">{t("Execution order")}</p>
|
||||
<div className="flex min-w-0 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 min-w-0 max-w-full items-center gap-1.5">
|
||||
@@ -203,7 +207,7 @@ export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol aria-label="CTE plan steps" className="flex flex-col gap-3">
|
||||
<ol aria-label={t("CTE plan steps")} className="flex flex-col gap-3">
|
||||
{plan.ctes.map((cte) => (
|
||||
<li key={cte.name} className="min-w-0">
|
||||
<CteCard cte={cte} total={plan.ctes.length} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
||||
import { PreviewGrid } from "./PreviewGrid";
|
||||
import { statusBadgeClass } from "./statusBadge";
|
||||
import type { CteResultV2 } from "./artifactV2";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
|
||||
@@ -19,6 +20,7 @@ function testStatus(status: string): SqlBlock["testStatus"] {
|
||||
}
|
||||
|
||||
export function CteResultViewer({ result }: { result: CteResultV2 }) {
|
||||
const { t } = useI18n();
|
||||
const hasPreviewRows = (result.preview?.rows?.length ?? 0) > 0;
|
||||
const descriptions = (result.columns ?? []).map((c) => c.description ?? "");
|
||||
const showColumns = descriptions.some((d) => d.trim() !== "");
|
||||
@@ -37,12 +39,12 @@ export function CteResultViewer({ result }: { result: CteResultV2 }) {
|
||||
CTE {result.index}/{result.total}
|
||||
</Badge>
|
||||
<span className="font-mono text-sm font-semibold">{result.name}</span>
|
||||
<Badge variant="outline" className={statusBadgeClass(result.status)}>{statusLabel(result.status)}</Badge>
|
||||
<Badge variant="outline" className={statusBadgeClass(result.status)}>{t(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>
|
||||
<span className="text-xs text-muted-foreground">{t(result.row_sample === 1 ? "{count} row" : "{count} rows", { count: result.row_sample })}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,7 +77,7 @@ export function CteResultViewer({ result }: { result: CteResultV2 }) {
|
||||
{hasPreviewRows ? (
|
||||
<PreviewGrid columns={result.preview!.columns} rows={result.preview!.rows} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No preview rows</p>
|
||||
<p className="text-sm text-muted-foreground">{t("No preview rows")}</p>
|
||||
)}
|
||||
|
||||
{showColumns && (
|
||||
|
||||
@@ -3,18 +3,23 @@ import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { renderMermaid } from "./mermaid";
|
||||
import { useShell } from "../shell/host/ShellProvider";
|
||||
|
||||
function MermaidBlock({ code }: { code: string }) {
|
||||
const { theme } = useShell();
|
||||
const [svg, setSvg] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
renderMermaid(code)
|
||||
.then(setSvg)
|
||||
.catch((e: unknown) =>
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
);
|
||||
}, [code]);
|
||||
let cancelled = false;
|
||||
setError("");
|
||||
renderMermaid(code, theme)
|
||||
.then((value) => { if (!cancelled) setSvg(value); })
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [code, theme]);
|
||||
|
||||
if (error) return <pre className="overflow-auto rounded-lg bg-muted p-3 text-xs opacity-60">{code}</pre>;
|
||||
if (!svg) return <pre className="text-xs opacity-50">{code}</pre>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
import { statusBadgeClass } from "./statusBadge";
|
||||
import { useI18n } from "../i18n";
|
||||
import {
|
||||
phaseOpenQuestionText,
|
||||
type PhaseCheck,
|
||||
@@ -13,9 +14,10 @@ import {
|
||||
const CODE_CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||
|
||||
function Check({ check }: { check: PhaseCheck }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Badge variant="outline" className={statusBadgeClass(check.status)}>{check.status}</Badge>
|
||||
<Badge variant="outline" className={statusBadgeClass(check.status)}>{t(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>}
|
||||
@@ -40,9 +42,10 @@ function SectionItemRow({ item }: { item: PhaseSectionItem }) {
|
||||
}
|
||||
|
||||
function Section({ section }: { section: PhaseSection }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1.5">{section.title}</h4>
|
||||
<h4 className="thot-label mb-1.5">{section.title_i18n ? t(section.title_i18n) : section.title}</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(section.items ?? []).map((item, i) => (
|
||||
<SectionItemRow key={i} item={item} />
|
||||
@@ -53,11 +56,12 @@ function Section({ section }: { section: PhaseSection }) {
|
||||
}
|
||||
|
||||
function TableRecap({ table }: { table: PhaseTable }) {
|
||||
const { t } = useI18n();
|
||||
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="outline" className={table.role === "promoted" ? statusBadgeClass(table.role) : undefined}>{table.role}</Badge>
|
||||
<Badge variant="outline" className={table.role === "promoted" ? statusBadgeClass(table.role) : undefined}>{t(table.role)}</Badge>
|
||||
</div>
|
||||
{table.description && <p className="text-xs text-muted-foreground">{table.description}</p>}
|
||||
{table.columns && table.columns.length > 0 && (
|
||||
@@ -76,6 +80,7 @@ function TableRecap({ table }: { table: PhaseTable }) {
|
||||
}
|
||||
|
||||
export function PhaseSummaryViewer({ phase }: { phase: PhaseSummaryV2 }) {
|
||||
const { t } = useI18n();
|
||||
const openQuestions = Array.isArray(phase.open_questions)
|
||||
? (phase.open_questions as unknown[])
|
||||
.map(phaseOpenQuestionText)
|
||||
@@ -116,7 +121,7 @@ export function PhaseSummaryViewer({ phase }: { phase: PhaseSummaryV2 }) {
|
||||
|
||||
{openQuestions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="thot-label mb-1">Open questions</h4>
|
||||
<h4 className="thot-label mb-1">{t("Open questions")}</h4>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-foreground/90 marker:text-muted-foreground">
|
||||
{openQuestions.map((q, i) => (
|
||||
<li key={i}>{q}</li>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { PreviewGrid } from "./PreviewGrid";
|
||||
import { setLocale } from "../i18n";
|
||||
|
||||
afterEach(() => setLocale("en"));
|
||||
|
||||
test("the grid's built-in empty state follows a language change", async () => {
|
||||
render(<PreviewGrid columns={["patient_id"]} rows={[]} />);
|
||||
expect(await screen.findByText("No Rows To Show")).toBeInTheDocument();
|
||||
act(() => setLocale("it"));
|
||||
expect(await screen.findByText("Nessuna riga da mostrare")).toBeInTheDocument();
|
||||
expect(screen.getByText("patient_id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders grid cells for the given columns and rows", () => {
|
||||
render(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { AllCommunityModule, ModuleRegistry } from "ag-grid-community";
|
||||
import { useRef } from "react";
|
||||
import { AllCommunityModule, ModuleRegistry, type GridState } from "ag-grid-community";
|
||||
import { AG_GRID_LOCALE_EN, AG_GRID_LOCALE_IT } from "@ag-grid-community/locale";
|
||||
import { useI18n } from "../i18n";
|
||||
import "ag-grid-community/styles/ag-grid.css";
|
||||
import "ag-grid-community/styles/ag-theme-alpine.css";
|
||||
|
||||
// Register AG Grid modules once at module load time
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
@@ -11,6 +16,9 @@ interface Props {
|
||||
|
||||
/** Reusable read-only preview grid: columns + row tuples → AG Grid. */
|
||||
export function PreviewGrid({ columns, rows }: Props) {
|
||||
const { locale } = useI18n();
|
||||
const gridMessages: Readonly<Record<string, string>> = locale === "it" ? AG_GRID_LOCALE_IT : AG_GRID_LOCALE_EN;
|
||||
const gridState = useRef<GridState>();
|
||||
const columnDefs = columns.map((col) => ({ field: col, headerName: col }));
|
||||
|
||||
const rowData = rows.map((row) => {
|
||||
@@ -23,8 +31,16 @@ export function PreviewGrid({ columns, rows }: Props) {
|
||||
|
||||
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 className="ag-theme-alpine thot-preview-grid" style={{ height: 400, width: "100%" }}>
|
||||
<AgGridReact
|
||||
key={locale}
|
||||
theme="legacy"
|
||||
columnDefs={columnDefs}
|
||||
rowData={rowData}
|
||||
localeText={gridMessages}
|
||||
initialState={gridState.current}
|
||||
onStateUpdated={({ state }) => { gridState.current = state; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useShell } from "../shell/host/ShellProvider";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { renderMermaid } from "./mermaid";
|
||||
|
||||
@@ -104,6 +106,8 @@ function buildHierarchicalRows(promoted: Candidate[]) {
|
||||
}
|
||||
|
||||
export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useShell();
|
||||
const promoted = linking.candidates.filter((c) => c.decision === "promoted");
|
||||
// The graph only contains promoted candidates, so cap on that count.
|
||||
const oversized = promoted.length > CAP;
|
||||
@@ -116,14 +120,14 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
if (view !== "chart" || oversized) return;
|
||||
let cancelled = false;
|
||||
const def = buildErDiagram(promoted, linking.joins);
|
||||
renderMermaid(def)
|
||||
renderMermaid(def, theme)
|
||||
.then((s) => { if (!cancelled) setSvg(s); })
|
||||
.catch(() => { if (!cancelled) setView("table"); });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, linking, oversized]);
|
||||
}, [view, linking, oversized, theme]);
|
||||
|
||||
const { grouped, orphans } = buildHierarchicalRows(promoted);
|
||||
|
||||
@@ -135,19 +139,19 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
disabled={oversized}
|
||||
className={`rounded-md border border-border/70 px-2 py-1 text-xs shadow-xs transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 ${view === "chart" ? "font-semibold" : ""}`}
|
||||
>
|
||||
Graph
|
||||
{t("Graph")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("table")}
|
||||
className={`rounded-md border border-border/70 px-2 py-1 text-xs shadow-xs transition-colors hover:bg-accent ${view === "table" ? "font-semibold" : ""}`}
|
||||
>
|
||||
Table
|
||||
{t("Table")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{oversized && (
|
||||
<p className="text-sm text-amber-600">
|
||||
Too many nodes to display in the graph. Use the table instead.
|
||||
{t("Too many nodes to display in the graph. Use the table instead.")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -163,9 +167,9 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left border-b py-1">Name</th>
|
||||
<th className="text-left border-b py-1">Type</th>
|
||||
<th className="text-left border-b py-1">Why</th>
|
||||
<th className="text-left border-b py-1">{t("Name")}</th>
|
||||
<th className="text-left border-b py-1">{t("Type")}</th>
|
||||
<th className="text-left border-b py-1">{t("Why")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -173,7 +177,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
<React.Fragment key={table.name}>
|
||||
<tr className="font-semibold bg-muted/30">
|
||||
<td className="py-1 pl-1">{table.name}</td>
|
||||
<td>table</td>
|
||||
<td>{t("table")}</td>
|
||||
<td className="text-xs text-muted-foreground">
|
||||
{reasonFor(table)}
|
||||
</td>
|
||||
@@ -183,7 +187,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
<td className="py-0.5 pl-4 text-muted-foreground">
|
||||
{col.name}
|
||||
</td>
|
||||
<td>column</td>
|
||||
<td>{t("column")}</td>
|
||||
<td className="text-xs text-muted-foreground">
|
||||
{reasonFor(col)}
|
||||
</td>
|
||||
@@ -196,7 +200,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
|
||||
<td className="py-0.5 pl-1 text-muted-foreground">
|
||||
{col.name}
|
||||
</td>
|
||||
<td>column</td>
|
||||
<td>{t("column")}</td>
|
||||
<td className="text-xs text-muted-foreground">
|
||||
{reasonFor(col)}
|
||||
</td>
|
||||
|
||||
@@ -43,8 +43,8 @@ test("(a) renders headers with name, field count, and test-status badge", async
|
||||
expect(screen.getByText("final_query")).toBeInTheDocument();
|
||||
|
||||
// Field counts
|
||||
expect(screen.getByText(/5 campi/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 campi/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/5 fields/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 fields/i)).toBeInTheDocument();
|
||||
|
||||
// Test-status badges
|
||||
expect(screen.getByText(/passed/i)).toBeInTheDocument();
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { Check, Copy, TriangleAlert } from "lucide-react";
|
||||
import { highlightSql } from "./highlight";
|
||||
import { statusBadgeClass } from "./statusBadge";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export interface SqlBlock {
|
||||
name: string;
|
||||
@@ -20,6 +21,7 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
function Block({ block }: { block: SqlBlock }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
@@ -61,21 +63,21 @@ function Block({ block }: { block: SqlBlock }) {
|
||||
<span className="truncate font-semibold">{block.name}</span>
|
||||
{block.fields !== undefined && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{block.fields} campi
|
||||
{t(block.fields === 1 ? "{count} field" : "{count} fields", { count: block.fields })}
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
className={`ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-xs ${badgeClass}`}
|
||||
>
|
||||
{badge}
|
||||
{t(badge)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Copy SQL for ${block.name}`}
|
||||
title="Copy SQL"
|
||||
aria-label={t("Copy SQL for {name}", { name: block.name })}
|
||||
title={t("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"
|
||||
>
|
||||
@@ -87,7 +89,7 @@ function Block({ block }: { block: SqlBlock }) {
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span role="status" aria-live="polite">
|
||||
{copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : ""}
|
||||
{copyState === "copied" ? t("Copied") : copyState === "failed" ? t("Copy failed") : ""}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -117,6 +119,7 @@ function Block({ block }: { block: SqlBlock }) {
|
||||
}
|
||||
|
||||
export function SqlViewer({ blocks }: { blocks: SqlBlock[] }) {
|
||||
const { t } = useI18n();
|
||||
const [layout, setLayout] = useState<Layout>("vertical");
|
||||
|
||||
return (
|
||||
@@ -129,7 +132,7 @@ export function SqlViewer({ blocks }: { blocks: SqlBlock[] }) {
|
||||
setLayout((l) => (l === "vertical" ? "horizontal" : "vertical"))
|
||||
}
|
||||
>
|
||||
{layout === "vertical" ? "Horizontal" : "Vertical"}
|
||||
{t(layout === "vertical" ? "Horizontal" : "Vertical")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -93,6 +93,7 @@ export interface PhaseSectionItem {
|
||||
|
||||
export interface PhaseSection {
|
||||
title: string;
|
||||
title_i18n?: string;
|
||||
items?: PhaseSectionItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { setLocale } from "../i18n";
|
||||
import { SqlViewer } from "./SqlViewer";
|
||||
|
||||
afterEach(() => setLocale("en"));
|
||||
|
||||
test("SQL controls follow the UI language while SQL and identifiers remain unchanged", () => {
|
||||
setLocale("it");
|
||||
const sql = "SELECT patient_id FROM admissions";
|
||||
render(<SqlViewer blocks={[{ name: "admissions", fields: 2, sql }]} />);
|
||||
expect(screen.getByRole("button", { name: "Copia SQL per admissions" })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 campi")).toBeInTheDocument();
|
||||
expect(screen.getByText(sql)).toBeInTheDocument();
|
||||
act(() => setLocale("en"));
|
||||
expect(screen.getByRole("button", { name: "Copy SQL for admissions" })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 fields")).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,10 +1,19 @@
|
||||
let _initialized = false;
|
||||
let initializedTheme: "light" | "dark" | undefined;
|
||||
let rendering: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export async function renderMermaid(def: string): Promise<string> {
|
||||
export function renderMermaid(def: string, theme: "light" | "dark" = "light"): Promise<string> {
|
||||
// Mermaid configuration is global: serialize renders so a theme change cannot
|
||||
// recolor another diagram midway through its asynchronous rendering.
|
||||
const result = rendering.then(() => renderDiagram(def, theme));
|
||||
rendering = result.catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function renderDiagram(def: string, theme: "light" | "dark"): Promise<string> {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
if (!_initialized) {
|
||||
mermaid.initialize({ startOnLoad: false, theme: "default" });
|
||||
_initialized = true;
|
||||
if (initializedTheme !== theme) {
|
||||
mermaid.initialize({ startOnLoad: false, theme: theme === "dark" ? "dark" : "default" });
|
||||
initializedTheme = theme;
|
||||
}
|
||||
const id = `mermaid-${Math.random().toString(36).slice(2)}`;
|
||||
const container = document.createElement("div");
|
||||
|
||||
Reference in New Issue
Block a user