feat(FN-2527): add bulk actions for in-progress and in-review columns

- Extend column action menus beyond Todo to include In Progress and In Review
- Add Stop All action that pauses only non-paused tasks with confirmation and success/error toasts
- Add Move All to Todo action with confirmation and partial-failure handling for bulk moves
- Thread pauseTask through useTasks, App, and Board so column menus can trigger task pausing
- Expand Column tests to cover new menu actions, disabled states, and bulk operation behavior
This commit is contained in:
Fusion
2026-04-25 15:11:54 -07:00
committed by gsxdsm
parent 1872abc1d0
commit 04d859be37
5 changed files with 247 additions and 20 deletions

View File

@@ -189,7 +189,7 @@ function AppInner() {
// Tasks hook with project context and search query
// SSE is only enabled for board/list views to free connection slots for mission detail fetches
const taskSseEnabled = taskView === "board" || taskView === "list";
const { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
const { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, lastFetchTimeMs } = useTasks(
{
...(currentProject ? { projectId: currentProject.id } : {}),
searchQuery: searchQuery || undefined,
@@ -688,6 +688,7 @@ function AppInner() {
projectId={currentProject?.id}
maxConcurrent={maxConcurrent}
onMoveTask={moveTask}
onPauseTask={pauseTask}
onOpenDetail={modalManager.openDetailTask}
addToast={addToast}
onQuickCreate={handleBoardQuickCreate}

View File

@@ -11,6 +11,7 @@ interface BoardProps {
projectId?: string;
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
@@ -77,7 +78,7 @@ function areWorkflowNameLookupsEqual(previous: ReadonlyMap<string, string>, next
return true;
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
@@ -201,6 +202,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
addToast={addToast}
globalPaused={globalPaused}

View File

@@ -21,6 +21,7 @@ interface ColumnProps {
projectId?: string;
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType) => Promise<Task>;
onPauseTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
@@ -65,11 +66,13 @@ interface ColumnProps {
workflowStepNameLookup?: ReadonlyMap<string, string>;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRespecifying, setIsRespecifying] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -185,6 +188,65 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
}
}, [tasks, onMoveTask, addToast]);
const pauseEligibleTasks = useMemo(() => tasks.filter((task) => !task.paused), [tasks]);
const pauseEligibleCount = pauseEligibleTasks.length;
const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review";
const isMenuBusy = isRespecifying || isPausingAll || isMovingAllToTodo;
const handlePauseAll = useCallback(async () => {
if (!onPauseTask) return;
setIsMenuOpen(false);
if (pauseEligibleCount === 0) return;
const confirmed = window.confirm(
`Stop all ${pauseEligibleCount} ${COLUMN_LABELS[column].toLowerCase()} task${pauseEligibleCount === 1 ? "" : "s"}?`,
);
if (!confirmed) return;
setIsPausingAll(true);
try {
const results = await Promise.allSettled(
pauseEligibleTasks.map((task) => onPauseTask(task.id)),
);
const failed = results.filter((r) => r.status === "rejected").length;
const paused = results.length - failed;
if (failed === 0) {
addToast(`Stopped ${paused} task${paused === 1 ? "" : "s"}`, "success");
} else {
addToast(`Stopped ${paused} of ${results.length} tasks; ${failed} failed`, "error");
}
} finally {
setIsPausingAll(false);
}
}, [onPauseTask, pauseEligibleCount, column, pauseEligibleTasks, addToast]);
const handleMoveAllToTodo = useCallback(async () => {
setIsMenuOpen(false);
if (tasks.length === 0) return;
const confirmed = window.confirm(
`Move all ${tasks.length} ${COLUMN_LABELS[column].toLowerCase()} task${tasks.length === 1 ? "" : "s"} to Todo?`,
);
if (!confirmed) return;
setIsMovingAllToTodo(true);
try {
const results = await Promise.allSettled(
tasks.map((task) => onMoveTask(task.id, "todo")),
);
const failed = results.filter((r) => r.status === "rejected").length;
const moved = results.length - failed;
if (failed === 0) {
addToast(`Moved ${moved} task${moved === 1 ? "" : "s"} to Todo`, "success");
} else {
addToast(`Moved ${moved} of ${results.length} tasks to Todo; ${failed} failed`, "error");
}
} finally {
setIsMovingAllToTodo(false);
}
}, [tasks, column, onMoveTask, addToast]);
const handleArchiveAll = useCallback(async () => {
if (!onArchiveAllDone) return;
if (tasks.length === 0) return;
@@ -249,7 +311,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
{collapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
)}
{column === "todo" && (
{hasColumnBulkActions && (
<div className="column-menu" ref={menuRef}>
<button
type="button"
@@ -257,26 +319,60 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label="Todo column actions"
aria-label={`${COLUMN_LABELS[column]} column actions`}
title="Column actions"
disabled={isRespecifying}
disabled={isMenuBusy}
>
<MoreVertical size={16} />
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleRespecifyAll()}
disabled={tasks.length === 0 || isRespecifying}
>
Respecify All
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Triage
</span>
</button>
{column === "todo" && (
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleRespecifyAll()}
disabled={tasks.length === 0 || isRespecifying}
>
Respecify All
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Triage
</span>
</button>
)}
{(column === "in-progress" || column === "in-review") && (
<>
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handlePauseAll()}
disabled={pauseEligibleCount === 0 || isPausingAll || !onPauseTask}
>
Stop All
<span className="column-menu-item-hint">
{tasks.length === 0
? "No tasks in this column"
: pauseEligibleCount === 0
? "All tasks are already paused"
: `Pause ${pauseEligibleCount} active task${pauseEligibleCount === 1 ? "" : "s"}`}
</span>
</button>
<button
type="button"
role="menuitem"
className="column-menu-item"
onClick={() => void handleMoveAllToTodo()}
disabled={tasks.length === 0 || isMovingAllToTodo}
>
Move All to Todo
<span className="column-menu-item-hint">
Move {tasks.length} task{tasks.length === 1 ? "" : "s"} to Todo
</span>
</button>
</>
)}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Column } from "../Column";
import type { Task, Column as ColumnType } from "@fusion/core";
@@ -292,6 +292,130 @@ describe("Column QuickEntryBox", () => {
});
});
describe("Column in-progress/in-review bulk actions", () => {
it.each(["in-progress", "in-review"] as const)("renders Stop All and Move All to Todo actions for %s", async (column) => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column={column}
tasks={[{ ...makeTask("FN-001"), column }]}
onPauseTask={vi.fn().mockResolvedValue({} as Task)}
/>,
);
const menuButton = screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` });
expect(menuButton).toHaveAttribute("aria-haspopup", "menu");
expect(menuButton).toHaveAttribute("aria-expanded", "false");
await user.click(menuButton);
expect(menuButton).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("menu")).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /Stop All/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /Move All to Todo/i })).toBeTruthy();
});
it.each(["in-progress", "in-review"] as const)("Stop All pauses only non-paused tasks in %s", async (column) => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const onPauseTask = vi.fn().mockResolvedValue({} as Task);
render(
<Column
{...defaultProps}
column={column}
tasks={[
{ ...makeTask("FN-001"), column, paused: false },
{ ...makeTask("FN-002"), column, paused: true },
{ ...makeTask("FN-003"), column, paused: false },
]}
onPauseTask={onPauseTask}
/>,
);
await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` }));
await user.click(screen.getByRole("menuitem", { name: /Stop All/i }));
await waitFor(() => {
expect(onPauseTask).toHaveBeenCalledTimes(2);
});
expect(onPauseTask).toHaveBeenCalledWith("FN-001");
expect(onPauseTask).toHaveBeenCalledWith("FN-003");
expect(screen.queryByRole("menu")).toBeNull();
confirmSpy.mockRestore();
});
it.each(["in-progress", "in-review"] as const)("disables Stop All when %s is empty", async (column) => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column={column}
tasks={[]}
onPauseTask={vi.fn().mockResolvedValue({} as Task)}
/>,
);
await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` }));
expect(screen.getByRole("menuitem", { name: /Stop All/i })).toBeDisabled();
expect(screen.getByText("No tasks in this column")).toBeTruthy();
});
it.each(["in-progress", "in-review"] as const)("disables Stop All when all %s tasks are already paused", async (column) => {
const user = userEvent.setup();
render(
<Column
{...defaultProps}
column={column}
tasks={[
{ ...makeTask("FN-010"), column, paused: true },
{ ...makeTask("FN-011"), column, paused: true },
]}
onPauseTask={vi.fn().mockResolvedValue({} as Task)}
/>,
);
await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` }));
expect(screen.getByRole("menuitem", { name: /Stop All/i })).toBeDisabled();
expect(screen.getByText("All tasks are already paused")).toBeTruthy();
});
it.each(["in-progress", "in-review"] as const)("Move All to Todo moves every task in %s", async (column) => {
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const onMoveTask = vi.fn().mockResolvedValue({} as Task);
render(
<Column
{...defaultProps}
column={column}
onMoveTask={onMoveTask}
tasks={[
{ ...makeTask("FN-001"), column },
{ ...makeTask("FN-002"), column },
]}
onPauseTask={vi.fn().mockResolvedValue({} as Task)}
/>,
);
await user.click(screen.getByRole("button", { name: `${column === "in-progress" ? "In Progress" : "In Review"} column actions` }));
await user.click(screen.getByRole("menuitem", { name: /Move All to Todo/i }));
await waitFor(() => {
expect(onMoveTask).toHaveBeenCalledTimes(2);
});
expect(onMoveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(onMoveTask).toHaveBeenCalledWith("FN-002", "todo");
expect(screen.queryByRole("menu")).toBeNull();
confirmSpy.mockRestore();
});
});
describe("Column same-column drop", () => {
it("does not call onMoveTask when dropping task into its current column", () => {
const onMoveTask = vi.fn().mockResolvedValue({} as Task);

View File

@@ -299,6 +299,10 @@ export function useTasks(options?: UseTasksOptions) {
return normalizeTask(await api.moveTask(id, column, projectId));
}, [projectId]);
const pauseTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.pauseTask(id, projectId));
}, [projectId]);
const deleteTask = useCallback(async (id: string): Promise<Task> => {
return normalizeTask(await api.deleteTask(id, projectId));
}, [projectId]);
@@ -379,5 +383,5 @@ export function useTasks(options?: UseTasksOptions) {
return normalized;
}, [projectId]);
return { tasks, createTask, moveTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
return { tasks, createTask, moveTask, pauseTask, deleteTask, mergeTask, retryTask, duplicateTask, updateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, includeArchived, lastFetchTimeMs: lastFetchTimeMs.current };
}