feat: render interrupt decision form
This commit is contained in:
@@ -0,0 +1,145 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { RunFactsInterrupt } from "./demo-run-facts.js";
|
||||||
|
import { InterruptDecisionForm } from "./InterruptDecisionForm.js";
|
||||||
|
|
||||||
|
const interrupt: RunFactsInterrupt = {
|
||||||
|
kind: "issue_review",
|
||||||
|
typed: true,
|
||||||
|
outcomes: ["submitted", "cancelled"],
|
||||||
|
proposedIssues: [
|
||||||
|
{
|
||||||
|
id: "risk-1",
|
||||||
|
title: "Prepare the defense walkthrough",
|
||||||
|
body: "Review the live and replay paths before the defense.",
|
||||||
|
severity: "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "risk-2",
|
||||||
|
title: "Update the architecture diagram",
|
||||||
|
body: "Ensure the diagram reflects the latest changes.",
|
||||||
|
severity: "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reportMarkdownPreview: "# Report\n\nThe workflow substrate is ready.",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("InterruptDecisionForm", () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
it("renders proposed issues as checkbox rows with default selection", () => {
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes).toHaveLength(2);
|
||||||
|
expect(checkboxes[0]).toBeChecked();
|
||||||
|
expect(checkboxes[1]).toBeChecked();
|
||||||
|
expect(screen.getByText("Prepare the defense walkthrough")).toBeDefined();
|
||||||
|
expect(screen.getByText("Update the architecture diagram")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults comment field to Create the selected issue.", () => {
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole("textbox", { name: /resume comment/i });
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("Create the selected issue.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits selected issue IDs and edited comment", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox");
|
||||||
|
expect(checkboxes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
await user.click(checkboxes[1]!);
|
||||||
|
expect(checkboxes[0]).toBeChecked();
|
||||||
|
expect(checkboxes[1]).not.toBeChecked();
|
||||||
|
|
||||||
|
const textarea = screen.getByRole("textbox", { name: /resume comment/i });
|
||||||
|
await user.clear(textarea);
|
||||||
|
await user.type(textarea, "Custom comment");
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /submit/i }));
|
||||||
|
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
["risk-1"],
|
||||||
|
"Custom comment",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls cancel callback without submit", async () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /cancel/i }));
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows terminal outcome label when state is submitted", () => {
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
terminalOutcome="submitted"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/submitted/i)).toBeDefined();
|
||||||
|
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
|
||||||
|
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows terminal outcome label when state is cancelled", () => {
|
||||||
|
render(
|
||||||
|
<InterruptDecisionForm
|
||||||
|
interrupt={interrupt}
|
||||||
|
runId="run_recorded_lda_report"
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
onCancel={vi.fn()}
|
||||||
|
terminalOutcome="cancelled"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/cancelled/i)).toBeDefined();
|
||||||
|
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
|
||||||
|
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type { RunFactsInterrupt } from "./demo-run-facts.js";
|
||||||
|
|
||||||
|
type InterruptDecisionFormProps = {
|
||||||
|
readonly interrupt: RunFactsInterrupt;
|
||||||
|
readonly runId: string;
|
||||||
|
readonly onSubmit: (selectedIssueIds: ReadonlyArray<string>, comment: string) => void;
|
||||||
|
readonly onCancel: () => void;
|
||||||
|
readonly terminalOutcome?: "submitted" | "cancelled" | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InterruptDecisionForm = ({
|
||||||
|
interrupt,
|
||||||
|
runId,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
terminalOutcome,
|
||||||
|
}: InterruptDecisionFormProps) => {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() =>
|
||||||
|
new Set(interrupt.proposedIssues.map((issue) => issue.id)),
|
||||||
|
);
|
||||||
|
const [comment, setComment] = useState("Create the selected issue.");
|
||||||
|
|
||||||
|
const toggleIssue = useCallback((id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit([...selectedIds], comment);
|
||||||
|
},
|
||||||
|
[selectedIds, comment, onSubmit],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (terminalOutcome) {
|
||||||
|
return (
|
||||||
|
<div className="interrupt-decision-form" role="group" aria-label="operator resume decision">
|
||||||
|
<header className="interrupt-decision-form__header">
|
||||||
|
<span className="interrupt-decision-form__kind">{interrupt.kind}</span>
|
||||||
|
<span className="interrupt-decision-form__run-id">{runId}</span>
|
||||||
|
</header>
|
||||||
|
<p className="interrupt-decision-form__terminal">
|
||||||
|
Outcome: <strong>{terminalOutcome}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="interrupt-decision-form"
|
||||||
|
role="group"
|
||||||
|
aria-label="operator resume decision"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
<header className="interrupt-decision-form__header">
|
||||||
|
<span className="interrupt-decision-form__kind">{interrupt.kind}</span>
|
||||||
|
<span className="interrupt-decision-form__run-id">{runId}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl className="interrupt-decision-form__meta">
|
||||||
|
<dt>Typed</dt>
|
||||||
|
<dd>{interrupt.typed ? "yes" : "no"}</dd>
|
||||||
|
<dt>Outcomes</dt>
|
||||||
|
<dd>{interrupt.outcomes.join(", ")}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{interrupt.reportMarkdownPreview && (
|
||||||
|
<pre className="interrupt-decision-form__report-preview">
|
||||||
|
{interrupt.reportMarkdownPreview}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset className="interrupt-decision-form__issues">
|
||||||
|
<legend>Proposed issues</legend>
|
||||||
|
{interrupt.proposedIssues.map((issue) => (
|
||||||
|
<label key={issue.id} className="interrupt-decision-form__issue-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(issue.id)}
|
||||||
|
onChange={() => toggleIssue(issue.id)}
|
||||||
|
/>
|
||||||
|
<span className="interrupt-decision-form__issue-title">{issue.title}</span>
|
||||||
|
<span className="interrupt-decision-form__issue-severity">{issue.severity}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label className="interrupt-decision-form__comment-label">
|
||||||
|
Resume comment
|
||||||
|
<textarea
|
||||||
|
className="interrupt-decision-form__comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="interrupt-decision-form__actions">
|
||||||
|
<button type="submit" className="interrupt-decision-form__submit">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupt-decision-form__cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1500,3 +1500,171 @@
|
|||||||
background: color-mix(in srgb, var(--accent-amber), transparent 85%);
|
background: color-mix(in srgb, var(--accent-amber), transparent 85%);
|
||||||
color: var(--accent-amber);
|
color: var(--accent-amber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interrupt decision form */
|
||||||
|
.interrupt-decision-form {
|
||||||
|
padding: 1.4rem 1.6rem;
|
||||||
|
border: 2px solid var(--accent-cyan);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--stage-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__kind {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__run-id {
|
||||||
|
font: 0.82rem/1.4 var(--font-mono);
|
||||||
|
color: color-mix(in srgb, var(--text-primary), white 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.25rem 0.6rem;
|
||||||
|
margin: 0 0 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__meta dt {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: color-mix(in srgb, var(--text-primary), white 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__report-preview {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--stage-surface), black 20%);
|
||||||
|
font: 0.82rem/1.5 var(--font-mono);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: color-mix(in srgb, var(--text-primary), white 8%);
|
||||||
|
max-height: 10rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issues {
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.8rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issues legend {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--accent-amber);
|
||||||
|
padding: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issue-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--stage-line), transparent 50%);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issue-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issue-row input[type="checkbox"] {
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
accent-color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issue-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__issue-severity {
|
||||||
|
font: 0.75rem/1.4 var(--font-mono);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--accent-amber), transparent 85%);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__comment-label {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__comment {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border: 1px solid var(--stage-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--stage-surface), black 15%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: 0.88rem/1.5 var(--font-mono);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__submit,
|
||||||
|
.interrupt-decision-form__cancel {
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__submit {
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
background: color-mix(in srgb, var(--accent-cyan), transparent 80%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__cancel {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-amber), transparent 40%);
|
||||||
|
background: color-mix(in srgb, var(--accent-amber), transparent 90%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__terminal {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.interrupt-decision-form__terminal strong {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user