fix(ui): expand session dialogs and repeat confirmation actions
Publish documentation / publish (push) Successful in 24s
Publish documentation / publish (push) Successful in 24s
This commit is contained in:
@@ -344,6 +344,22 @@ default, hover, focus, active, disabled, loading, and error behavior where those
|
||||
In embedded mode the portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
|
||||
actual application container. Narrow session document panels may use the available width.
|
||||
|
||||
### Session review and confirmations
|
||||
|
||||
Session dialogs use the visible application area, including the portal's header
|
||||
and side rail. Artifact and schema-column review can grow to 80rem wide and the
|
||||
available height; short confirmations use up to 40rem and at least 18rem when
|
||||
space permits. Keep a 24px outer margin on desktop and 8px on small or short
|
||||
screens. Long review content scrolls internally; on very short screens the
|
||||
whole dialog can also scroll so every action remains reachable.
|
||||
|
||||
Session forms and review gates repeat their existing primary confirmation above
|
||||
and below the content, sharing selection, validation, pending state and response
|
||||
handlers. Alternate-response inputs follow the same rule. Reserved navigation
|
||||
controls remain below the review. Stop/delete initially focus Cancel; rename
|
||||
initially focuses the name field. Administration dialogs and forms retain their
|
||||
existing layout and actions.
|
||||
|
||||
### Tabs
|
||||
|
||||
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
|
||||
|
||||
@@ -12,6 +12,21 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository
|
||||
`docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback
|
||||
requirements as mandatory; do not replace the running server stack in place.
|
||||
|
||||
## Session review layout deployed — 2026-09-14
|
||||
|
||||
Session-only dialogs now use the visible app bounds: artifact/column review grows
|
||||
up to 80rem wide and the available height; short confirmations grow to 40rem.
|
||||
Existing primary actions appear above and below session forms and review content,
|
||||
with shared handlers, validation and pending state. Stop/delete focus Cancel;
|
||||
rename focuses the name field. Administration surfaces are unchanged. The activity
|
||||
shortcut is relabelled To Administration / Vai all’Amministrazione, retaining its
|
||||
workspace-management destination. 778 frontend tests, five responsive browser
|
||||
scenarios, typecheck, translations and the production build passed. The owner
|
||||
authorized deployment and the server frontend was recreated at 18:09 CEST with tag `b1723c34-session-dialogs-20260914`. Frontend is healthy,
|
||||
Omics serves the new assets, and doctor passes 13/13 checks. Core and Omics web
|
||||
were not restarted. See DESIGN.md for the layout contract and
|
||||
`docs/reports/2026-09-14-session-dialogs-release.md` for provenance and rollback.
|
||||
|
||||
## Session composer and empty Memory fix deployed — 2026-09-14
|
||||
|
||||
The embedded shell now caps its height at the portal mount height, keeping steering
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Dialog di sessione: rilascio server del 14 settembre 2026
|
||||
|
||||
Aggiornamento autorizzato dall'utente ed eseguito alle 18:09 CEST. Il solo frontend
|
||||
è stato ricreato; core e Omics web mantengono ID e timestamp di avvio precedenti.
|
||||
La modifica comprende dialog di sessione più ampi e contenuti nella vista,
|
||||
conferme sopra e sotto le form, e l'etichetta To Administration / Vai
|
||||
all’Amministrazione. Le superfici amministrative e il workflow restano invariati.
|
||||
|
||||
## Provenienza e configurazione
|
||||
|
||||
- Base sorgente: `b1723c34`, più le modifiche frontend della working tree verificate.
|
||||
- Checkout di build: `/home/chirone/Thoth`; i 32 file frontend interessati sono
|
||||
stati allineati anche in `/srv/thothii-v2/source/ThothII`, conservando i precedenti.
|
||||
- Immagine: `thothii-v2-frontend:b1723c34-session-dialogs-20260914`.
|
||||
- Image ID: `sha256:d2ed3dec42f8a56536ebbc8d74f13892d4c42c9a7f2fb67c476ff39f43fe7af9`.
|
||||
- Container: `becadeb12b5d9bd380700813e1f489e7f5674575b98ea0d00c6b7d751c890278`.
|
||||
- Avvio: `2026-09-14T16:09:12.835900768Z`.
|
||||
- Il solo campo `services.frontend.image` dell'override operativo
|
||||
`/srv/thothii-v2/operator/compose.portal-upstream.yaml` è fissato al nuovo tag.
|
||||
Il tag condiviso in `operator.env` non è cambiato: core e job mantengono
|
||||
`49333a2d-session-memory-fix`. I prossimi aggiornamenti frontend devono aggiornare
|
||||
esplicitamente questo campo, oppure ripristinarne l'interpolazione condivisa.
|
||||
- Nessuna migrazione, modifica di dati, credenziali, modello o auth.
|
||||
|
||||
## Verifiche
|
||||
|
||||
Prima della distribuzione: 778 test frontend, cinque scenari browser responsive
|
||||
(320/390/844/1280/1440 px, full e embedded), typecheck, traduzioni, build Vite e
|
||||
Docker. Smoke della configurazione embedded montata nella nuova immagine superato.
|
||||
|
||||
Dopo la distribuzione:
|
||||
|
||||
- frontend healthy, core e Omics web invariati e healthy;
|
||||
- `nginx -t` e reload Omics riusciti;
|
||||
- config embedded e asset nuovi HTTP 200 tramite nginx Omics locale con Host reale;
|
||||
- JavaScript `index-inK74FWS.js` contiene entrambe le nuove etichette;
|
||||
- CSS `index-4WPDUJu7.css` contiene le regole `.thot-session-dialog`;
|
||||
- manifest nuovo raggiungibile dal container Omics; trascorso il TTL di 30 secondi;
|
||||
- API `/me` senza login ancora HTTP 403;
|
||||
- `tht doctor --json`: `ok: true`, 13/13 controlli superati.
|
||||
|
||||
I test browser usano dati simulati; l'accettazione della resa nella sessione
|
||||
Omics autenticata dell'utente richiede di ricaricare la pagina. Non sono state
|
||||
create sessioni reali né invocati modelli per il collaudo.
|
||||
|
||||
## Backup e rollback
|
||||
|
||||
Directory protetta: `/srv/thothii-v2/backups/20260914-session-dialogs` (0700).
|
||||
Contiene override precedente, archivi sorgente prima/dopo, patch, inventario dei
|
||||
container e checksum verificati. L'immagine precedente è conservata anche come
|
||||
`thothii-v2-frontend:before-session-dialogs-20260914`.
|
||||
|
||||
Il launcher `compose.sh` nel backup conserva esattamente progetto, directory,
|
||||
env file e ordine dei cinque file Compose della distribuzione. Rilascio eseguito:
|
||||
|
||||
```bash
|
||||
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
|
||||
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
|
||||
```
|
||||
|
||||
Rollback applicativo, dopo aver verificato che non ci siano release successive:
|
||||
|
||||
```bash
|
||||
sudo cp -p /srv/thothii-v2/backups/20260914-session-dialogs/compose.portal-upstream.yaml \
|
||||
/srv/thothii-v2/operator/compose.portal-upstream.yaml
|
||||
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
|
||||
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
|
||||
sudo docker exec omics_portal-nginx-1 nginx -t
|
||||
sudo docker exec omics_portal-nginx-1 nginx -s reload
|
||||
```
|
||||
|
||||
Il ripristino dei sorgenti è separato: gli archivi conservano esattamente i file
|
||||
interessati. Un rollback grafico non richiede ripristino di database o indici.
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user