diff --git a/frontend/src/widgets/ArtifactGateWidget.test.tsx b/frontend/src/widgets/ArtifactGateWidget.test.tsx new file mode 100644 index 00000000..5e68299d --- /dev/null +++ b/frontend/src/widgets/ArtifactGateWidget.test.tsx @@ -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( + + ); + expect(screen.getByText("SELECT 42")).toBeInTheDocument(); +}); + +test("clicking an option without opens responds immediately", async () => { + const onRespond = vi.fn(); + render( + + ); + 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( + + ); + await userEvent.click(screen.getByRole("button", { name: /indietro/i })); + expect(onRespond).toHaveBeenCalledWith({ id: "u1", control: "back" }); +}); diff --git a/frontend/src/widgets/ArtifactGateWidget.tsx b/frontend/src/widgets/ArtifactGateWidget.tsx new file mode 100644 index 00000000..7770fd84 --- /dev/null +++ b/frontend/src/widgets/ArtifactGateWidget.tsx @@ -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 ( + + ); + } + + return ( +
+ {descriptor.title &&

{descriptor.title}

} + {descriptor.artifact?.content && ( +
+          {descriptor.artifact.content}
+        
+ )} +
+ {descriptor.options?.map((o) => ( + + ))} +
+ onRespond({ id: descriptor.id, control: c })} + /> +
+ ); +} diff --git a/frontend/src/widgets/ArtifactWidget.tsx b/frontend/src/widgets/ArtifactWidget.tsx new file mode 100644 index 00000000..729f8c33 --- /dev/null +++ b/frontend/src/widgets/ArtifactWidget.tsx @@ -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 ( +
+ {descriptor.title &&

{descriptor.title}

} + {descriptor.artifact?.content && ( +
+          {descriptor.artifact.content}
+        
+ )} +
+ ); +} diff --git a/frontend/src/widgets/LinkageHost.tsx b/frontend/src/widgets/LinkageHost.tsx new file mode 100644 index 00000000..df3a0349 --- /dev/null +++ b/frontend/src/widgets/LinkageHost.tsx @@ -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 ; +} diff --git a/frontend/src/widgets/MultiselectWidget.test.tsx b/frontend/src/widgets/MultiselectWidget.test.tsx new file mode 100644 index 00000000..4d5881fa --- /dev/null +++ b/frontend/src/widgets/MultiselectWidget.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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"] }); +}); diff --git a/frontend/src/widgets/MultiselectWidget.tsx b/frontend/src/widgets/MultiselectWidget.tsx new file mode 100644 index 00000000..67f621ba --- /dev/null +++ b/frontend/src/widgets/MultiselectWidget.tsx @@ -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>(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 ( +
+ {descriptor.title &&

{descriptor.title}

} +
+ +
+
+ {options.map((o) => ( + + ))} +
+ +
+ ); +} diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 63937d7a..640e7402 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -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"; diff --git a/frontend/src/widgets/linkage.test.tsx b/frontend/src/widgets/linkage.test.tsx new file mode 100644 index 00000000..c044a1ea --- /dev/null +++ b/frontend/src/widgets/linkage.test.tsx @@ -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( + + ); + 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( + + ); + 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(); +});