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:
7
.changeset/fn-7839-done-card-actions-dropdown.md
Normal file
7
.changeset/fn-7839-done-card-actions-dropdown.md
Normal 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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user