feat(frontend): English UI + 'Datamart Builder with Human In The Loop' tagline

The interface is now fully English regardless of the chat language (session
titles stay in whatever language the user typed). Changed the right-rail
subtitle to 'Datamart Builder with Human In The Loop'. Translated all chrome:
new-question/settings dialogs, workflow phase strip (F1-F8), session rail,
steer input, widgets (select/multiselect/freetext/fallback/reserved controls)
and viewers (SQL, results, schema linking). Test matchers updated in lockstep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 18:27:11 +02:00
co-authored by Claude Opus 4.8
parent acb674b4bc
commit bcaf6708de
27 changed files with 98 additions and 97 deletions
+1 -1
View File
@@ -3,5 +3,5 @@ import { App } from "./App";
test("App renders the shell with create affordance", () => { test("App renders the shell with create affordance", () => {
render(<App />); render(<App />);
expect(screen.getByRole("button", { name: /nuova/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /new/i })).toBeInTheDocument();
}); });
+5 -4
View File
@@ -61,7 +61,7 @@ export function AppShell() {
Thoth<span className="text-primary">II</span> Thoth<span className="text-primary">II</span>
</h1> </h1>
<p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground"> <p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground">
Query in linguaggio naturale Datamart Builder with Human In The Loop
</p> </p>
</div> </div>
@@ -72,7 +72,7 @@ export function AppShell() {
<div className="px-4 pb-1.5 pt-1"> <div className="px-4 pb-1.5 pt-1">
<span className="text-[0.7rem] font-bold uppercase tracking-[0.12em] text-primary"> <span className="text-[0.7rem] font-bold uppercase tracking-[0.12em] text-primary">
Sessioni Sessions
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto px-2 pb-4"> <div className="flex-1 overflow-y-auto px-2 pb-4">
@@ -95,8 +95,9 @@ function EmptyState() {
Thoth<span className="text-primary">II</span> Thoth<span className="text-primary">II</span>
</span> </span>
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground"> <p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
Interroga il tuo data warehouse in linguaggio naturale. Crea una nuova Build datamarts from your data warehouse through a guided,
domanda dal pannello a destra per avviare una sessione guidata. human-in-the-loop workflow. Start a new question from the panel on the
right to begin a session.
</p> </p>
</div> </div>
); );
+1 -1
View File
@@ -26,7 +26,7 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<p className="px-1 py-6 text-center text-xs text-muted-foreground"> <p className="px-1 py-6 text-center text-xs text-muted-foreground">
Nessuna sessione. Inizia con <span className="font-medium text-foreground">Nuova domanda</span>. No sessions yet. Start with <span className="font-medium text-foreground">New question</span>.
</p> </p>
); );
} }
+9 -9
View File
@@ -19,10 +19,10 @@ function renderDialog() {
test("the form has only a question field (no workspace/model/provider/thinking)", async () => { test("the form has only a question field (no workspace/model/provider/thinking)", async () => {
renderDialog(); renderDialog();
await userEvent.click(screen.getByRole("button", { name: /nuova/i })); await userEvent.click(screen.getByRole("button", { name: /new/i }));
expect(await screen.findByLabelText(/domanda/i)).toBeInTheDocument(); expect(await screen.findByLabelText(/question/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/workspace/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/workspace/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/modello/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/model/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/provider/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/provider/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
}); });
@@ -36,9 +36,9 @@ test("submitting posts only { question } and calls onCreated", async () => {
}), }),
); );
const { onCreated } = renderDialog(); const { onCreated } = renderDialog();
await userEvent.click(screen.getByRole("button", { name: /nuova/i })); await userEvent.click(screen.getByRole("button", { name: /new/i }));
await userEvent.type(await screen.findByLabelText(/domanda/i), "Quante vendite nel 2025?"); await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?");
await userEvent.click(screen.getByRole("button", { name: /^crea$/i })); await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" })); await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" }));
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1")); await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
@@ -46,7 +46,7 @@ test("submitting posts only { question } and calls onCreated", async () => {
test("empty question shows a validation error and does not submit", async () => { test("empty question shows a validation error and does not submit", async () => {
renderDialog(); renderDialog();
await userEvent.click(screen.getByRole("button", { name: /nuova/i })); await userEvent.click(screen.getByRole("button", { name: /new/i }));
await userEvent.click(screen.getByRole("button", { name: /^crea$/i })); await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/vuota/i); expect(await screen.findByRole("alert")).toHaveTextContent(/empty/i);
}); });
+8 -8
View File
@@ -23,7 +23,7 @@ export function NewSessionDialog({ onCreated }: Props) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (!question.trim()) { if (!question.trim()) {
setError("La domanda non può essere vuota."); setError("The question cannot be empty.");
return; return;
} }
setBusy(true); setBusy(true);
@@ -33,7 +33,7 @@ export function NewSessionDialog({ onCreated }: Props) {
setQuestion(""); setQuestion("");
onCreated(id); onCreated(id);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Errore nella creazione della sessione."); setError(err instanceof Error ? err.message : "Failed to create the session.");
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -47,16 +47,16 @@ export function NewSessionDialog({ onCreated }: Props) {
className="w-full" className="w-full"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
Nuova domanda New question
</Button> </Button>
<DialogContent showCloseButton={false}> <DialogContent showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>Nuova sessione</DialogTitle> <DialogTitle>New session</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3"> <form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="nsd-question"> <label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="nsd-question">
Domanda Question
</label> </label>
<textarea <textarea
id="nsd-question" id="nsd-question"
@@ -64,7 +64,7 @@ export function NewSessionDialog({ onCreated }: Props) {
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
rows={3} rows={3}
className="w-full resize-none rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/50 focus:ring-3 focus:ring-ring/15" className="w-full resize-none rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
placeholder="Inserisci la tua domanda…" placeholder="Enter your question…"
/> />
</div> </div>
@@ -81,10 +81,10 @@ export function NewSessionDialog({ onCreated }: Props) {
size="sm" size="sm"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Annulla Cancel
</Button> </Button>
<Button type="submit" size="sm" disabled={busy}> <Button type="submit" size="sm" disabled={busy}>
{busy ? "Creazione…" : "Crea"} {busy ? "Creating…" : "Create"}
</Button> </Button>
</div> </div>
</form> </form>
+3 -3
View File
@@ -54,7 +54,7 @@ test("provider change filters the model list", async () => {
const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement; const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement;
await userEvent.selectOptions(provider, "anthropic"); await userEvent.selectOptions(provider, "anthropic");
const model = screen.getByLabelText(/modello/i) as HTMLSelectElement; const model = screen.getByLabelText(/model/i) as HTMLSelectElement;
const optionValues = Array.from(model.options).map((o) => o.value); const optionValues = Array.from(model.options).map((o) => o.value);
expect(optionValues).toContain("claude-opus-4-8"); expect(optionValues).toContain("claude-opus-4-8");
expect(optionValues).not.toContain("glm-5.2"); expect(optionValues).not.toContain("glm-5.2");
@@ -65,14 +65,14 @@ test("Salva PUTs the selected settings", async () => {
server.use( server.use(
http.put("http://localhost:8787/settings", async ({ request }) => { http.put("http://localhost:8787/settings", async ({ request }) => {
body = await request.json(); body = await request.json();
return HttpResponse.json(body); return HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" });
}), }),
); );
renderDialog(); renderDialog();
await userEvent.click(screen.getByRole("button", { name: /settings/i })); await userEvent.click(screen.getByRole("button", { name: /settings/i }));
await screen.findByLabelText(/workspace/i); await screen.findByLabelText(/workspace/i);
await userEvent.selectOptions(screen.getByLabelText(/thinking/i), "high"); await userEvent.selectOptions(screen.getByLabelText(/thinking/i), "high");
await userEvent.click(screen.getByRole("button", { name: /salva/i })); await userEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => await waitFor(() =>
expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }), expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }),
+8 -8
View File
@@ -76,7 +76,7 @@ export function SettingsDialog() {
await queryClient.invalidateQueries({ queryKey: ["settings"] }); await queryClient.invalidateQueries({ queryKey: ["settings"] });
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Errore nel salvataggio dei settings."); setError(err instanceof Error ? err.message : "Failed to save settings.");
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -133,7 +133,7 @@ export function SettingsDialog() {
}} }}
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15" className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
> >
<option value="">— tutti —</option> <option value="">— all —</option>
{providers.map((p) => ( {providers.map((p) => (
<option key={p} value={p}> <option key={p} value={p}>
{p} {p}
@@ -146,7 +146,7 @@ export function SettingsDialog() {
type="text" type="text"
value={provider} value={provider}
onChange={(e) => setProvider(e.target.value)} onChange={(e) => setProvider(e.target.value)}
placeholder="es. anthropic" placeholder="e.g. anthropic"
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15" className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
data-testid="provider-freetext" data-testid="provider-freetext"
/> />
@@ -155,7 +155,7 @@ export function SettingsDialog() {
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-model"> <label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-model">
Modello Model
</label> </label>
{hasModels ? ( {hasModels ? (
<select <select
@@ -177,7 +177,7 @@ export function SettingsDialog() {
type="text" type="text"
value={model} value={model}
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
placeholder="es. glm-5.2" placeholder="e.g. glm-5.2"
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15" className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
data-testid="model-freetext" data-testid="model-freetext"
/> />
@@ -204,7 +204,7 @@ export function SettingsDialog() {
{!hasModels && ( {!hasModels && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero. Pi returned no models (not running or no API key): free text entry.
</p> </p>
)} )}
{error && ( {error && (
@@ -215,10 +215,10 @@ export function SettingsDialog() {
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}> <Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
Annulla Cancel
</Button> </Button>
<Button type="submit" size="sm" disabled={busy}> <Button type="submit" size="sm" disabled={busy}>
{busy ? "Salvataggio…" : "Salva"} {busy ? "Saving…" : "Save"}
</Button> </Button>
</div> </div>
</form> </form>
+2 -2
View File
@@ -32,7 +32,7 @@ export function SteerInput({ sessionId }: Props) {
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Steering libero (!)…" placeholder="Free steering (!)…"
aria-label="Steering" aria-label="Steering"
className="flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground" className="flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
/> />
@@ -40,7 +40,7 @@ export function SteerInput({ sessionId }: Props) {
size="icon-sm" size="icon-sm"
onClick={submit} onClick={submit}
disabled={!text.trim()} disabled={!text.trim()}
aria-label="Invia" aria-label="Send"
> >
<ArrowUpIcon /> <ArrowUpIcon />
</Button> </Button>
+2 -2
View File
@@ -19,8 +19,8 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
level: "error", level: "error",
text: text:
err instanceof Error err instanceof Error
? `Invio risposta fallito: ${err.message}` ? `Failed to send response: ${err.message}`
: "Invio risposta fallito.", : "Failed to send response.",
}); });
return; return;
} }
+8 -8
View File
@@ -2,14 +2,14 @@
import { useSessionStore } from "../store/sessionStore"; import { useSessionStore } from "../store/sessionStore";
const PHASES = [ const PHASES = [
{ id: "F1", name: "Disambiguazione" }, { id: "F1", name: "Disambiguation" },
{ id: "F2", name: "Schema linking" }, { id: "F2", name: "Schema linking" },
{ id: "F3", name: "Esplorazione" }, { id: "F3", name: "Exploration" },
{ id: "F4", name: "Piano SQL" }, { id: "F4", name: "SQL plan" },
{ id: "F5", name: "Generazione SQL" }, { id: "F5", name: "SQL generation" },
{ id: "F6", name: "Validazione" }, { id: "F6", name: "Validation" },
{ id: "F7", name: "Revisione" }, { id: "F7", name: "Review" },
{ id: "F8", name: "Risultati" }, { id: "F8", name: "Results" },
]; ];
export function WorkflowBar() { export function WorkflowBar() {
@@ -18,7 +18,7 @@ export function WorkflowBar() {
return ( return (
<nav <nav
aria-label="Avanzamento workflow" aria-label="Workflow progress"
className="flex items-center gap-0 overflow-x-auto py-0.5" className="flex items-center gap-0 overflow-x-auto py-0.5"
> >
{PHASES.map((p, i) => { {PHASES.map((p, i) => {
+3 -3
View File
@@ -31,12 +31,12 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
render(<App />); render(<App />);
// Open the "Nuova domanda" dialog // Open the "Nuova domanda" dialog
await userEvent.click(screen.getByRole("button", { name: /nuova/i })); await userEvent.click(screen.getByRole("button", { name: /new/i }));
// Fill in the question and submit // Fill in the question and submit
const textarea = await screen.findByLabelText(/domanda/i); const textarea = await screen.findByLabelText(/question/i);
await userEvent.type(textarea, "Quante vendite?"); await userEvent.type(textarea, "Quante vendite?");
await userEvent.click(screen.getByRole("button", { name: /^crea$/i })); await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
// Wait for the session to be created and SSE to connect // Wait for the session to be created and SSE to connect
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
+6 -6
View File
@@ -48,8 +48,8 @@ test("(a) renders grid cells for a 2×2 preview result", async () => {
// Grid must be present, not bold scalar // Grid must be present, not bold scalar
expect(screen.queryByTestId("scalar-value")).not.toBeInTheDocument(); expect(screen.queryByTestId("scalar-value")).not.toBeInTheDocument();
// truncated:true → show the "risultati troncati" indicator // truncated:true → show the "results truncated" indicator
expect(screen.getByText(/risultati troncati/i)).toBeInTheDocument(); expect(screen.getByText(/results truncated/i)).toBeInTheDocument();
}); });
// (b) 1×1 preview → bold scalar shown, no grid // (b) 1×1 preview → bold scalar shown, no grid
@@ -105,7 +105,7 @@ test("(c) Esporta CSV button calls the export endpoint", async () => {
// Wait for preview to load (use role to avoid ambiguity with paging panel) // Wait for preview to load (use role to avoid ambiguity with paging panel)
await waitFor(() => expect(screen.getAllByRole("gridcell")).not.toHaveLength(0)); await waitFor(() => expect(screen.getAllByRole("gridcell")).not.toHaveLength(0));
const btn = screen.getByRole("button", { name: /esporta csv/i }); const btn = screen.getByRole("button", { name: /export csv/i });
await user.click(btn); await user.click(btn);
await waitFor(() => expect(exportCalled).toBe(true)); await waitFor(() => expect(exportCalled).toBe(true));
@@ -136,9 +136,9 @@ test("(d) selecting tutti re-fetches preview with the large limit", async () =>
// Initial fetch uses limit 10 // Initial fetch uses limit 10
await waitFor(() => expect(limits).toContain(10)); await waitFor(() => expect(limits).toContain(10));
// Select "tutti" // Select "all"
const select = screen.getByLabelText(/righe da visualizzare/i); const select = screen.getByLabelText(/rows to display/i);
await user.selectOptions(select, "tutti"); await user.selectOptions(select, "all");
// A re-fetch with the large/unlimited limit must occur // A re-fetch with the large/unlimited limit must occur
await waitFor(() => expect(limits.some((l) => (l as number) > 10)).toBe(true)); await waitFor(() => expect(limits.some((l) => (l as number) > 10)).toBe(true));
+7 -7
View File
@@ -47,20 +47,20 @@ export function ResultsPanel({ sessionId }: Props) {
<select <select
value={limit} value={limit}
onChange={(e) => onChange={(e) =>
setLimit(e.target.value === "tutti" ? 1_000_000 : Number(e.target.value)) setLimit(e.target.value === "all" ? 1_000_000 : Number(e.target.value))
} }
aria-label="Righe da visualizzare" aria-label="Rows to display"
> >
<option value={10}>10</option> <option value={10}>10</option>
<option value="tutti">tutti</option> <option value="all">all</option>
</select> </select>
<button onClick={handleExport}>Esporta CSV</button> <button onClick={handleExport}>Export CSV</button>
</div> </div>
{/* Loading / error states */} {/* Loading / error states */}
{isLoading && <div>Caricamento...</div>} {isLoading && <div>Loading…</div>}
{error && <div>Errore: {String(error)}</div>} {error && <div>Error: {String(error)}</div>}
{/* Scalar result: 1 col × 1 row → bold value */} {/* Scalar result: 1 col × 1 row → bold value */}
{data && isScalar && ( {data && isScalar && (
@@ -73,7 +73,7 @@ export function ResultsPanel({ sessionId }: Props) {
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}> <div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact columnDefs={columnDefs} rowData={rowData} /> <AgGridReact columnDefs={columnDefs} rowData={rowData} />
</div> </div>
{data.truncated && <div>(risultati troncati)</div>} {data.truncated && <div>(results truncated)</div>}
</> </>
)} )}
</div> </div>
@@ -42,7 +42,7 @@ test("(b) clicking toggle switches to table listing a promoted table name", asyn
// wait for mermaid to load // wait for mermaid to load
await waitFor(() => expect(screen.getByTestId("mm")).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId("mm")).toBeInTheDocument());
const toggle = screen.getByRole("button", { name: /tabella/i }); const toggle = screen.getByRole("button", { name: /table/i });
await userEvent.click(toggle); await userEvent.click(toggle);
expect(screen.getByText("orders")).toBeInTheDocument(); expect(screen.getByText("orders")).toBeInTheDocument();
@@ -52,7 +52,7 @@ test("(b) clicking toggle switches to table listing a promoted table name", asyn
test("(c) oversized linking (>45) shows cap notice", () => { test("(c) oversized linking (>45) shows cap notice", () => {
render(<SchemaLinkingViewer linking={bigLinking} />); render(<SchemaLinkingViewer linking={bigLinking} />);
expect( expect(
screen.getByText(/troppi elementi per il grafico/i) screen.getByText(/too many elements for the graph/i)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
+9 -9
View File
@@ -109,19 +109,19 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
disabled={oversized} disabled={oversized}
className={view === "chart" ? "font-bold" : ""} className={view === "chart" ? "font-bold" : ""}
> >
Grafico Graph
</button> </button>
<button <button
onClick={() => setView("table")} onClick={() => setView("table")}
className={view === "table" ? "font-bold" : ""} className={view === "table" ? "font-bold" : ""}
> >
Tabella Table
</button> </button>
</div> </div>
{oversized && ( {oversized && (
<p className="text-sm text-amber-600"> <p className="text-sm text-amber-600">
Troppi elementi per il grafico, usa la tabella Too many elements for the graph, use the table
</p> </p>
)} )}
@@ -137,9 +137,9 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
<tr> <tr>
<th className="text-left border-b py-1">Nome</th> <th className="text-left border-b py-1">Name</th>
<th className="text-left border-b py-1">Tipo</th> <th className="text-left border-b py-1">Type</th>
<th className="text-left border-b py-1">Perché</th> <th className="text-left border-b py-1">Why</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -147,7 +147,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
<React.Fragment key={table.name}> <React.Fragment key={table.name}>
<tr className="font-semibold bg-muted/30"> <tr className="font-semibold bg-muted/30">
<td className="py-1 pl-1">{table.name}</td> <td className="py-1 pl-1">{table.name}</td>
<td>tabella</td> <td>table</td>
<td className="text-xs text-muted-foreground"> <td className="text-xs text-muted-foreground">
{reasonFor(table)} {reasonFor(table)}
</td> </td>
@@ -157,7 +157,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
<td className="py-0.5 pl-4 text-muted-foreground"> <td className="py-0.5 pl-4 text-muted-foreground">
{col.name} {col.name}
</td> </td>
<td>colonna</td> <td>column</td>
<td className="text-xs text-muted-foreground"> <td className="text-xs text-muted-foreground">
{reasonFor(col)} {reasonFor(col)}
</td> </td>
@@ -170,7 +170,7 @@ export function SchemaLinkingViewer({ linking }: { linking: SchemaLinking }) {
<td className="py-0.5 pl-1 text-muted-foreground"> <td className="py-0.5 pl-1 text-muted-foreground">
{col.name} {col.name}
</td> </td>
<td>colonna</td> <td>column</td>
<td className="text-xs text-muted-foreground"> <td className="text-xs text-muted-foreground">
{reasonFor(col)} {reasonFor(col)}
</td> </td>
+3 -3
View File
@@ -71,16 +71,16 @@ test("(c) layout toggle flips its label between Orizzontale and Verticale", asyn
await waitFor(() => expect(screen.getAllByTestId("hl")).toHaveLength(2)); await waitFor(() => expect(screen.getAllByTestId("hl")).toHaveLength(2));
// Default layout is vertical, so the toggle offers "Orizzontale". // Default layout is vertical, so the toggle offers "Orizzontale".
const toggle = screen.getByRole("button", { name: /orizzontale/i }); const toggle = screen.getByRole("button", { name: /horizontal/i });
expect(toggle).toBeInTheDocument(); expect(toggle).toBeInTheDocument();
await userEvent.click(toggle); await userEvent.click(toggle);
// After switching to horizontal, the toggle now offers "Verticale". // After switching to horizontal, the toggle now offers "Verticale".
expect( expect(
screen.getByRole("button", { name: /verticale/i }) screen.getByRole("button", { name: /vertical/i })
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("button", { name: /orizzontale/i }) screen.queryByRole("button", { name: /horizontal/i })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
+1 -1
View File
@@ -101,7 +101,7 @@ export function SqlViewer({ blocks }: { blocks: SqlBlock[] }) {
setLayout((l) => (l === "vertical" ? "horizontal" : "vertical")) setLayout((l) => (l === "vertical" ? "horizontal" : "vertical"))
} }
> >
{layout === "vertical" ? "Orizzontale" : "Verticale"} {layout === "vertical" ? "Horizontal" : "Vertical"}
</button> </button>
</div> </div>
@@ -49,6 +49,6 @@ test("reserved control responds with control field", async () => {
onRespond={onRespond} onRespond={onRespond}
/> />
); );
await userEvent.click(screen.getByRole("button", { name: /indietro/i })); await userEvent.click(screen.getByRole("button", { name: /back/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
}); });
+2 -2
View File
@@ -5,10 +5,10 @@ export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
const [text, setText] = useState(""); const [text, setText] = useState("");
return ( return (
<div className="border rounded p-3 space-y-2"> <div className="border rounded p-3 space-y-2">
<p className="text-sm text-amber-600">Widget non supportato (kind: {descriptor.widget}) — rispondi manualmente</p> <p className="text-sm text-amber-600">Unsupported widget (kind: {descriptor.widget}) — respond manually</p>
<pre className="text-xs overflow-auto max-h-48 bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre> <pre className="text-xs overflow-auto max-h-48 bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
<textarea className="w-full border rounded p-1" value={text} onChange={(e) => setText(e.target.value)} /> <textarea className="w-full border rounded p-1" value={text} onChange={(e) => setText(e.target.value)} />
<button className="border rounded px-2 py-1" onClick={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Invia</button> <button className="border rounded px-2 py-1" onClick={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Send</button>
</div> </div>
); );
} }
+1 -1
View File
@@ -5,6 +5,6 @@ test("submits typed text", async () => {
const onRespond = vi.fn(); const onRespond = vi.fn();
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />); render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
await userEvent.type(screen.getByRole("textbox"), "ciao"); await userEvent.type(screen.getByRole("textbox"), "ciao");
await userEvent.click(screen.getByRole("button", { name: /invia/i })); await userEvent.click(screen.getByRole("button", { name: /send/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" });
}); });
+1 -1
View File
@@ -6,7 +6,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
<div className="space-y-2"> <div className="space-y-2">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>} {descriptor.title && <p className="font-medium">{descriptor.title}</p>}
<textarea className="w-full border rounded p-2" value={text} onChange={(e) => setText(e.target.value)} /> <textarea className="w-full border rounded p-2" value={text} onChange={(e) => setText(e.target.value)} />
<button className="border rounded px-3 py-1" onClick={() => onRespond({ id: descriptor.id, kind: "freetext", text })}>Invia</button> <button className="border rounded px-3 py-1" onClick={() => onRespond({ id: descriptor.id, kind: "freetext", text })}>Send</button>
</div> </div>
); );
} }
@@ -18,7 +18,7 @@ test("confirms the checked ids including initially selected", async () => {
/> />
); );
await userEvent.click(screen.getByRole("checkbox", { name: /t2/ })); await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i })); await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
}); });
@@ -37,8 +37,8 @@ test("select-all checks all options", async () => {
onRespond={onRespond} onRespond={onRespond}
/> />
); );
await userEvent.click(screen.getByRole("button", { name: /seleziona tutti/i })); await userEvent.click(screen.getByRole("button", { name: /select all/i }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i })); await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] });
}); });
@@ -57,8 +57,8 @@ test("deselect-all unchecks all options", async () => {
onRespond={onRespond} onRespond={onRespond}
/> />
); );
await userEvent.click(screen.getByRole("button", { name: /deseleziona tutti/i })); await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i })); await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] });
}); });
@@ -75,7 +75,7 @@ test("confirm is disabled when nothing checked and allow_empty is false", async
onRespond={onRespond} onRespond={onRespond}
/> />
); );
const btn = screen.getByRole("button", { name: /conferma/i }); const btn = screen.getByRole("button", { name: /confirm/i });
expect(btn).toBeDisabled(); expect(btn).toBeDisabled();
await userEvent.click(btn); await userEvent.click(btn);
expect(onRespond).not.toHaveBeenCalled(); expect(onRespond).not.toHaveBeenCalled();
@@ -97,9 +97,9 @@ test("deselect-all with allow_empty false disables Conferma (empty confirm impos
onRespond={onRespond} onRespond={onRespond}
/> />
); );
const btn = screen.getByRole("button", { name: /conferma/i }); const btn = screen.getByRole("button", { name: /confirm/i });
expect(btn).not.toBeDisabled(); expect(btn).not.toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /deseleziona tutti/i })); await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
expect(btn).toBeDisabled(); expect(btn).toBeDisabled();
await userEvent.click(btn); await userEvent.click(btn);
expect(onRespond).not.toHaveBeenCalled(); expect(onRespond).not.toHaveBeenCalled();
@@ -118,7 +118,7 @@ test("confirm is enabled when allow_empty is false but something is checked", as
onRespond={onRespond} onRespond={onRespond}
/> />
); );
const btn = screen.getByRole("button", { name: /conferma/i }); const btn = screen.getByRole("button", { name: /confirm/i });
expect(btn).not.toBeDisabled(); expect(btn).not.toBeDisabled();
await userEvent.click(btn); await userEvent.click(btn);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] });
+2 -2
View File
@@ -32,7 +32,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.title && <p className="font-medium">{descriptor.title}</p>} {descriptor.title && <p className="font-medium">{descriptor.title}</p>}
<div className="flex gap-2"> <div className="flex gap-2">
<button className="text-sm border rounded px-2 py-1" onClick={allChecked ? deselectAll : selectAll}> <button className="text-sm border rounded px-2 py-1" onClick={allChecked ? deselectAll : selectAll}>
{allChecked ? "Deseleziona tutti" : "Seleziona tutti"} {allChecked ? "Deselect all" : "Select all"}
</button> </button>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -52,7 +52,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
disabled={isDisabled} disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })} onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
> >
Conferma Confirm
</button> </button>
</div> </div>
); );
+1 -1
View File
@@ -1,4 +1,4 @@
const LABELS: Record<string, string> = { back: "Torna indietro", exit: "Esci", other: "Altro — specifica" }; const LABELS: Record<string, string> = { back: "Go back", exit: "Exit", other: "Other — specify" };
export function ReservedControls({ reserved, onControl }: { reserved?: string[]; onControl: (c: string) => void }) { export function ReservedControls({ reserved, onControl }: { reserved?: string[]; onControl: (c: string) => void }) {
if (!reserved?.length) return null; if (!reserved?.length) return null;
return <div className="flex gap-2 pt-2">{reserved.map((c) => ( return <div className="flex gap-2 pt-2">{reserved.map((c) => (
+1 -1
View File
@@ -12,6 +12,6 @@ test("picking an option responds with its id", async () => {
test("a reserved control responds with control, not a choice", async () => { test("a reserved control responds with control, not a choice", async () => {
const onRespond = vi.fn(); const onRespond = vi.fn();
render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }], reserved: ["back"] }} onRespond={onRespond} />); render(<SelectWidget descriptor={{ id: "u1", widget: "select", options: [{ id: "a", label: "A" }], reserved: ["back"] }} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: /indietro/i })); await userEvent.click(screen.getByRole("button", { name: /back/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" }); expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
}); });
+1 -1
View File
@@ -24,7 +24,7 @@ test("reject opens a freetext and combines the reason", async () => {
); );
await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ })); await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ }));
await userEvent.type(screen.getByRole("textbox"), "join sbagliata"); await userEvent.type(screen.getByRole("textbox"), "join sbagliata");
await userEvent.click(screen.getByRole("button", { name: /invia/i })); await userEvent.click(screen.getByRole("button", { name: /send/i }));
expect(onRespond).toHaveBeenCalledWith({ expect(onRespond).toHaveBeenCalledWith({
id: "u1", id: "u1",
kind: "artifact-gate", kind: "artifact-gate",
+1 -1
View File
@@ -11,5 +11,5 @@ test("resolve returns the registered renderer", () => {
test("resolve falls back for unknown kind and shows the JSON", () => { test("resolve falls back for unknown kind and shows the JSON", () => {
const Comp = resolve("totally-unknown"); const Comp = resolve("totally-unknown");
render(<Comp descriptor={{ id: "u1", widget: "totally-unknown", title: "X" } as any} onRespond={() => {}} />); render(<Comp descriptor={{ id: "u1", widget: "totally-unknown", title: "X" } as any} onRespond={() => {}} />);
expect(screen.getByText(/Widget non supportato/i)).toBeInTheDocument(); expect(screen.getByText(/Unsupported widget/i)).toBeInTheDocument();
}); });