feat(frontend): multiselect/artifact-gate/artifact widgets + linkage + no-limbo
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user