feat(frontend): multiselect/artifact-gate/artifact widgets + linkage + no-limbo

This commit is contained in:
2026-06-28 12:36:13 +02:00
parent 2b55e96608
commit be9e761e0a
8 changed files with 389 additions and 1 deletions
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
test("renders artifact content in a pre block", () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 42" },
options: [{ id: "approve", label: "Approva" }],
}}
onRespond={onRespond}
/>
);
expect(screen.getByText("SELECT 42")).toBeInTheDocument();
});
test("clicking an option without opens responds immediately", async () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 1" },
options: [{ id: "approve", label: "Approva" }],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
});
test("reserved control responds with control field", async () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 1" },
options: [{ id: "approve", label: "Approva" }],
reserved: ["back"],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /indietro/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" });
});
@@ -0,0 +1,60 @@
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
export function ArtifactGateWidget({ descriptor, onRespond }: WidgetProps) {
const [pendingLinkage, setPendingLinkage] = useState<{
parentResponse: UiResponse;
childDescriptor: WidgetDescriptor;
} | null>(null);
function handleOption(optionId: string) {
const option = descriptor.options?.find((o) => o.id === optionId);
const parentResponse: UiResponse = { id: descriptor.id, kind: "artifact-gate", choices: [optionId] };
if (option?.opens) {
// Route through LinkageHost to collect child text first
setPendingLinkage({ parentResponse, childDescriptor: option.opens });
} else {
onRespond(parentResponse);
}
}
if (pendingLinkage) {
return (
<LinkageHost
parentResponse={pendingLinkage.parentResponse}
childDescriptor={pendingLinkage.childDescriptor}
onRespond={onRespond}
/>
);
}
return (
<div className="space-y-3">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
{descriptor.artifact?.content && (
<pre className="overflow-auto max-h-64 bg-muted p-3 text-sm rounded border">
{descriptor.artifact.content}
</pre>
)}
<div className="flex flex-col gap-2">
{descriptor.options?.map((o) => (
<button
key={o.id}
className="border rounded px-3 py-2 text-left hover:bg-accent"
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</div>
<ReservedControls
reserved={descriptor.reserved}
onControl={(c) => onRespond({ id: descriptor.id, control: c })}
/>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { WidgetProps } from "./types";
/**
* ArtifactWidget: view-only renderer. Never calls onRespond.
* Invariant no-limbo: this widget intentionally produces no response;
* it is used only as a passive display, never as a decision point.
*/
export function ArtifactWidget({ descriptor }: WidgetProps) {
return (
<div className="space-y-2">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
{descriptor.artifact?.content && (
<pre className="overflow-auto max-h-64 bg-muted p-3 text-sm rounded border">
{descriptor.artifact.content}
</pre>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useState } from "react";
import type { WidgetDescriptor, UiResponse } from "../api/types";
import { FreetextWidget } from "./FreetextWidget";
/**
* LinkageHost: when an artifact-gate option has `opens` (a child descriptor),
* this component renders the child widget and merges the parent choice with
* the child's text into a single combined UiResponse.
*/
export function LinkageHost({
parentResponse,
childDescriptor,
onRespond,
}: {
parentResponse: UiResponse;
childDescriptor: WidgetDescriptor;
onRespond: (r: UiResponse) => void;
}) {
function handleChildResponse(childResponse: UiResponse) {
// Merge parent choice + child text into one response
onRespond({ ...parentResponse, text: childResponse.text });
}
// Currently only freetext child is supported per spec.
// FreetextWidget is the canonical child for "opens".
return <FreetextWidget descriptor={childDescriptor} onRespond={handleChildResponse} />;
}
@@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MultiselectWidget } from "./MultiselectWidget";
test("confirms the checked ids including initially selected", async () => {
const onRespond = vi.fn();
render(
<MultiselectWidget
descriptor={{
id: "u1",
widget: "multiselect",
options: [
{ id: "t1", label: "t1", selected: true },
{ id: "t2", label: "t2" },
],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
});
test("select-all checks all options", async () => {
const onRespond = vi.fn();
render(
<MultiselectWidget
descriptor={{
id: "u1",
widget: "multiselect",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Beta" },
],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /seleziona tutti/i }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] });
});
test("deselect-all unchecks all options", async () => {
const onRespond = vi.fn();
render(
<MultiselectWidget
descriptor={{
id: "u1",
widget: "multiselect",
options: [
{ id: "a", label: "Alpha", selected: true },
{ id: "b", label: "Beta", selected: true },
],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /deseleziona tutti/i }));
await userEvent.click(screen.getByRole("button", { name: /conferma/i }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] });
});
test("confirm is disabled when nothing checked and allow_empty is false", async () => {
const onRespond = vi.fn();
render(
<MultiselectWidget
descriptor={{
id: "u1",
widget: "multiselect",
allow_empty: false,
options: [{ id: "x", label: "X" }],
}}
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /conferma/i });
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onRespond).not.toHaveBeenCalled();
});
test("confirm is enabled when allow_empty is false but something is checked", async () => {
const onRespond = vi.fn();
render(
<MultiselectWidget
descriptor={{
id: "u1",
widget: "multiselect",
allow_empty: false,
options: [{ id: "x", label: "X", selected: true }],
}}
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /conferma/i });
expect(btn).not.toBeDisabled();
await userEvent.click(btn);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] });
});
@@ -0,0 +1,59 @@
import { useState } from "react";
import type { WidgetProps } from "./types";
export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
const options = descriptor.options ?? [];
const initialChecked = new Set(options.filter((o) => o.selected).map((o) => o.id));
const [checked, setChecked] = useState<Set<string>>(initialChecked);
const allChecked = options.length > 0 && options.every((o) => checked.has(o.id));
function toggle(id: string) {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAll() {
setChecked(new Set(options.map((o) => o.id)));
}
function deselectAll() {
setChecked(new Set());
}
const isDisabled = descriptor.allow_empty === false && checked.size === 0;
return (
<div className="space-y-2">
{descriptor.title && <p className="font-medium">{descriptor.title}</p>}
<div className="flex gap-2">
<button className="text-sm border rounded px-2 py-1" onClick={allChecked ? deselectAll : selectAll}>
{allChecked ? "Deseleziona tutti" : "Seleziona tutti"}
</button>
</div>
<div className="flex flex-col gap-1">
{options.map((o) => (
<label key={o.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={checked.has(o.id)}
onChange={() => toggle(o.id)}
/>
{o.label}
</label>
))}
</div>
<button
className="border rounded px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
Conferma
</button>
</div>
);
}
+9 -1
View File
@@ -2,5 +2,13 @@ import { register } from "./registry";
import { SelectWidget } from "./SelectWidget";
import { InfoWidget } from "./InfoWidget";
import { FreetextWidget } from "./FreetextWidget";
register("select", SelectWidget); register("info", InfoWidget); register("freetext", FreetextWidget);
import { MultiselectWidget } from "./MultiselectWidget";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
import { ArtifactWidget } from "./ArtifactWidget";
register("select", SelectWidget);
register("info", InfoWidget);
register("freetext", FreetextWidget);
register("multiselect", MultiselectWidget);
register("artifact-gate", ArtifactGateWidget);
register("artifact", ArtifactWidget);
export { resolve } from "./registry";
+60
View File
@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
test("reject opens a freetext and combines the reason", async () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 1" },
options: [
{ id: "approve", label: "Approva" },
{
id: "reject",
label: "Rifiuta",
opens: { id: "u1c", widget: "freetext", title: "Motivazione" },
},
],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ }));
await userEvent.type(screen.getByRole("textbox"), "join sbagliata");
await userEvent.click(screen.getByRole("button", { name: /invia/i }));
expect(onRespond).toHaveBeenCalledWith({
id: "u1",
kind: "artifact-gate",
choices: ["reject"],
text: "join sbagliata",
});
});
test("approve (no opens) responds immediately without child widget", async () => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
descriptor={{
id: "u1",
widget: "artifact-gate",
artifact: { kind: "cte", content: "SELECT 1" },
options: [
{ id: "approve", label: "Approva" },
{
id: "reject",
label: "Rifiuta",
opens: { id: "u1c", widget: "freetext", title: "Motivazione" },
},
],
}}
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
// No textbox should appear
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});