FN-7839: group done-card Archive and Revert actions into one dropdown

Replaces the standalone Archive button on done task cards with an Actions dropdown that groups Archive and Revert, mirroring the existing in-progress "Send back" menu pattern.

- Add a card-done-actions dropdown (Actions trigger + menu) rendered for done cards, reusing card-send-back* styling
- Move Archive into the dropdown menu; add Revert as a menu item when the task is revertable (isRevertable)
- Only render the dropdown trigger when at least one action (archive/revert) is available, avoiding an empty button shell
- Restrict the old inline card-revert-btn to archived cards only (done cards now use the dropdown)
- Add outside-click handling and aria-haspopup/aria-expanded wiring for the new menu
- Add i18n key tasks.doneActions
- Update TaskCard tests to cover the dropdown (archive/revert menu items, empty-state omission) and mock useToast in board-mobile tests for isolated TaskCard renders
- Add changeset for @runfusion/fusion (patch)

Files changed:
 .changeset/fn-7839-done-card-actions-dropdown.md   |  7 ++
 packages/dashboard/app/components/TaskCard.tsx     | 88 ++++++++++++++++++----
 .../app/components/__tests__/TaskCard.test.tsx     | 82 +++++++++++++++-----
 .../app/components/__tests__/board-mobile.test.tsx | 15 +++-
 4 files changed, 157 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7839

Fusion-Task-Lineage: 1f8137cb-22b1-43dd-beaa-1bee0387d44b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-11 21:39:15 -07:00
parent 0b5c5517d8
commit 3da3f377c9
4 changed files with 157 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Done task cards group Archive and Revert into one dropdown.
category: feature
dev: Reuses the in-progress "Send back" .card-send-back* dropdown pattern in TaskCard; new i18n key tasks.doneActions.

View File

