FN-8674: add plan approval banner actions
Place plan approval controls alongside the task approval message for faster operator action. - Add approve and reject controls to the plan-approval banner. - Reuse existing approval handlers and add coverage for banner and footer actions. - Add responsive banner action styling and a patch changeset. Files changed: .changeset/plan-approval-banner-actions.md | 7 +++ .../dashboard/app/components/TaskDetailModal.css | 14 +++++ .../dashboard/app/components/TaskDetailModal.tsx | 19 ++++++- .../TaskDetailModal.definition-actions.test.tsx | 59 +++++++++++++++++----- 4 files changed, 85 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-8674 Fusion-Task-Lineage: 89c521c2-535d-4492-8b39-6e1517693218 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/plan-approval-banner-actions.md
Normal file
7
.changeset/plan-approval-banner-actions.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Place plan approval actions alongside the task approval message.
|
||||
category: fix
|
||||
dev: The task-detail approval banner now reuses the footer approval handlers and visibility gate.
|
||||
@@ -3727,6 +3727,20 @@ Reuse the near-duplicate banner layout; replan-cap escalations use a stronger wa
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.detail-plan-approval-banner__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-plan-approval-banner__actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4736,6 +4736,21 @@ export function TaskDetailContent({
|
||||
"This project's plan-approval settings require a human decision before work starts. Review the plan below, then Approve Plan to continue to Todo or Reject Plan to regenerate it.",
|
||||
)}
|
||||
</p>
|
||||
{/*
|
||||
FNXC:PlanApproval 2026-08-01-06:34:
|
||||
Approval actions must sit beside the top approval message as well as in the persistent footer,
|
||||
so an operator can act without scrolling through a long task body.
|
||||
*/}
|
||||
{workingTask.prompt && (
|
||||
<div className="detail-plan-approval-banner__actions" data-testid="detail-plan-approval-banner-actions">
|
||||
<button className="btn btn-primary btn-sm" data-testid="detail-plan-approval-banner-approve" onClick={handleApprovePlan}>
|
||||
{t("taskDetail.plan.approveBtn", "Approve Plan")}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" data-testid="detail-plan-approval-banner-reject" onClick={handleRejectPlan}>
|
||||
{t("taskDetail.plan.rejectBtn", "Reject Plan")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="detail-meta">
|
||||
@@ -6769,10 +6784,10 @@ export function TaskDetailContent({
|
||||
legacy rows with awaitingApprovalReason === "release-authorization"). */}
|
||||
{isAwaitingApproval && workingTask.prompt && (
|
||||
<>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleApprovePlan}>
|
||||
<button className="btn btn-primary btn-sm" data-testid="detail-plan-approval-footer-approve" onClick={handleApprovePlan}>
|
||||
{t("taskDetail.plan.approveBtn", "Approve Plan")}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-sm" onClick={handleRejectPlan}>
|
||||
<button className="btn btn-danger btn-sm" data-testid="detail-plan-approval-footer-reject" onClick={handleRejectPlan}>
|
||||
{t("taskDetail.plan.rejectBtn", "Reject Plan")}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -454,10 +454,11 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
describe("Plan Approval UI", () => {
|
||||
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", () => {
|
||||
it("shows Approve Plan and Reject Plan buttons for awaiting-approval tasks in triage", async () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
status: "awaiting-approval",
|
||||
prompt: "# Task Spec",
|
||||
@@ -472,12 +473,39 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Approve Plan")).toBeTruthy();
|
||||
expect(screen.getByText("Reject Plan")).toBeTruthy();
|
||||
const { approvePlan, rejectPlan } = await import("../../api");
|
||||
const mockApprovePlan = vi.mocked(approvePlan);
|
||||
const mockRejectPlan = vi.mocked(rejectPlan);
|
||||
mockApprovePlan.mockClear();
|
||||
mockRejectPlan.mockClear();
|
||||
|
||||
const banner = screen.getByTestId("detail-plan-approval-banner");
|
||||
const bannerActions = screen.getByTestId("detail-plan-approval-banner-actions");
|
||||
const bannerApprove = screen.getByTestId("detail-plan-approval-banner-approve");
|
||||
const bannerReject = screen.getByTestId("detail-plan-approval-banner-reject");
|
||||
|
||||
expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("manual");
|
||||
expect(banner.contains(bannerActions)).toBe(true);
|
||||
expect(bannerActions.contains(bannerApprove)).toBe(true);
|
||||
expect(bannerActions.contains(bannerReject)).toBe(true);
|
||||
const approveButtons = screen.getAllByRole("button", { name: "Approve Plan" });
|
||||
const rejectButtons = screen.getAllByRole("button", { name: "Reject Plan" });
|
||||
expect(approveButtons).toHaveLength(2);
|
||||
expect(rejectButtons).toHaveLength(2);
|
||||
expect(approveButtons.some(button => !banner.contains(button))).toBe(true);
|
||||
expect(rejectButtons.some(button => !banner.contains(button))).toBe(true);
|
||||
expect(screen.getByText("Approval needed before implementation")).toBeTruthy();
|
||||
expect(screen.getByText(/require a human decision before work starts/i)).toBeTruthy();
|
||||
|
||||
fireEvent.click(bannerApprove);
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
|
||||
fireEvent.click(bannerReject);
|
||||
await waitFor(() => {
|
||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -504,10 +532,12 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Approve Plan")).toBeTruthy();
|
||||
expect(screen.getByText("Reject Plan")).toBeTruthy();
|
||||
const banner = screen.getByTestId("detail-plan-approval-banner");
|
||||
expect(banner.getAttribute("data-awaiting-approval-reason")).toBe("plan-review-replan-cap");
|
||||
expect(banner.classList.contains("detail-plan-approval-banner--replan-cap")).toBe(true);
|
||||
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-actions"))).toBe(true);
|
||||
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-approve"))).toBe(true);
|
||||
expect(banner.contains(screen.getByTestId("detail-plan-approval-banner-reject"))).toBe(true);
|
||||
expect(screen.getByText("Approval needed: Plan Review did not converge")).toBeTruthy();
|
||||
expect(screen.getByText(/exhausted|without approving|stopped the replan loop/i)).toBeTruthy();
|
||||
});
|
||||
@@ -537,8 +567,10 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Approve Plan")).toBeTruthy();
|
||||
expect(screen.getByText("Reject Plan")).toBeTruthy();
|
||||
expect(screen.getByTestId("detail-plan-approval-banner-approve")).toBeTruthy();
|
||||
expect(screen.getByTestId("detail-plan-approval-banner-reject")).toBeTruthy();
|
||||
expect(screen.getByTestId("detail-plan-approval-footer-approve")).toBeTruthy();
|
||||
expect(screen.getByTestId("detail-plan-approval-footer-reject")).toBeTruthy();
|
||||
expect(screen.queryByText(/Awaiting release authorization/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -584,6 +616,8 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
expect(screen.queryByText("Approve Plan")).toBeNull();
|
||||
expect(screen.queryByText("Reject Plan")).toBeNull();
|
||||
expect(screen.queryByTestId("detail-plan-approval-banner")).toBeNull();
|
||||
expect(screen.queryByTestId("detail-plan-approval-banner-actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show approval buttons when task has no prompt", () => {
|
||||
@@ -606,6 +640,7 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
expect(screen.queryByText("Approve Plan")).toBeNull();
|
||||
expect(screen.queryByText("Reject Plan")).toBeNull();
|
||||
expect(screen.queryByTestId("detail-plan-approval-banner-actions")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls approvePlan API and shows success toast when Approve Plan is clicked", async () => {
|
||||
@@ -632,7 +667,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Approve Plan"));
|
||||
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-approve"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||
@@ -668,7 +703,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Reject Plan"));
|
||||
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
title: "Reject Plan",
|
||||
@@ -714,7 +749,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Reject Plan"));
|
||||
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
expect(mockRejectPlan).not.toHaveBeenCalled();
|
||||
@@ -747,7 +782,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Approve Plan"));
|
||||
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-approve"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Network error", "error");
|
||||
@@ -782,7 +817,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Reject Plan"));
|
||||
fireEvent.click(screen.getByTestId("detail-plan-approval-footer-reject"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith("Server error", "error");
|
||||
|
||||
Reference in New Issue
Block a user