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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user