@@ -906,6 +906,7 @@ function TaskCardComponent({
const [missionTitle, setMissionTitle] = useState<string | null>(null);
const [agentName, setAgentName] = useState<string | null>(null);
const [showSendBackMenu, setShowSendBackMenu] = useState(false);
const [showDoneActionsMenu, setShowDoneActionsMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [isRetrying, setIsRetrying] = useState(false);
const [isPrCreateOpen, setIsPrCreateOpen] = useState(false);
@@ -925,6 +926,7 @@ function TaskCardComponent({
*/
const menuButtonRef = useRef<HTMLButtonElement>(null);
const sendBackRef = useRef<HTMLDivElement>(null);
const doneActionsRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket(projectId);
const { agentsMap } = useAgentsMapCache(projectId);
@@ -969,6 +971,18 @@ function TaskCardComponent({
return () => document.removeEventListener("click", handleClick);
}, [showSendBackMenu]);
// Close done-actions menu on outside click
useEffect(() => {
if (!showDoneActionsMenu) return;
const handleClick = (e: MouseEvent) => {
if (doneActionsRef.current && !doneActionsRef.current.contains(e.target as Node)) {
setShowDoneActionsMenu(false);
}
};
document.addEventListener("click", handleClick);
return () => document.removeEventListener("click", handleClick);
}, [showDoneActionsMenu]);
// Fetch mission title when missionId is set
useEffect(() => {
if (!task.missionId) {
@@ -2410,6 +2424,7 @@ function TaskCardComponent({
const openContextMenuAt = useCallback((clientX: number, clientY: number) => {
if (!hasContextMenuActions || isEditing) return;
setShowSendBackMenu(false);
setShowDoneActionsMenu(false);
setContextMenuPosition({
x: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientX, window.innerWidth - CONTEXT_MENU_VIEWPORT_MARGIN)),
y: Math.max(CONTEXT_MENU_VIEWPORT_MARGIN, Math.min(clientY, window.innerHeight - CONTEXT_MENU_VIEWPORT_MARGIN)),
@@ -2564,6 +2579,11 @@ function TaskCardComponent({
setShowSendBackMenu((current) => !current);
}, []);
const handleDoneActionsToggle = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowDoneActionsMenu((current) => !current);
}, []);
const handleSendBackOptionClick = useCallback(async (e: React.MouseEvent, column: Column) => {
e.stopPropagation();
setShowSendBackMenu(false);
@@ -3143,15 +3163,56 @@ function TaskCardComponent({
<Trash2 size={12} />
</button>
)}
{task.column === "done" && onArchiveTask && (
<button
className="card-archive-btn"
onClick={handleArchiveClick}
title={t("tasks.archiveTask", "Archive task")}
aria-label={t("tasks.archiveTask", "Archive task")}
>
{t("tasks.archive", "Archive")}
</button>
{task.column === "done" && (onArchiveTask || (onRevertTask && isRevertable)) && (
<div className="card-send-back card-done-actions" ref={doneActionsRef}>
{/*
FNXC:BoardCardActions 2026-07-11-00:00 (FN-7839):
Done-card Archive + Revert are grouped behind one dropdown that mirrors the
in-progress "Send back" control, replacing two standalone inline buttons.
Revert only appears when the task is revertable (isRevertable); the trigger
only renders when at least one action is available so no empty shell is left.
Reuses .card-send-back* styling so no new one-off CSS/colors are introduced.
*/}
<button
className="card-send-back-btn"
onClick={handleDoneActionsToggle}
title={t("tasks.doneActions", "Actions")}
aria-label={t("tasks.doneActions", "Actions")}
aria-haspopup="menu"
aria-expanded={showDoneActionsMenu}
>
{t("tasks.doneActions", "Actions")}
<ChevronDown size={10} />
</button>
{showDoneActionsMenu && (
<div className="card-send-back-menu" role="menu">
{onArchiveTask && (
<button
className="card-send-back-menu-item"
role="menuitem"
onClick={(e) => {
setShowDoneActionsMenu(false);
handleArchiveClick(e);
}}
>
{t("tasks.archive", "Archive")}
</button>
)}
{onRevertTask && isRevertable && (
<button
className="card-send-back-menu-item"
role="menuitem"
onClick={(e) => {
setShowDoneActionsMenu(false);
handleRevertClick(e);
}}
>
{t("tasks.revert", "Revert")}
</button>
)}
</div>
)}
</div>
)}
{task.column === "archived" && onUnarchiveTask && (
<button
@@ -3165,14 +3226,15 @@ function TaskCardComponent({
)}
{/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Inline Revert affordance for done/archived cards (parent FN-7501). Rendered
Inline Revert affordance for archived cards (parent FN-7501). Rendered
only when the task actually has a landed commit to revert (`isRevertable`)
— omitted (not disabled) here to avoid an empty button shell on cards with
nothing to revert, matching the "omit inline / disable in menu" split called
out in the task spec. Reuses `card-archive-btn`'s tokenized styling via a
shared class so no new one-off CSS/colors are introduced.
out in the task spec. Done cards use the FN-7839 actions dropdown above.
Reuses `card-archive-btn`'s tokenized styling via a shared class so no new
one-off CSS/colors are introduced.
*/}
{(task.column === "done" || task.column === "archived") && onRevertTask && isRevertable && (
{task.column === "archived" && onRevertTask && isRevertable && (
<button
className="card-archive-btn card-revert-btn"
onClick={handleRevertClick}

View File

@@ -1161,7 +1161,7 @@ describe("TaskCard", () => {
});
});
it("hides delete button for done tasks while keeping archive action", () => {
it("hides delete button for done tasks while keeping archive action in the actions dropdown", () => {
const onDeleteTask = vi.fn(async () => makeTask());
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
@@ -1176,7 +1176,8 @@ describe("TaskCard", () => {
);
expect(screen.queryByLabelText("Delete task")).toBeNull();
expect(screen.getByLabelText("Archive task")).toBeDefined();
expect(screen.getByRole("button", { name: "Actions" })).toBeDefined();
expect(screen.queryByLabelText("Archive task")).toBeNull();
});
it.each(["triage", "todo", "in-progress", "in-review"] as const)(
@@ -1192,20 +1193,46 @@ describe("TaskCard", () => {
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
},
);
it("renders archive action for done tasks", () => {
it("renders archive action for done tasks inside the actions dropdown", async () => {
const onArchiveTask = vi.fn(async () => makeTask({ column: "archived" }));
render(
<TaskCard
task={makeTask({ column: "done" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onArchiveTask={onArchiveTask}
/>,
);
expect(screen.getByLabelText("Archive task")).toBeDefined();
const actionsButton = screen.getByRole("button", { name: "Actions" });
expect(screen.queryByLabelText("Archive task")).toBeNull();
fireEvent.click(actionsButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
fireEvent.click(within(menu).getByRole("menuitem", { name: "Archive" }));
await waitFor(() => expect(onArchiveTask).toHaveBeenCalledWith("FN-001"));
});
it("does not render an empty done actions dropdown when no done action is available", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
expect(container.querySelector(".card-done-actions")).toBeNull();
});
it("does not render archive action for archived tasks", () => {
@@ -1220,28 +1247,34 @@ describe("TaskCard", () => {
);
expect(screen.queryByLabelText("Archive task")).toBeNull();
expect(screen.queryByRole("button", { name: "Actions" })).toBeNull();
expect(screen.getByLabelText("Unarchive task")).toBeDefined();
});
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7525):
Coverage for the Revert affordance: presence/absence on done + archived
cards (inline row + context menu), the disabled/omitted no-commit-to-revert
guard, the auto→clean-success path, and the auto→conflict→confirm→AI-undo
fallback path.
cards (done-actions dropdown, archived inline row, and context menu), the
disabled/omitted no-commit-to-revert guard, the auto→clean-success path,
and the auto→conflict→confirm→AI-undo fallback path.
*/
describe("Revert affordance", () => {
it("renders the inline Revert button for a done card with a landed commit", () => {
render(
it("renders Revert inside the done actions dropdown for a done card with a landed commit", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: { commitSha: "abc123def456" } as any })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.getByLabelText("Revert this task's changes")).toBeDefined();
expect(container.querySelector(".card-revert-btn")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
expect(within(menu).getByRole("menuitem", { name: "Revert" })).toBeDefined();
});
it("renders the inline Revert button for an archived card with a landed commit", () => {
@@ -1269,17 +1302,22 @@ describe("TaskCard", () => {
expect(screen.queryByLabelText("Revert this task's changes")).toBeNull();
});
it("omits the inline Revert button when the task has no landed commit", () => {
render(
it("omits Revert from the done actions dropdown when the task has no landed commit", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", mergeDetails: undefined })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={vi.fn(async () => makeTask({ column: "archived" }))}
onRevertTask={vi.fn(async () => ({ mode: "git", clean: true, revertCommitSha: "deadbeef" }) as any)}
/>,
);
expect(screen.queryByLabelText("Revert this task's changes")).toBeNull();
expect(container.querySelector(".card-revert-btn")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Archive" })).toBeDefined();
expect(within(menu).queryByRole("menuitem", { name: "Revert" })).toBeNull();
});
it("shows a disabled Revert context-menu entry when the task has no landed commit", () => {
@@ -1324,8 +1362,10 @@ describe("TaskCard", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
await act(async () => {
fireEvent.click(screen.getByLabelText("Revert this task's changes"));
fireEvent.click(screen.getByRole("menuitem", { name: "Revert" }));
});
await waitFor(() => {
@@ -1355,8 +1395,10 @@ describe("TaskCard", () => {
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
await act(async () => {
fireEvent.click(screen.getByLabelText("Revert this task's changes"));
fireEvent.click(screen.getByRole("menuitem", { name: "Revert" }));
});
await waitFor(() => {
@@ -3446,7 +3488,7 @@ describe("TaskCard", () => {
expect(actionsContainer?.contains(editBtn)).toBe(true);
});
it("renders archive button inside card-header-actions for done columns", () => {
it("renders done actions dropdown inside card-header-actions for done columns", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "done", size: "L" })}
@@ -3456,11 +3498,11 @@ describe("TaskCard", () => {
/>,
);
const actionsContainer = container.querySelector(".card-header-actions");
const archiveBtn = container.querySelector(".card-archive-btn");
const actionsButton = screen.getByRole("button", { name: "Actions" });
expect(actionsContainer).not.toBeNull();
expect(archiveBtn).not.toBeNull();
expect(actionsContainer?.contains(archiveBtn)).toBe(true);
expect(container.querySelector(".card-archive-btn")).toBeNull();
expect(actionsContainer?.contains(actionsButton)).toBe(true);
});
it("renders in-review Move control inline in card-meta for overlap-blocked tasks", () => {

View File

@@ -8,6 +8,15 @@ import { fetchTaskDetail } from "../../api";
import { InlineCreateCard } from "../InlineCreateCard";
import { TaskCard } from "../TaskCard";
// FNXC:TaskCardTestHarness 2026-07-11-00:00: RuntimeFallbackBadge calls useToast directly, so isolated TaskCard mobile renders need the hook mocked unless wrapped in ToastProvider.
vi.mock("../../hooks/useToast", () => ({
useToast: () => ({
addToast: vi.fn(),
removeToast: vi.fn(),
toasts: [],
}),
}));
vi.mock("../../api", () => ({
fetchTaskDetail: vi.fn(),
uploadAttachment: vi.fn(),
@@ -432,7 +441,7 @@ describe("TaskCard mobile", () => {
expectRuleToContain(mobileSection, ".card-revert-btn", "opacity: 1;");
});
it("renders the Revert affordance on a done card at the mobile breakpoint", () => {
it("renders the Revert affordance inside the done actions dropdown at the mobile breakpoint", () => {
const task = createTask({ id: "FN-201", column: "done", mergeDetails: { commitSha: "abc123def456" } as any });
const { container } = render(
@@ -444,7 +453,9 @@ describe("TaskCard mobile", () => {
/>,
);
expect(container.querySelector(".card-revert-btn")).toBeTruthy();
expect(container.querySelector(".card-revert-btn")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Actions" }));
expect(screen.getByRole("menuitem", { name: "Revert" })).toBeTruthy();
});
it("renders the Revert affordance on an archived card at the mobile breakpoint", () => {