fix(ui): expand session dialogs and repeat confirmation actions
Publish documentation / publish (push) Successful in 24s

This commit is contained in:
User
2026-09-14 18:13:53 +02:00
parent b1723c34c4
commit c3caba94dd
35 changed files with 425 additions and 142 deletions
+71
View File
@@ -619,3 +619,74 @@ for (const width of [390, 1280]) {
await expect(page.getByRole("dialog", { name: "Stop the session?" })).toBeVisible();
});
}
for (const [width, height, mode] of [
[390, 844, "embedded"], [1280, 900, "embedded"], [1440, 1000, "full"],
[844, 390, "embedded"], [320, 640, "embedded"],
] as const) {
test(`session confirmation layout stays within ${mode} view at ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
await fixtures(page, mode);
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
if (mode === "embedded") await page.addStyleTag({ content: `
body { margin: 0; padding-top: 60px; }
#root { margin-left: ${width > 900 ? 160 : 0}px; height: calc(100dvh - 80px); overflow: hidden; }
` });
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "New question", exact: true }).fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Steering", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: {
id: "layout-gate", widget: "artifact-gate", title: "Verifica della domanda e dei criteri di inclusione",
artifact: { kind: "markdown", content: "## Criteri di inclusione\n\n" + "Verificare i pazienti inclusi nella coorte e il periodo di riferimento.\n\n".repeat(100) },
options: [{ id: "approve", label: "Conferma e procedi alla fase successiva" }], reserved: ["back", "other"],
} });
});
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
const bounds = await dialog.boundingBox();
const area = await page.getByTestId("app-shell").boundingBox();
expect(area).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(area!.x);
expect(bounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(area!.x + area!.width);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
expect(bounds!.width).toBeGreaterThan(Math.min(1000, area!.width - 60));
expect(bounds!.height).toBeGreaterThan(area!.height * 0.8);
const confirmations = dialog.getByRole("button", { name: "Conferma e procedi alla fase successiva" });
await expect(confirmations).toHaveCount(2);
for (const button of await confirmations.all()) {
await button.scrollIntoViewIfNeeded();
await expect(button).toBeInViewport();
}
expect(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const body = dialog.locator(".thot-session-dialog__body");
expect(await body.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await body.evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.screenshot({ path: testInfo.outputPath("session-review.png") });
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().clearPending();
});
await page.getByRole("button", { name: "Stop and save session", exact: true }).click();
const stop = page.getByRole("dialog", { name: "Stop the session?" });
await expect(stop.getByRole("button", { name: "Stop & save", exact: true })).toHaveCount(2);
await expect(stop.getByRole("button", { name: "Cancel", exact: true })).toBeFocused();
const stopBounds = await stop.boundingBox();
expect(stopBounds!.width).toBeGreaterThan(Math.min(600, area!.width - 60));
expect(stopBounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(stopBounds!.y + stopBounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
await page.screenshot({ path: testInfo.outputPath("session-stop.png") });
await stop.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(stop).toHaveCount(0);
});
}
@@ -0,0 +1,31 @@
.thot-session-dialog[data-slot="dialog-content"] {
--session-dialog-space: 1rem;
--session-dialog-height: min(100dvh, var(--app-area-height, 100dvh));
top: calc(var(--app-area-top, 0px) + var(--session-dialog-height) / 2);
width: min(40rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
max-width: calc(100vw - var(--session-dialog-space));
min-height: min(18rem, calc(var(--session-dialog-height) - var(--session-dialog-space)));
max-height: calc(var(--session-dialog-height) - var(--session-dialog-space));
overflow: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
padding: 1.5rem;
gap: 1.25rem;
}
.thot-session-dialog[data-review] {
width: min(80rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
height: calc(var(--session-dialog-height) - var(--session-dialog-space));
}
.thot-session-dialog [data-slot="dialog-title"] { padding-right: 1.5rem; line-height: 1.4; }
.thot-session-dialog [data-slot="dialog-footer"] { margin: 0; border-radius: 0; }
.thot-session-dialog button { max-width: 100%; white-space: normal; height: auto; min-height: 2rem; }
.thot-session-dialog__body { min-height: 4rem; overflow: auto; overscroll-behavior: contain; }
@media (min-width: 640px) and (min-height: 600px) {
.thot-session-dialog[data-slot="dialog-content"] { --session-dialog-space: 3rem; }
}
@media (max-width: 639px), (max-height: 599px) {
.thot-session-dialog[data-slot="dialog-content"] { padding: 1rem; gap: 0.75rem; }
}
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
import { DialogContent } from "./ui/dialog";
import "./SessionDialogContent.css";
/** Session-only sizing; administrative dialogs retain their own layout. */
export function SessionDialogContent({ className = "", review = false, ...props }:
ComponentProps<typeof DialogContent> & { review?: boolean }) {
return <DialogContent {...props} className={`thot-session-dialog ${className}`} data-review={review || undefined} />;
}
+1
View File
@@ -47,6 +47,7 @@ export const itWorkflow: Record<string, string> = {
"Memory for future questions": "Memory per le domande future",
"Memory summary": "Riepilogo delle memorie",
"Model activity": "Attività del modello",
"To Administration": "Vai all’Amministrazione",
"Model activity timeline": "Cronologia dell'attività del modello",
"Move to group ›": "Sposta nel gruppo ›",
"Navigation": "Navigazione",
@@ -273,7 +273,7 @@ test("administrator confirms before deleting a same-named user's session", async
await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" }));
expect(deletes).toBe(0);
expect(await screen.findByRole("heading", { name: "Delete permanently" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
await waitFor(() => expect(deletes).toBe(1));
});
@@ -1253,7 +1253,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
const input = await screen.findByLabelText(/name/i, { selector: "input" });
await userEvent.clear(input);
await userEvent.type(input, "Cardiologia");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[0]);
await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }]));
});
@@ -61,7 +61,7 @@ test("a held stop for s1 cannot reset the newer active s2 session", async () =>
await userEvent.click(await screen.findByTestId("session-item-s1"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
await userEvent.click(screen.getByRole("button", { name: /stop and save session/i }));
await userEvent.click(await screen.findByRole("button", { name: "Stop & save" }));
await userEvent.click((await screen.findAllByRole("button", { name: "Stop & save" }))[0]);
await closeStarted.promise;
await userEvent.click(screen.getByTestId("session-item-s2"));
+8 -2
View File
@@ -117,8 +117,7 @@ export function AppShell({ canLogout }: AppShellProps) {
resizing: sessionResizing,
separatorProps: sessionSeparatorProps,
} = useSessionPanelResize(containerRef, panelSession !== null);
// Publish the app area's horizontal geometry so viewport-fixed dialogs center on
// the application area rather than the whole browser window.
// Publish the visible app bounds for dialogs, including embedded portal layouts.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -127,16 +126,23 @@ export function AppShell({ canLogout }: AppShellProps) {
const r = el.getBoundingClientRect();
doc.style.setProperty("--app-area-left", `${r.left}px`);
doc.style.setProperty("--app-area-width", `${r.width}px`);
const top = Math.max(0, r.top);
doc.style.setProperty("--app-area-top", `${top}px`);
doc.style.setProperty("--app-area-height", `${Math.max(0, Math.min(window.innerHeight, r.bottom) - top)}px`);
};
publish();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish);
observer?.observe(el);
window.addEventListener("resize", publish);
window.addEventListener("scroll", publish, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", publish);
window.removeEventListener("scroll", publish, true);
doc.style.removeProperty("--app-area-left");
doc.style.removeProperty("--app-area-width");
doc.style.removeProperty("--app-area-top");
doc.style.removeProperty("--app-area-height");
};
}, [containerRef]);
const panelWidthsStyle = {
+11 -5
View File
@@ -1,5 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useId } from "react";
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -13,22 +15,26 @@ interface Props {
export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const cancelId = useId();
const targets = labels ?? (label ? [label] : []);
const description = targets.length > 1
? translate("{count} sessions will be permanently deleted, including all of their documents. This action cannot be undone.", { count: targets.length })
: translate('"{name}" will be permanently deleted, including all of its documents. This action cannot be undone.', { name: targets[0] ?? "" });
const confirmAction = (<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={() => document.getElementById(cancelId)}>
<DialogHeader><DialogTitle>{translate("Delete permanently")}</DialogTitle></DialogHeader>
<div>{confirmAction}</div>
<DialogDescription>
{description}
</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>
<Button id={cancelId} type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
{confirmAction}
</DialogFooter>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -92,8 +92,8 @@ export function ModelActivityPanel({
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<h2 className="font-heading text-sm font-semibold text-foreground">{translate("Model activity")}</h2>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" aria-label={translate("Workspace management")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("Workspace management")}</Button>
<Button size="sm" variant="ghost" aria-label={translate("To Administration")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("To Administration")}</Button>
<Button size="icon-sm" variant="ghost" aria-label={translate("Close model activity")} onClick={onClose}>
<X className="size-4" />
</Button>
+9 -5
View File
@@ -1,6 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useI18n } from "../i18n";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
import { useEffect, useRef, useState } from "react";
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -13,6 +14,7 @@ interface Props {
export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: Props) {
const { t: translate } = useI18n();
const nameRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(initial);
useEffect(() => { if (open) setName(initial); }, [open, initial]);
@@ -25,12 +27,14 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: P
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={nameRef}>
<DialogHeader><DialogTitle>{title ?? translate("Rename session")}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="flex flex-col gap-3">
<form onSubmit={submit} className="flex flex-col gap-4">
<div><Button type="submit" size="sm">{translate("Save")}</Button></div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">{translate("Name")}</label>
<input
ref={nameRef}
id="rename-name"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -43,7 +47,7 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: P
<Button type="submit" size="sm">{translate("Save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+6 -6
View File
@@ -4,30 +4,30 @@ import { vi } from "vitest";
import { RenameDialog } from "./RenameDialog";
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
test("RenameDialog submits the edited name", async () => {
test.each([0, 1])("RenameDialog submits the edited name (position %s)", async (position) => {
const onSubmit = vi.fn();
render(<RenameDialog open initial="Vecchio" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
const input = screen.getByDisplayValue("Vecchio");
await userEvent.clear(input);
await userEvent.type(input, "Nuovo");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[position]);
expect(onSubmit).toHaveBeenCalledWith("Nuovo");
});
test("DeleteConfirmDialog confirms only on the destructive button", async () => {
test.each([0, 1])("DeleteConfirmDialog confirms only on the destructive button (position %s)", async (position) => {
const onConfirm = vi.fn();
render(<DeleteConfirmDialog open label="Sessione X" onOpenChange={vi.fn()} onConfirm={onConfirm} />);
expect(screen.getByText(/Sessione X/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
await userEvent.click(screen.getAllByRole("button", { name: /delete/i })[position]);
expect(onConfirm).toHaveBeenCalled();
});
test("RenameDialog does not submit an empty name", async () => {
test.each([0, 1])("RenameDialog does not submit an empty name (position %s)", async (position) => {
const onSubmit = vi.fn();
render(<RenameDialog open initial="Old" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
const input = screen.getByDisplayValue("Old");
await userEvent.clear(input);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[position]);
expect(onSubmit).not.toHaveBeenCalled();
});
+11 -5
View File
@@ -1,5 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useId } from "react";
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -10,17 +12,21 @@ interface Props {
export function StopConfirmDialog({ open, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const cancelId = useId();
const confirmAction = (<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Stop & save")}</Button>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={() => document.getElementById(cancelId)}>
<DialogHeader><DialogTitle>{translate("Stop the session?")}</DialogTitle></DialogHeader>
<div>{confirmAction}</div>
<DialogDescription>
{translate("The running process will be interrupted. Progress so far is saved, and you can resume the session later.")}</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Stop & save")}</Button>
<Button id={cancelId} type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
{confirmAction}
</DialogFooter>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+3 -3
View File
@@ -66,17 +66,17 @@ test("keeps answer drafts protected during a failed POST and retry, then clears
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(screen.getByRole("status")).toHaveTextContent("Sending response");
try {
expect(reloadIsBlocked()).toBe(true);
} finally {
release();
}
await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled());
await waitFor(() => expect(screen.getAllByRole("button", { name: "Send" })[0]).toBeEnabled());
expect(screen.getByRole("textbox")).toHaveValue("Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
await waitFor(() => expect(screen.queryByRole("textbox")).not.toBeInTheDocument());
expect(requests).toBe(2);
expect(reloadIsBlocked()).toBe(false);
@@ -14,7 +14,7 @@ test("localizes repair recovery without changing the recorded choice or archive
expect(screen.getByText("Before memory")).toBeVisible();
expect(screen.getByText("After evidence")).toBeVisible();
expect(screen.getByText("Conflicting order definitions")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Riprova la correzione selezionata" }));
await userEvent.click(screen.getAllByRole("button", { name: "Riprova la correzione selezionata" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "retry-it", choices: ["e"] });
});
@@ -32,7 +32,7 @@ test.each(["Memory", "Evidence"])("shows both contents and sends only the select
expect(screen.getByText("Before memory")).toBeVisible();
expect(screen.getByText("After memory")).toBeVisible();
expect(screen.getByText(/After evidence/)).toBeVisible();
const button = screen.getByRole("button", { name: `Apply this correction to ${archive}` });
const button = screen.getAllByRole("button", { name: `Apply this correction to ${archive}` })[0];
await userEvent.click(button);
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: [archive === "Memory" ? "m" : "e"] });
expect(button).toBeDisabled();
@@ -53,7 +53,7 @@ test("saved but inactive corrections keep the choice fixed and offer retry", asy
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={respond} />);
expect(screen.getByRole("status")).toHaveTextContent("Index activation is incomplete");
expect(screen.queryByRole("button", { name: /None are adequate/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Continue with repair pending" })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Retry selected correction" }));
expect(screen.getAllByRole("button", { name: "Continue with repair pending" })[0]).toBeVisible();
await userEvent.click(screen.getAllByRole("button", { name: "Retry selected correction" })[0]);
expect(respond).toHaveBeenCalledWith({ id: "retry", choices: ["e"] });
});
+16 -6
View File
@@ -62,7 +62,19 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
setSubmitted(true);
onRespond({ id: descriptor.id, choices: [choice] });
};
const correctionAction = (option: RepairOption) => (
<button type="button" onClick={() => send(option.id)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
</button>
);
const pending = repair.status === "applying" || repair.status === "pending_activation";
const retryAction = pending && repair.can_apply && repair.choice ? (
<button type="button" onClick={() => send(repair.choice!)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
{translate("Retry selected correction")}
</button>
) : null;
return <section aria-label={translate("Archive conflict repair")} className="space-y-4 rounded-xl border border-border bg-card p-5">
<div><h3 className="font-semibold">{translate("Resolve archive conflict")}</h3>
<p className="mt-1 whitespace-pre-wrap text-sm">{repair.reason}</p></div>
@@ -70,26 +82,24 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
{Boolean(descriptor.error) && <p role="alert" className="text-sm text-destructive">{String(descriptor.error)}</p>}
{!repair.can_apply && <p className="text-sm text-muted-foreground">{translate("An administrator must approve shared archive corrections. You can reject the proposals or continue reviewing this question.")}</p>}
<fieldset disabled={submitted} className="min-w-0 space-y-4">
{retryAction && <div>{retryAction}</div>}
{repair.options.map(option => <article key={option.id} className="space-y-3 border-t border-border pt-4">
<div><h4 className="text-sm font-semibold">{option.label}</h4>
<p className="text-xs text-muted-foreground">{option.archive === "memory" ? translate("Memory") : translate("Evidence")} · {option.target_id}
{repair.choice === option.id && <> · {translate("Selected")}</>}</p></div>
{repair.can_apply && !repair.choice && correctionAction(option)}
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<div className="min-w-0 rounded-md bg-muted/50 p-3"><p className="mb-2 text-xs font-semibold">{translate("Before this proposal")}</p>
<Content value={option.before} /></div>
<div className="min-w-0 rounded-md border border-border p-3"><p className="mb-2 text-xs font-semibold">{translate("Proposed result")}</p>
<Content value={option.content} /></div>
</div>
{repair.can_apply && !repair.choice && <button type="button" onClick={() => send(option.id)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
</button>}
{repair.can_apply && !repair.choice && correctionAction(option)}
</article>)}
<div className="flex flex-wrap gap-3 border-t border-border pt-4">
{!repair.choice && <button type="button" onClick={() => send("reject")} className="rounded-md border border-border px-3 py-2 text-sm">
{translate("None are adequate — reformulate")}</button>}
{pending && repair.can_apply && repair.choice && <button type="button" onClick={() => send(repair.choice!)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">{translate("Retry selected correction")}</button>}
{retryAction}
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? translate("Continue with repair pending") : translate("Continue question review")}</button>}
</div>
@@ -66,7 +66,7 @@ test("a file-reference artifact resolves to the session document, rendered with
expect(screen.queryByText("question.md", { exact: true })).toBeNull();
});
test("clicking an option without opens responds immediately", async () => {
test.each([0, 1])("clicking an option without opens responds immediately (position %s)", async (position) => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
@@ -79,7 +79,7 @@ test("clicking an option without opens responds immediately", async () => {
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Approva/ })[position]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
});
+28 -21
View File
@@ -1,16 +1,17 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useI18n } from "../i18n";
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
import { Dialog, DialogTitle } from "../components/ui/dialog";
import { GateArtifactBody } from "./GateArtifactBody";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
/**
* Artifact review gate rendered as a centered (70%) modal: the artifact fills
* the top (scrollable) area via ArtifactView; the action bar (options + reserved
* controls) sits at the bottom. The gate contract forbids silent dismissal, so the
* Artifact review fills the visible session area with scrollable content.
* The same options appear above and below it; reserved controls remain below.
* The gate contract forbids silent dismissal, so the
* dialog has no close button and is not closeable by Esc/backdrop — the only way
* out is an action or a reserved control, both of which call onRespond.
*/
@@ -28,17 +29,35 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
else onRespond(parentResponse);
}
const options = (<>
{descriptor.options?.map((o, i) => (
<button
key={o.id}
className={
i === 0
? "rounded-lg bg-primary px-3.5 py-2 text-left font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
: "rounded-lg border border-border/70 px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent"
}
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</>);
return (
<Dialog open>
<DialogContent
<SessionDialogContent
showCloseButton={false}
className="grid h-[70vh] w-[min(52rem,calc(var(--app-area-width,100vw)-3rem))] max-w-none grid-rows-[auto_1fr_auto] gap-3.5 sm:max-w-none"
review
className="grid-rows-[auto_auto_minmax(4rem,1fr)_auto]"
>
<DialogTitle className="text-lg leading-snug">
{descriptor.title ?? translate("Artifact review")}
</DialogTitle>
<div className="flex flex-wrap gap-2">{!pendingLinkage && options}</div>
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-5">
<div className="thot-session-dialog__body rounded-xl border border-border/70 bg-background p-3 sm:p-5">
{descriptor.artifact ? (
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
) : (
@@ -56,19 +75,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
) : (
<>
<div className="flex flex-wrap gap-2">
{descriptor.options?.map((o, i) => (
<button
key={o.id}
className={
i === 0
? "rounded-lg bg-primary px-3.5 py-2 text-left font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
: "rounded-lg border border-border/70 px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent"
}
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
{options}
</div>
<ReservedControls
reserved={descriptor.reserved}
@@ -79,7 +86,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
</>
)}
</div>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -24,10 +24,10 @@ test("protects an answer draft until cleared or unmounted, including after sendi
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("submits typed text", async () => {
test.each([0, 1])("submits typed text (position %s)", async (position) => {
const onRespond = vi.fn();
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
await userEvent.type(screen.getByRole("textbox"), "ciao");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[position]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" });
});
+10 -5
View File
@@ -12,6 +12,14 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
onRespond({ id: descriptor.id, kind: "freetext", text });
}
}
const confirmAction = (
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={submit}
>
{translate("Send")}</button>
);
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
@@ -19,6 +27,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.title}
</p>
)}
<div>{confirmAction}</div>
<textarea
data-awaiting-input="true"
className="thot-awaiting-input min-h-24 w-full resize-y rounded-lg border p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow]"
@@ -28,11 +37,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
placeholder={translate("Type your answer…")}
aria-label={translate("Your answer")}
/>
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={submit}
>
{translate("Send")}</button>
{confirmAction}
</div>
);
}
@@ -25,7 +25,7 @@ const descriptor = {
],
} satisfies WidgetDescriptor;
test("renders joins as read-only information and Continue accepts the complete set", async () => {
test.each([0, 1])("renders joins as read-only information and Continue accepts the complete set (position %s)", async (position) => {
const onRespond = vi.fn();
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
@@ -34,7 +34,7 @@ test("renders joins as read-only information and Continue accepts the complete s
expect(screen.getByText(descriptor.options[0].detail)).toBeInTheDocument();
expect(screen.getByText("Patient grain")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await userEvent.click(screen.getAllByRole("button", { name: "Continue" })[position]);
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
@@ -49,7 +49,7 @@ test("Other specify sends textual feedback instead of a join selection", async (
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
await userEvent.type(screen.getByPlaceholderText("Describe your alternative…"), "Use the episode key");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
+15 -12
View File
@@ -7,6 +7,19 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const options = descriptor.options ?? [];
const confirmAction = (
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={() => onRespond({
id: descriptor.id,
kind: "join-review",
choices: options.map((option) => option.id),
})}
>
{descriptor.confirm_label ?? translate("Continue")}
</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="space-y-1.5">
@@ -19,6 +32,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
{translate("These joins are required by the selected tables and are shown for review. To request a correction, use Other — specify.")}</p>
</div>
<div>{confirmAction}</div>
<div className="space-y-2.5">
{options.map((option, index) => (
<article
@@ -59,18 +73,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
))}
</div>
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={() =>
onRespond({
id: descriptor.id,
kind: "join-review",
choices: options.map((option) => option.id),
})
}
>
{descriptor.confirm_label ?? translate("Continue")}
</button>
{confirmAction}
<ReservedControls
reserved={descriptor.reserved}
@@ -30,7 +30,7 @@ test.each(["subject", "scope", "detail", "rationale", "question", "concepts"])(
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByLabelText(`domain ${field}`), " revised");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
@@ -50,7 +50,7 @@ test("protects Memory selection, dependency and link drafts, including invalid e
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]);
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(reloadIsBlocked()).toBe(true); // Required database is still empty.
await userEvent.click(screen.getByRole("button", { name: "Remove dependency" }));
expect(reloadIsBlocked()).toBe(false);
@@ -63,7 +63,7 @@ test("protects Memory selection, dependency and link drafts, including invalid e
expect(reloadIsBlocked()).toBe(false);
});
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
test.each([0, 1])("sends only selected cards with reviewer edits and physical dependencies (position %s)", async (position) => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.clear(screen.getByLabelText("domain detail"));
@@ -73,20 +73,20 @@ test("sends only selected cards with reviewer edits and physical dependencies",
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
await userEvent.type(screen.getByLabelText("domain dependency 1 schema"), "clinical");
await userEvent.type(screen.getByLabelText("domain dependency 1 table"), "patients");
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[position]);
expect(onRespond).toHaveBeenCalledTimes(1);
const response = JSON.parse(onRespond.mock.calls[0]![0].text);
expect(response).toEqual({ summary_id: "hash", items: [{ id: "domain", card: { ...card,
detail: "Reviewer correction", dependencies: [{ database: "warehouse", schema_name: "clinical",
table: "patients", column: "" }] } }] });
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Saving…" })[position]).toBeDisabled();
});
test("declining every proposal submits an explicit empty review", async () => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
await userEvent.click(screen.getByRole("button", { name: "Finish without saving Memory" }));
await userEvent.click(screen.getAllByRole("button", { name: "Finish without saving Memory" })[0]);
expect(JSON.parse(onRespond.mock.calls[0]![0].text)).toEqual({ summary_id: "hash", items: [] });
});
@@ -97,7 +97,7 @@ test("links selected proposals and keeps approved SQL read only", async () => {
expect(screen.queryByRole("textbox", { name: /sql/i })).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
await userEvent.type(screen.getByLabelText("domain link 1 meaning"), "Example using this definition");
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
});
@@ -117,7 +117,7 @@ test("switches review chrome to Italian without changing edited cards, SQL or re
await userEvent.click(screen.getAllByRole("button", { name: "Aggiungi dipendenza" })[0]);
await userEvent.type(screen.getByLabelText("domain dipendenza 1 database"), "warehouse");
await userEvent.type(screen.getByLabelText("domain dipendenza 1 tabella"), "patients");
await userEvent.click(screen.getByRole("button", { name: "Salva le selezionate e termina" }));
await userEvent.click(screen.getAllByRole("button", { name: "Salva le selezionate e termina" })[0]);
const response = onRespond.mock.calls[0][0];
expect(response).toMatchObject({ id: "review", kind: "memory-review" });
+8 -3
View File
@@ -29,6 +29,12 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const confirmAction = (
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
{submitted ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
</button>
);
return <form aria-label={translate("Memory summary")} className="space-y-5 rounded-xl border border-border bg-card p-5"
onSubmit={event => {
event.preventDefault();
@@ -45,6 +51,7 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
<p className="mt-1 text-sm text-muted-foreground">{translate("Review the scope and wording, then choose what to save. Only selected cards and their links will enter the shared archive.")}</p>
</div>
<fieldset disabled={submitted} className="min-w-0 space-y-4">
<div>{confirmAction}</div>
<button type="button" className="text-sm font-medium text-primary" onClick={() => setSelected(
selected.size ? new Set() : new Set(items.map(item => item.id)))}>
{selected.size ? translate("Deselect all") : translate("Select all")}
@@ -121,9 +128,7 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
</section>)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
<span className="text-sm text-muted-foreground">{translate("{selected} of {total} selected", { selected: selected.size, total: items.length })}</span>
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
{submitted ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
</button>
{confirmAction}
</div>
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
</fieldset>
+10 -10
View File
@@ -23,7 +23,7 @@ test("protects changed choices until reverted or unmounted, including after conf
expect(reloadIsBlocked()).toBe(false); // Restoring a set in a different order is clean.
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
await userEvent.click(screen.getAllByRole("button", { name: "Confirm" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: [] });
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "Beta" }));
@@ -43,7 +43,7 @@ test("keeps active choices and model labels unchanged across a locale switch", a
expect(screen.getByRole("button", { name: "Deseleziona tutto" })).toBeVisible();
expect(screen.getByRole("checkbox", { name: /Choose a workspace/ })).toBeChecked();
expect(screen.getByText("SELECT id FROM patients")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Conferma" }));
await userEvent.click(screen.getAllByRole("button", { name: "Conferma" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: ["a", "b"] });
});
@@ -63,7 +63,7 @@ test("confirms the checked ids including initially selected", async () => {
/>
);
await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
});
@@ -87,7 +87,7 @@ test("uses memory-specific selection copy without changing selected ids", async
expect(screen.getByRole("checkbox", { name: /memory raccomandata/i })).toBeChecked();
expect(screen.getByText("1 / 2 memory da applicare")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Applica le memory selezionate" }));
await userEvent.click(screen.getAllByRole("button", { name: "Applica le memory selezionate" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u-memory", kind: "multiselect", choices: ["recommended"] });
});
@@ -157,7 +157,7 @@ test("select-all checks all options", async () => {
/>
);
await userEvent.click(screen.getByRole("button", { name: /select all/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] });
});
@@ -177,11 +177,11 @@ test("deselect-all unchecks all options", async () => {
/>
);
await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] });
});
test("confirm is disabled when nothing checked and allow_empty is false", async () => {
test.each([0, 1])("confirm is disabled when nothing checked and allow_empty is false (position %s)", async (position) => {
const onRespond = vi.fn();
render(
<MultiselectWidget
@@ -194,7 +194,7 @@ test("confirm is disabled when nothing checked and allow_empty is false", async
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[position];
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onRespond).not.toHaveBeenCalled();
@@ -216,7 +216,7 @@ test("deselect-all with allow_empty false disables Conferma (empty confirm impos
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[0];
expect(btn).not.toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
expect(btn).toBeDisabled();
@@ -237,7 +237,7 @@ test("confirm is enabled when allow_empty is false but something is checked", as
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[0];
expect(btn).not.toBeDisabled();
await userEvent.click(btn);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] });
+12 -7
View File
@@ -26,6 +26,16 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
const isDisabled = descriptor.allow_empty === false && checked.size === 0;
const confirmAction = (
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
{descriptor.confirm_label ?? translate("Confirm")}
</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
@@ -33,6 +43,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.title}
</p>
)}
<div>{confirmAction}</div>
<div className="flex flex-col">
{/* Prominent select-all control at the head of the list. */}
<div className="flex items-center justify-between border-b border-border/60 pb-2">
@@ -97,13 +108,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
</div>
</div>
<div>
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
{descriptor.confirm_label ?? translate("Confirm")}
</button>
{confirmAction}
</div>
<ReservedControls
reserved={descriptor.reserved}
@@ -18,15 +18,15 @@ test("other reveals a textarea and emits freetext with the typed text", async ()
// clicking Other does NOT emit a control yet — it reveals the input
expect(onControl).not.toHaveBeenCalled();
await userEvent.type(screen.getByRole("textbox"), "usa la tabella X");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[0]);
expect(onControl).toHaveBeenCalledWith("freetext", "usa la tabella X");
});
test("Send stays disabled until non-whitespace text is entered", async () => {
test.each([0, 1])("Send stays disabled until non-whitespace text is entered (position %s)", async (position) => {
const onControl = vi.fn();
render(<ReservedControls reserved={["other"]} onControl={onControl} />);
await userEvent.click(screen.getByRole("button", { name: /other — specify/i }));
const send = screen.getByRole("button", { name: /send/i });
const send = screen.getAllByRole("button", { name: /send/i })[position];
expect(send).toBeDisabled();
await userEvent.type(screen.getByRole("textbox"), " ");
expect(send).toBeDisabled();
@@ -55,7 +55,7 @@ test.each(["button", "Enter"])("protects Other drafts through %s submission unti
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(input, "Correction");
if (method === "Enter") await userEvent.keyboard("{Enter}");
else await userEvent.click(screen.getByRole("button", { name: "Send" }));
else await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(onControl).toHaveBeenCalledWith("freetext", "Correction");
expect(reloadIsBlocked()).toBe(true);
unmount();
+11 -7
View File
@@ -19,6 +19,15 @@ export function ReservedControls({
if (!text.trim()) return;
onControl("freetext", text);
}
const confirmAction = (
<button
type="button"
className="self-start rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!text.trim()}
onClick={submit}
>
{translate("Send")}</button>
);
if (!reserved?.length) return null;
return (
<div className="flex flex-col gap-2 border-t border-border/60 pt-3">
@@ -36,6 +45,7 @@ export function ReservedControls({
</div>
{otherOpen && (
<div className="flex flex-col gap-2">
<div>{confirmAction}</div>
<textarea
data-awaiting-input="true"
className="thot-awaiting-input min-h-20 w-full resize-y rounded-lg border p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow]"
@@ -45,13 +55,7 @@ export function ReservedControls({
placeholder={translate("Describe your alternative…")}
aria-label={translate("Your alternative")}
/>
<button
type="button"
className="self-start rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!text.trim()}
onClick={submit}
>
{translate("Send")}</button>
{confirmAction}
</div>
)}
</div>
+4 -3
View File
@@ -1,7 +1,8 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useI18n } from "../i18n";
import { useMemo, useState, type ReactElement } from "react";
import type { SchemaTable } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
import { Dialog, DialogTitle } from "../components/ui/dialog";
export function SchemaColumnsDialog({
table,
@@ -42,7 +43,7 @@ export function SchemaColumnsDialog({
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="grid max-h-[70vh] w-[70vw] max-w-[46rem] grid-rows-[auto_auto_1fr] gap-3 sm:max-w-[46rem]">
<SessionDialogContent review className="grid-rows-[auto_auto_minmax(4rem,1fr)]">
<DialogTitle className="font-mono text-sm">{table.name}</DialogTitle>
<div className="flex items-center gap-2">
<input
@@ -91,7 +92,7 @@ export function SchemaColumnsDialog({
)}
</ul>
</div>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
@@ -56,7 +56,7 @@ test("protects changed tables and columns until reverted or unmounted, including
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
await userEvent.click(screen.getByRole("button", { name: /close/i }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
await userEvent.click(screen.getAllByRole("button", { name: "Confirm" })[0]);
expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({ kind: "schema-linking" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
@@ -72,10 +72,10 @@ test("starts include rows checked and exclude rows unchecked even for legacy pay
expect(screen.getByRole("checkbox", { name: "fact_sostituzione" })).not.toBeChecked();
});
test("Confirm emits enacted tables with suggested columns (catalog order)", async () => {
test.each([0, 1])("Confirm emits enacted tables with suggested columns (catalog order) (position %s)", async (position) => {
const onRespond = vi.fn();
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[position]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -93,7 +93,7 @@ test("selecting a column in the modal adds it to the response", async () => {
await userEvent.click(screen.getByRole("button", { name: /columns 1\/2/i }));
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
await userEvent.click(screen.getByRole("button", { name: /close/i })); // dialog X button
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -108,7 +108,7 @@ test("declining a table's enact checkbox drops its columns", async () => {
const onRespond = vi.fn();
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("checkbox", { name: /dim_patient/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -62,12 +62,21 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
const openTable = tables.find((t) => t.id === openId) ?? null;
const confirmAction = (
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={confirm}
>
{translate("Confirm")}</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-base font-semibold leading-snug text-foreground">{descriptor.title}</p>
)}
<div>{confirmAction}</div>
<div className="flex flex-col gap-2">
{tables.map((t) => {
const on = enacted.has(t.id);
@@ -119,11 +128,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
)}
<div>
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={confirm}
>
{translate("Confirm")}</button>
{confirmAction}
</div>
<ReservedControls
+1 -1
View File
@@ -38,7 +38,7 @@ test.each(cases)("%s protects Other drafts through submission until the host unm
expect(onRespond).not.toHaveBeenCalled(); // Opening Other inside a form must not submit it.
await userEvent.type(screen.getByRole("textbox", { name: "Your alternative" }), "Alternative draft");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: action }));
await userEvent.click(screen.getAllByRole("button", { name: action })[0]);
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
+3 -3
View File
@@ -22,9 +22,9 @@ test("reject opens a freetext and combines the reason", async () => {
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Rifiuta/ })[0]);
await userEvent.type(screen.getByRole("textbox"), "join sbagliata");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u1",
kind: "artifact-gate",
@@ -53,7 +53,7 @@ test("approve (no opens) responds immediately without child widget", async () =>
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Approva/ })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
// No textbox should appear
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();