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:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
+3 -1
View File
@@ -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">
+21 -17
View File
@@ -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} />
+5 -3
View File
@@ -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 && (
+11 -6
View File
@@ -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>;
+9 -4
View File
@@ -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>
+12 -1
View File
@@ -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(
+19 -3
View File
@@ -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>
);
+15 -11
View File
@@ -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>
+2 -2
View File
@@ -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();
+9 -6
View File
@@ -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>
)}
+1
View File
@@ -93,6 +93,7 @@ export interface PhaseSectionItem {
export interface PhaseSection {
title: string;
title_i18n?: string;
items?: PhaseSectionItem[];
}
+18
View File
@@ -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();
});
+14 -5
View File
@@ -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");