Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -1,7 +1,23 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setLocale } from "../i18n";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
|
||||
afterEach(() => act(() => setLocale("en")));
|
||||
|
||||
test("localizes repair recovery without changing the recorded choice or archive content", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<ArchiveRepairWidget descriptor={{ id: "retry-it", widget: "archive-repair",
|
||||
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={onRespond} />);
|
||||
act(() => setLocale("it"));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("L'attivazione dell'indice è incompleta");
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "retry-it", choices: ["e"] });
|
||||
});
|
||||
|
||||
const repair = { repair_id: "receipt", reason: "Conflicting order definitions", choice: null,
|
||||
status: "proposed", saved: false, indexed: false, can_apply: true, options: [
|
||||
{ id: "m", label: "Correct Memory", archive: "memory", target_id: "mem-1",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
@@ -25,27 +26,35 @@ const fieldLabels: Record<string, string> = {
|
||||
language: "Language", purposes: "Purposes", applies_to: "Applies to", payload: "Content",
|
||||
provenance: "Source history", review_items: "Review items",
|
||||
};
|
||||
const nestedFieldLabels: Record<string, string> = {
|
||||
...fieldLabels,
|
||||
database: "Database", schema_name: "Schema", table: "Table", column: "Column",
|
||||
target_id: "Target", meaning: "Meaning", rule: "Rule", text: "Text", source: "Source",
|
||||
};
|
||||
|
||||
function Value({ value }: { value: unknown }) {
|
||||
const { t: translate } = useI18n();
|
||||
if (value == null) return null;
|
||||
if (Array.isArray(value)) return <ul className="space-y-1">{value.map((item, index) =>
|
||||
<li key={index}><Value value={item} /></li>)}</ul>;
|
||||
if (typeof value === "object") return <dl className="space-y-1">{Object.entries(value).map(([key, item]) =>
|
||||
<div key={key}><dt className="text-xs capitalize">{key.replaceAll("_", " ")}</dt>
|
||||
<div key={key}><dt className="text-xs capitalize">{nestedFieldLabels[key] ? translate(nestedFieldLabels[key]) : key.replaceAll("_", " ")}</dt>
|
||||
<dd className="pl-2"><Value value={item} /></dd></div>)}</dl>;
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
function Content({ value }: { value: Record<string, unknown> }) {
|
||||
const { t: translate } = useI18n();
|
||||
return <dl className="min-w-0 space-y-2 text-sm">{Object.entries(fieldLabels).map(([key, label]) => {
|
||||
const item = value[key];
|
||||
if (item == null || item === "" || (Array.isArray(item) && item.length === 0)) return null;
|
||||
return <div key={key}><dt className="font-medium">{label}</dt>
|
||||
return <div key={key}><dt className="font-medium">{translate(label)}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words text-muted-foreground"><Value value={item} /></dd></div>;
|
||||
})}</dl>;
|
||||
}
|
||||
|
||||
export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const repair = descriptor.repair as Repair;
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const send = (choice: string) => {
|
||||
@@ -54,37 +63,35 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
onRespond({ id: descriptor.id, choices: [choice] });
|
||||
};
|
||||
const pending = repair.status === "applying" || repair.status === "pending_activation";
|
||||
return <section aria-label="Archive conflict repair" className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<div><h3 className="font-semibold">Resolve archive conflict</h3>
|
||||
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>
|
||||
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ?? repair.status}</p>
|
||||
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ? translate(statuses[repair.status]) : repair.status}</p>
|
||||
{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">An administrator must approve shared archive corrections.
|
||||
You can reject the proposals or continue reviewing this question.</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">
|
||||
{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" ? "Memory" : "Evidence"} · {option.target_id}
|
||||
{repair.choice === option.id ? " · Selected" : ""}</p></div>
|
||||
<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>
|
||||
<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">Before this proposal</p>
|
||||
<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">Proposed result</p>
|
||||
<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">
|
||||
Apply this correction to {option.archive === "memory" ? "Memory" : "Evidence"}
|
||||
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
|
||||
</button>}
|
||||
</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">
|
||||
None are adequate — reformulate
|
||||
</button>}
|
||||
{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">Retry selected correction</button>}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">{translate("Retry selected correction")}</button>}
|
||||
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? "Continue with repair pending" : "Continue question review"}</button>}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? translate("Continue with repair pending") : translate("Continue question review")}</button>}
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => {
|
||||
if (!submitted) { setSubmitted(true); onRespond({ id: descriptor.id, control, text }); }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import type { UiResponse, WidgetDescriptor } from "../api/types";
|
||||
@@ -14,6 +15,7 @@ import { LinkageHost } from "./LinkageHost";
|
||||
* out is an action or a reserved control, both of which call onRespond.
|
||||
*/
|
||||
export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const [pendingLinkage, setPendingLinkage] = useState<{
|
||||
parentResponse: UiResponse;
|
||||
childDescriptor: WidgetDescriptor;
|
||||
@@ -33,14 +35,14 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
|
||||
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"
|
||||
>
|
||||
<DialogTitle className="text-lg leading-snug">
|
||||
{descriptor.title ?? "Artifact review"}
|
||||
{descriptor.title ?? translate("Artifact review")}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-5">
|
||||
{descriptor.artifact ? (
|
||||
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No artifact available.</p>
|
||||
<p className="text-sm text-muted-foreground">{translate("No artifact available.")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FallbackWidget } from "./FallbackWidget";
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test("protects manual response drafts until cleared or unmounted, including after sending", async () => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<FallbackWidget descriptor={{ id: "unknown", widget: "unknown" }} onRespond={onRespond} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.type(screen.getByRole("textbox"), "Manual answer");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.clear(screen.getByRole("textbox"));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.type(screen.getByRole("textbox"), "Correction");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "unknown", control: "freetext", text: "Correction" });
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.type(screen.getByRole("textbox"), " updated");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
import type { WidgetProps } from "./types";
|
||||
|
||||
export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const [text, setText] = useState("");
|
||||
useUnsavedDraft(text);
|
||||
return (
|
||||
<div className="rounded-xl border border-border/70 bg-card p-3 shadow-sm space-y-2">
|
||||
<p className="text-sm text-amber-600">Unsupported widget (kind: {descriptor.widget}) — respond manually</p>
|
||||
<p className="text-sm text-amber-600">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
|
||||
<pre className="text-xs font-mono overflow-auto max-h-48 rounded-lg bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
|
||||
<textarea className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Send</button>
|
||||
<textarea aria-label={translate("Manual response")} className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
|
||||
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => {
|
||||
onRespond({ id: descriptor.id, control: "freetext", text });
|
||||
}}>{translate("Send")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { FreetextWidget } from "./FreetextWidget";
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test("protects an answer draft until cleared or unmounted, including after sending", async () => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.type(screen.getByRole("textbox"), "Draft answer");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.clear(screen.getByRole("textbox"));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.type(screen.getByRole("textbox"), "Answer{Enter}");
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "Answer" });
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.type(screen.getByRole("textbox"), " updated");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
test("submits typed text", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
import type { WidgetProps } from "./types";
|
||||
|
||||
export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const [text, setText] = useState("");
|
||||
useUnsavedDraft(text);
|
||||
function submit() {
|
||||
if (text.trim()) onRespond({ id: descriptor.id, kind: "freetext", text });
|
||||
if (text.trim()) {
|
||||
onRespond({ id: descriptor.id, kind: "freetext", text });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
@@ -19,14 +25,14 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }}
|
||||
placeholder="Type your answer…"
|
||||
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}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{translate("Send")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import type { ReactElement } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getSessionDocuments } from "../api/sessions";
|
||||
@@ -46,11 +47,12 @@ function ResolvedDoc({
|
||||
filename: string;
|
||||
fallback: ReactElement;
|
||||
}): ReactElement {
|
||||
const { t: translate } = useI18n();
|
||||
const { data: docs, isLoading, isError } = useQuery<SessionDocument[]>({
|
||||
queryKey: ["session-documents", sessionId],
|
||||
queryFn: () => getSessionDocuments(sessionId),
|
||||
});
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
|
||||
if (isLoading) return <p className="text-sm text-muted-foreground">{translate("Loading…")}</p>;
|
||||
const doc = docs?.find((d) => d.key === docKey);
|
||||
if (isError || !doc || doc.format !== "markdown") return fallback;
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { Link2 } from "lucide-react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const options = descriptor.options ?? [];
|
||||
|
||||
return (
|
||||
@@ -14,9 +16,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
These joins are required by the selected tables and are shown for review.
|
||||
To request a correction, use Other — specify.
|
||||
</p>
|
||||
{translate("These joins are required by the selected tables and are shown for review. To request a correction, use Other — specify.")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
@@ -46,8 +46,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
{option.rationale && (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Rationale
|
||||
</span>
|
||||
{translate("Rationale")}</span>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{option.rationale}
|
||||
</p>
|
||||
@@ -69,7 +68,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
})
|
||||
}
|
||||
>
|
||||
{descriptor.confirm_label ?? "Continue"}
|
||||
{descriptor.confirm_label ?? translate("Continue")}
|
||||
</button>
|
||||
|
||||
<ReservedControls
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import type { MemoryInput } from "../api/memory";
|
||||
import { setLocale } from "../i18n";
|
||||
|
||||
afterEach(() => act(() => setLocale("en")));
|
||||
|
||||
const card: MemoryInput = { family: "domain_clarification", subject: "Active patients",
|
||||
detail: "Active means the flag is true", scope: "clinical", rationale: "Approved definition",
|
||||
@@ -13,6 +16,53 @@ const descriptor = { id: "review", widget: "memory-review", summary: { summary_i
|
||||
reason: "Approved solution", source_seqs: [7], before: null, target_id: null },
|
||||
] } };
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test.each(["subject", "scope", "detail", "rationale", "question", "concepts"])(
|
||||
"protects unsaved Memory %s edits until the submitted widget unmounts",
|
||||
async (field) => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledTimes(1);
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
test("protects Memory selection, dependency and link drafts, including invalid edits, until reverted or unmounted", async () => {
|
||||
const { unmount } = render(<MemoryReviewWidget descriptor={descriptor} onRespond={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Select all" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
|
||||
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" }));
|
||||
expect(reloadIsBlocked()).toBe(true); // Required database is still empty.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove dependency" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Remove link" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.type(screen.getByLabelText("domain detail"), " correction");
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
@@ -21,7 +71,7 @@ test("sends only selected cards with reviewer edits and physical dependencies",
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]!);
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
|
||||
await userEvent.type(screen.getByLabelText("domain dependency 1 schema_name"), "clinical");
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledTimes(1);
|
||||
@@ -51,3 +101,28 @@ test("links selected proposals and keeps approved SQL read only", async () => {
|
||||
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
|
||||
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
|
||||
});
|
||||
|
||||
test("switches review chrome to Italian without changing edited cards, SQL or response identifiers", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
await userEvent.clear(screen.getByLabelText("domain detail"));
|
||||
await userEvent.type(screen.getByLabelText("domain detail"), "Reviewer correction {{scope}");
|
||||
act(() => setLocale("it"));
|
||||
|
||||
expect(screen.getByRole("form", { name: "Riepilogo delle memorie" })).toBeVisible();
|
||||
expect(screen.getByLabelText("domain dettaglio")).toHaveValue("Reviewer correction {scope}");
|
||||
expect(screen.getByText("2 di 2 selezionate")).toBeVisible();
|
||||
expect(screen.getByText("SELECT COUNT(*) FROM patients WHERE active")).toBeVisible();
|
||||
expect(screen.getByText("Approved clarification")).toBeVisible();
|
||||
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" }));
|
||||
|
||||
const response = onRespond.mock.calls[0][0];
|
||||
expect(response).toMatchObject({ id: "review", kind: "memory-review" });
|
||||
expect(JSON.parse(response.text).items[0]).toEqual({ id: "domain", card: { ...card,
|
||||
detail: "Reviewer correction {scope}",
|
||||
dependencies: [{ database: "warehouse", schema_name: "", table: "patients", column: "" }],
|
||||
} });
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
import type { MemoryCard, MemoryInput } from "../api/memory";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
@@ -10,20 +12,24 @@ interface Proposal {
|
||||
interface Summary { summary_id: string; items: Proposal[] }
|
||||
const labels = { domain_clarification: "Domain clarification", sql_rule: "SQL rule",
|
||||
solved_question: "Solved question", explained_error: "Explained error" };
|
||||
const fieldLabels = { subject: "subject", detail: "detail", scope: "scope", rationale: "rationale",
|
||||
question: "question", sql: "SQL", database: "database", schema_name: "schema", table: "table", column: "column" };
|
||||
const inputStyle = "min-w-0 w-full rounded-md border border-border bg-background p-2 text-sm text-foreground";
|
||||
|
||||
export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const summary = descriptor.summary as Summary;
|
||||
const [items, setItems] = useState(summary.items);
|
||||
const [selected, setSelected] = useState(new Set(summary.items.map(p => p.id)));
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
useUnsavedDraft(JSON.stringify({ items, selected: [...selected].sort() }));
|
||||
const patch = (id: string, changes: Partial<MemoryInput>) => setItems(current => current.map(
|
||||
item => item.id === id ? { ...item, card: { ...item.card, ...changes } } : item));
|
||||
const toggle = (id: string) => setSelected(current => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
|
||||
return <form aria-label="Memory summary" className="space-y-5 rounded-xl border border-border bg-card p-5"
|
||||
return <form aria-label={translate("Memory summary")} className="space-y-5 rounded-xl border border-border bg-card p-5"
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (submitted) return;
|
||||
@@ -35,90 +41,88 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
}) });
|
||||
}}>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">Memory for future questions</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Review the scope and wording, then choose what to save.
|
||||
Only selected cards and their links will enter the shared archive.</p>
|
||||
<h3 className="font-semibold text-foreground">{translate("Memory for future questions")}</h3>
|
||||
<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">
|
||||
<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 ? "Deselect all" : "Select all"}
|
||||
{selected.size ? translate("Deselect all") : translate("Select all")}
|
||||
</button>
|
||||
{items.map(item => <section key={item.id} className="space-y-3 border-t border-border pt-4">
|
||||
<label className="flex items-start gap-3 text-sm font-medium">
|
||||
<input type="checkbox" className="mt-1 size-4" checked={selected.has(item.id)} onChange={() => toggle(item.id)} />
|
||||
<span>{item.card.subject}<span className="block text-xs font-normal text-muted-foreground">
|
||||
{item.target_id ? "Update existing card" : "New card"} · {labels[item.card.family]}
|
||||
{item.target_id ? translate("Update existing card") : translate("New card")} · {translate(labels[item.card.family])}
|
||||
</span></span>
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">{item.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">Approved decisions: {item.source_seqs.map(seq => `#${seq}`).join(", ")}</p>
|
||||
<p className="text-xs text-muted-foreground">{translate("Approved decisions: {decisions}", { decisions: item.source_seqs.map(seq => `#${seq}`).join(", ") })}</p>
|
||||
{item.before && <details className="rounded-md bg-muted p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium">Current card being replaced</summary>
|
||||
<summary className="cursor-pointer font-medium">{translate("Current card being replaced")}</summary>
|
||||
<dl className="mt-2 space-y-2">
|
||||
{(["subject", "detail", "scope", "rationale", "question", "sql"] as const).map(key =>
|
||||
<div key={key}><dt className="font-medium capitalize">{key}</dt>
|
||||
<div key={key}><dt className="font-medium capitalize">{translate(fieldLabels[key])}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">{item.before![key] || "—"}</dd></div>)}
|
||||
</dl>
|
||||
</details>}
|
||||
<fieldset disabled={!selected.has(item.id)} className="min-w-0 space-y-3 disabled:opacity-60">
|
||||
{(["subject", "scope", "detail", "rationale", "question"] as const).map(key => <label key={key} className="block text-xs font-medium capitalize">
|
||||
{key}<textarea aria-label={`${item.id} ${key}`} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
|
||||
{translate(fieldLabels[key])}<textarea aria-label={translate("{id} {field}", { id: item.id, field: translate(fieldLabels[key]) })} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
|
||||
required={key === "subject" || key === "scope" || (key === "rationale" && item.card.family === "explained_error")}
|
||||
value={item.card[key]} onChange={event => patch(item.id, { [key]: event.target.value })} />
|
||||
</label>)}
|
||||
{item.card.family === "solved_question" && <div className="text-sm">
|
||||
<p className="font-medium">Approved SQL</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
|
||||
<p className="mt-1 text-xs text-muted-foreground">To change the solution, return to SQL review.</p>
|
||||
<p className="font-medium">{translate("Approved SQL")}</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{translate("To change the solution, return to SQL review.")}</p>
|
||||
</div>}
|
||||
<label className="block text-xs font-medium">Concepts, separated by commas
|
||||
<input aria-label={`${item.id} concepts`} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
|
||||
<label className="block text-xs font-medium">{translate("Concepts, separated by commas")}<input aria-label={translate("{id} concepts", { id: item.id })} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
|
||||
onChange={event => patch(item.id, { concepts: event.target.value.split(",").map(v => v.trim()) })} />
|
||||
</label>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Physical dependencies</p>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">{translate("Physical dependencies")}</p>
|
||||
{item.card.dependencies.map((dependency, index) => <div key={index} className="grid gap-2 sm:grid-cols-2">
|
||||
{(["database", "schema_name", "table", "column"] as const).map(key => <input key={key}
|
||||
className={inputStyle} aria-label={`${item.id} dependency ${index+1} ${key}`} placeholder={key}
|
||||
className={inputStyle} aria-label={translate("{id} dependency {number} {field}", { id: item.id, number: index + 1, field: translate(fieldLabels[key]) })} placeholder={translate(fieldLabels[key])}
|
||||
required={key === "database"} value={dependency[key]} onChange={event => patch(item.id, {
|
||||
dependencies: item.card.dependencies.map((d, i) => i === index ? { ...d, [key]: event.target.value } : d),
|
||||
})} />)}
|
||||
<button type="button" className="text-left text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
dependencies: item.card.dependencies.filter((_, i) => i !== index),
|
||||
})}>Remove dependency</button>
|
||||
})}>{translate("Remove dependency")}</button>
|
||||
</div>)}
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
dependencies: [...item.card.dependencies, { database: "", schema_name: "", table: "", column: "" }],
|
||||
})}>Add dependency</button>
|
||||
})}>{translate("Add dependency")}</button>
|
||||
</div>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">Links to other cards</p>
|
||||
<div className="space-y-2"><p className="text-xs font-medium">{translate("Links to other cards")}</p>
|
||||
{item.card.links.map((link, index) => <div key={index} className="space-y-2 rounded-md border border-border p-2">
|
||||
<input aria-label={`${item.id} link ${index+1} target`} className={inputStyle} required
|
||||
value={link.target_id} placeholder="Card ID or proposal reference" onChange={event => patch(item.id, {
|
||||
<input aria-label={translate("{id} link {number} target", { id: item.id, number: index + 1 })} className={inputStyle} required
|
||||
value={link.target_id} placeholder={translate("Card ID or proposal reference")} onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, target_id: event.target.value } : l),
|
||||
})} />
|
||||
<input aria-label={`${item.id} link ${index+1} meaning`} className={inputStyle} required
|
||||
value={link.meaning} placeholder="Why these cards are linked" onChange={event => patch(item.id, {
|
||||
<input aria-label={translate("{id} link {number} meaning", { id: item.id, number: index + 1 })} className={inputStyle} required
|
||||
value={link.meaning} placeholder={translate("Why these cards are linked")} onChange={event => patch(item.id, {
|
||||
links: item.card.links.map((l, i) => i === index ? { ...l, meaning: event.target.value } : l),
|
||||
})} />
|
||||
<button type="button" className="text-xs text-muted-foreground" onClick={() => patch(item.id, {
|
||||
links: item.card.links.filter((_, i) => i !== index),
|
||||
})}>Remove link</button>
|
||||
})}>{translate("Remove link")}</button>
|
||||
</div>)}
|
||||
<select aria-label={`${item.id} add link`} className={inputStyle} value="" onChange={event => {
|
||||
<select aria-label={translate("{id} add link", { id: item.id })} className={inputStyle} value="" onChange={event => {
|
||||
if (event.target.value) patch(item.id, { links: [...item.card.links, { target_id: event.target.value, meaning: "" }] });
|
||||
}}><option value="">Link to another proposed card…</option>
|
||||
}}><option value="">{translate("Link to another proposed card…")}</option>
|
||||
{items.filter(p => p.id !== item.id).map(p => <option key={p.id} value={`proposal:${p.id}`}>{p.card.subject}</option>)}
|
||||
</select>
|
||||
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
|
||||
links: [...item.card.links, { target_id: "", meaning: "" }],
|
||||
})}>Link to an existing card</button>
|
||||
})}>{translate("Link to an existing card")}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</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">{selected.size} of {items.length} selected</span>
|
||||
<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 ? "Saving…" : selected.size ? "Save selected and finish" : "Finish without saving Memory"}
|
||||
{submitted ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
|
||||
</button>
|
||||
</div>
|
||||
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { setLocale } from "../i18n";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MultiselectWidget } from "./MultiselectWidget";
|
||||
|
||||
afterEach(() => act(() => setLocale("en")));
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test("protects changed choices until reverted or unmounted, including after confirming", async () => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", options: [
|
||||
{ id: "a", label: "Alpha", selected: true }, { id: "b", label: "Beta", selected: true },
|
||||
] }} onRespond={onRespond} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: [] });
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Beta" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps active choices and model labels unchanged across a locale switch", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", options: [
|
||||
{ id: "a", label: "Choose a workspace", detail: "SELECT id FROM patients", selected: true },
|
||||
{ id: "b", label: "Model-defined choice" },
|
||||
] }} onRespond={onRespond} />);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Model-defined choice" }));
|
||||
act(() => setLocale("it"));
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: ["a", "b"] });
|
||||
});
|
||||
|
||||
test("confirms the checked ids including initially selected", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const options = descriptor.options ?? [];
|
||||
const initialChecked = new Set(options.filter((o) => o.selected).map((o) => o.id));
|
||||
const [checked, setChecked] = useState<Set<string>>(initialChecked);
|
||||
useUnsavedDraft(JSON.stringify([...checked].sort()));
|
||||
|
||||
const allChecked = options.length > 0 && options.every((o) => checked.has(o.id));
|
||||
|
||||
@@ -38,10 +42,10 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
setChecked(allChecked ? new Set() : new Set(options.map((o) => o.id)))
|
||||
}
|
||||
>
|
||||
{allChecked ? "Deselect all" : "Select all"}
|
||||
{allChecked ? translate("Deselect all") : translate("Select all")}
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{checked.size} / {options.length} {descriptor.selection_label ?? "selected"}
|
||||
{checked.size} / {options.length} {descriptor.selection_label ?? translate("selected")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
@@ -68,8 +72,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
{o.rationale && (
|
||||
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Reason
|
||||
</div>
|
||||
{translate("Reason")}</div>
|
||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
|
||||
</div>
|
||||
@@ -78,8 +81,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
|
||||
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide">
|
||||
Context question
|
||||
</div>
|
||||
{translate("Context question")}</div>
|
||||
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{o.meta.question_context}
|
||||
@@ -98,7 +100,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
disabled={isDisabled}
|
||||
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
|
||||
>
|
||||
{descriptor.confirm_label ?? "Confirm"}
|
||||
{descriptor.confirm_label ?? translate("Confirm")}
|
||||
</button>
|
||||
</div>
|
||||
<ReservedControls
|
||||
|
||||
@@ -35,3 +35,38 @@ test("Send stays disabled until non-whitespace text is entered", async () => {
|
||||
await userEvent.type(screen.getByRole("textbox"), "ok");
|
||||
expect(send).not.toBeDisabled();
|
||||
});
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test.each(["button", "Enter"])("protects Other drafts through %s submission until cleared or unmounted", async (method) => {
|
||||
const onControl = vi.fn();
|
||||
const { unmount } = render(<ReservedControls reserved={["back", "exit", "other"]} onControl={onControl} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
const input = screen.getByRole("textbox", { name: "Your alternative" });
|
||||
await userEvent.type(input, "Alternative");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.clear(input);
|
||||
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" }));
|
||||
expect(onControl).toHaveBeenCalledWith("freetext", "Correction");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
test("removes an unsubmitted Other draft warning on unmount", async () => {
|
||||
const { unmount } = render(<ReservedControls reserved={["other"]} onControl={vi.fn()} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), "Correction");
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
|
||||
const LABELS: Record<string, string> = { back: "Go back", exit: "Exit", other: "Other — specify" };
|
||||
|
||||
@@ -9,19 +11,26 @@ export function ReservedControls({
|
||||
reserved?: string[];
|
||||
onControl: (c: string, text?: string) => void;
|
||||
}) {
|
||||
const { t: translate } = useI18n();
|
||||
const [otherOpen, setOtherOpen] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
useUnsavedDraft(text, otherOpen && Boolean(reserved?.length));
|
||||
function submit() {
|
||||
if (!text.trim()) return;
|
||||
onControl("freetext", text);
|
||||
}
|
||||
if (!reserved?.length) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-t border-border/60 pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reserved.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c}
|
||||
className="rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => (c === "other" ? setOtherOpen(true) : onControl(c))}
|
||||
>
|
||||
{LABELS[c] ?? c}
|
||||
{LABELS[c] ? translate(LABELS[c]) : c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -32,16 +41,17 @@ export function ReservedControls({
|
||||
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]"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && text.trim()) { e.preventDefault(); onControl("freetext", text); } }}
|
||||
placeholder="Describe your alternative…"
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && text.trim()) { e.preventDefault(); submit(); } }}
|
||||
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={() => onControl("freetext", text)}
|
||||
onClick={submit}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{translate("Send")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -17,6 +18,7 @@ export function SchemaColumnsDialog({
|
||||
onToggle: (column: string) => void;
|
||||
onClose: () => void;
|
||||
}): ReactElement {
|
||||
const { t: translate } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Suggested-first, then catalog order. Sorting on the descriptor's `suggested`
|
||||
@@ -47,8 +49,8 @@ export function SchemaColumnsDialog({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Filter columns"
|
||||
placeholder="Filter columns…"
|
||||
aria-label={translate("Filter columns")}
|
||||
placeholder={translate("Filter columns…")}
|
||||
className="min-w-0 flex-1 rounded-md border border-input bg-card px-2.5 py-1.5 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20"
|
||||
/>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
@@ -85,7 +87,7 @@ export function SchemaColumnsDialog({
|
||||
);
|
||||
})}
|
||||
{displayed.length === 0 && (
|
||||
<li className="py-3 text-sm text-muted-foreground">No columns match the filter.</li>
|
||||
<li className="py-3 text-sm text-muted-foreground">{translate("No columns match the filter.")}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,38 @@ const descriptor: WidgetDescriptor = {
|
||||
reserved: ["back", "exit", "other"],
|
||||
};
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
test("protects changed tables and columns until reverted or unmounted, including after confirming", async () => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Columns 1/2" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
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" }));
|
||||
expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({ kind: "schema-linking" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
test("starts include rows checked and exclude rows unchecked even for legacy payloads", () => {
|
||||
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useState } from "react";
|
||||
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
|
||||
import type { WidgetProps } from "./types";
|
||||
import type { SchemaTable } from "../api/types";
|
||||
import { SchemaColumnsDialog } from "./SchemaColumnsDialog";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
const tables: SchemaTable[] = descriptor.tables ?? [];
|
||||
const [enacted, setEnacted] = useState<Set<string>>(
|
||||
() => new Set(tables.filter((t) => t.kind === "promote" && t.recommended).map((t) => t.id))
|
||||
@@ -20,6 +23,10 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
|
||||
return m;
|
||||
});
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
useUnsavedDraft(JSON.stringify({
|
||||
enacted: [...enacted].sort(),
|
||||
selected: [...selected].map(([id, columns]) => [id, [...columns].sort()]),
|
||||
}));
|
||||
|
||||
function toggleEnact(id: string) {
|
||||
setEnacted((prev) => {
|
||||
@@ -81,7 +88,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-foreground">{t.name}</span>
|
||||
<span className="thot-label">{t.kind === "promote" ? "Promote" : "Exclude"}</span>
|
||||
<span className="thot-label">{t.kind === "promote" ? translate("Promote") : translate("Exclude")}</span>
|
||||
</div>
|
||||
{t.description && <p className="mt-0.5 text-sm text-muted-foreground">{t.description}</p>}
|
||||
{t.rationale && <p className="mt-0.5 text-sm text-foreground/80">{t.rationale}</p>}
|
||||
@@ -90,7 +97,9 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
|
||||
className="shrink-0 self-center rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setOpenId(t.id)}
|
||||
>
|
||||
{t.kind === "promote" ? `Columns ${count}/${t.columns.length}` : `Columns ${t.columns.length}`}
|
||||
{t.kind === "promote"
|
||||
? translate("Columns {selected}/{total}", { selected: count, total: t.columns.length })
|
||||
: translate("Columns {total}", { total: t.columns.length })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,8 +123,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
|
||||
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}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
{translate("Confirm")}</button>
|
||||
</div>
|
||||
|
||||
<ReservedControls
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
import { IntroBody } from "./IntroBody";
|
||||
|
||||
export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const { t: translate } = useI18n();
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
{(descriptor.title || descriptor.intro) && (
|
||||
@@ -25,8 +27,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
<span>{o.label}</span>
|
||||
{o.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
|
||||
Recommended
|
||||
</span>
|
||||
{translate("Recommended")}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { WidgetDescriptor } from "../api/types";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { SelectWidget } from "./SelectWidget";
|
||||
import { MultiselectWidget } from "./MultiselectWidget";
|
||||
import { JoinReviewWidget } from "./JoinReviewWidget";
|
||||
import { ArtifactGateWidget } from "./ArtifactGateWidget";
|
||||
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||
import { MemoryReviewWidget } from "./MemoryReviewWidget";
|
||||
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
|
||||
|
||||
function reloadIsBlocked() {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
}
|
||||
|
||||
const cases: [string, ComponentType<WidgetProps>, Partial<WidgetDescriptor>, string][] = [
|
||||
["select", SelectWidget, { options: [{ id: "a", label: "Accept" }] }, "Accept"],
|
||||
["multiselect", MultiselectWidget, { options: [{ id: "a", label: "Alpha" }] }, "Confirm"],
|
||||
["join-review", JoinReviewWidget, {}, "Continue"],
|
||||
["artifact-gate", ArtifactGateWidget, { options: [{ id: "a", label: "Accept" }] }, "Accept"],
|
||||
["schema-linking", SchemaLinkingGateWidget, {}, "Confirm"],
|
||||
["memory-review", MemoryReviewWidget, { summary: { summary_id: "summary", items: [] } }, "Finish without saving Memory"],
|
||||
["archive-repair", ArchiveRepairWidget, { repair: {
|
||||
repair_id: "repair", reason: "Conflict", options: [], choice: null,
|
||||
status: "proposed", saved: false, indexed: false, can_apply: true,
|
||||
} }, "None are adequate — reformulate"],
|
||||
];
|
||||
|
||||
test.each(cases)("%s protects Other drafts through submission until the host unmounts the widget", async (widget, Widget, extra, action) => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<Widget descriptor={{ id: "review", widget, reserved: ["other"], ...extra }} onRespond={onRespond} />);
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
|
||||
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 }));
|
||||
expect(onRespond).toHaveBeenCalledTimes(1);
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
|
||||
test("submitting Other keeps changed main choices protected until unmount", async () => {
|
||||
const onRespond = vi.fn();
|
||||
const { unmount } = render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", reserved: ["other"],
|
||||
options: [{ id: "a", label: "Alpha" }],
|
||||
}} onRespond={onRespond} />);
|
||||
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
|
||||
await userEvent.type(screen.getByRole("textbox"), "Correction{Enter}");
|
||||
expect(onRespond).toHaveBeenCalledWith({ id: "review", control: "freetext", text: "Correction" });
|
||||
expect(reloadIsBlocked()).toBe(true);
|
||||
unmount();
|
||||
expect(reloadIsBlocked()).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { WidgetDescriptor } from "../api/types";
|
||||
|
||||
/** Translate only explicit deterministic chrome, never arbitrary model text or artifacts. */
|
||||
export function localizeWidget(descriptor: WidgetDescriptor, t: (message: string) => string): WidgetDescriptor {
|
||||
const localized = { ...descriptor };
|
||||
for (const field of ["title", "confirm_label", "selection_label", "prompt", "error"] as const) {
|
||||
const message = descriptor[`${field}_i18n`];
|
||||
if (typeof message === "string") localized[field] = t(message);
|
||||
}
|
||||
if (descriptor.options) localized.options = descriptor.options.map((option) => ({
|
||||
...option,
|
||||
label: option.label_i18n ? t(option.label_i18n) : option.label,
|
||||
...(option.opens ? { opens: localizeWidget(option.opens, t) } : {}),
|
||||
}));
|
||||
return localized;
|
||||
}
|
||||
Reference in New Issue
Block a user