feat(FN-4412): complete Steps 2-6 — add bulk pause/unpause/archive actions

Fusion-Task-Id: FN-4412
Fusion-Task-Lineage: 20883b92-0de6-4b95-b8f8-9075f1d9d54e
This commit is contained in:
Fusion
2026-05-13 19:32:53 -07:00
committed by gsxdsm
parent 746dc9633d
commit 1b925f1e1c
3 changed files with 274 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add bulk Pause / Unpause / Archive actions to List View bulk edit.

View File

@@ -1,6 +1,6 @@
import "./ListView.css"; import "./ListView.css";
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2 } from "lucide-react"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2, Pause, Play, Archive } from "lucide-react";
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core";
import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting"; import { sortTasksForDisplayColumn } from "./taskSorting";
@@ -732,6 +732,169 @@ export function ListView({
); );
}, [addToast, confirm, onDeleteTask, selectedTaskIds, tasks]); }, [addToast, confirm, onDeleteTask, selectedTaskIds, tasks]);
const handleBulkPause = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
if (!onPauseTask) {
addToast("Pause action is unavailable", "error");
return;
}
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused !== true);
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
addToast("No selected tasks can be paused", "error");
return;
}
setIsApplying(true);
const pausedIds: string[] = [];
const failedIds: string[] = [];
try {
for (const task of actionableTasks) {
try {
await onPauseTask(task.id);
pausedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
} finally {
setIsApplying(false);
}
if (pausedIds.length > 0) {
setSelectedTaskIds((previous) => {
const next = new Set(previous);
for (const id of pausedIds) {
next.delete(id);
}
return next;
});
}
addToast(
`Paused ${pausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, onPauseTask, selectedTaskIds, tasks]);
const handleBulkUnpause = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
if (!onUnpauseTask) {
addToast("Unpause action is unavailable", "error");
return;
}
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused === true);
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
addToast("No selected tasks can be unpaused", "error");
return;
}
setIsApplying(true);
const unpausedIds: string[] = [];
const failedIds: string[] = [];
try {
for (const task of actionableTasks) {
try {
await onUnpauseTask(task.id);
unpausedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
} finally {
setIsApplying(false);
}
if (unpausedIds.length > 0) {
setSelectedTaskIds((previous) => {
const next = new Set(previous);
for (const id of unpausedIds) {
next.delete(id);
}
return next;
});
}
addToast(
`Unpaused ${unpausedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, onUnpauseTask, selectedTaskIds, tasks]);
const handleBulkArchive = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
if (!onArchiveTask) {
addToast("Archive action is unavailable", "error");
return;
}
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const actionableTasks = selectedTasks.filter((task) => task.column === "done");
const skippedCount = selectedTasks.length - actionableTasks.length;
if (actionableTasks.length === 0) {
addToast("No selected tasks can be archived (only done tasks)", "error");
return;
}
const confirmed = await confirm({
title: "Archive Selected Tasks",
message: `Archive ${actionableTasks.length} selected task${actionableTasks.length === 1 ? "" : "s"}?`,
confirmLabel: "Archive",
cancelLabel: "Cancel",
danger: false,
});
if (!confirmed) return;
setIsApplying(true);
const archivedIds: string[] = [];
const failedIds: string[] = [];
try {
for (const task of actionableTasks) {
try {
await onArchiveTask(task.id);
archivedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
} finally {
setIsApplying(false);
}
if (archivedIds.length > 0) {
setSelectedTaskIds((previous) => {
const next = new Set(previous);
for (const id of archivedIds) {
next.delete(id);
}
return next;
});
}
addToast(
`Archived ${archivedIds.length} · ${skippedCount} skipped · ${failedIds.length} failed`,
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, confirm, onArchiveTask, selectedTaskIds, tasks]);
const handleApplyBulkUpdate = useCallback(async () => { const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return; if (selectedTaskIds.size === 0) return;
@@ -1190,7 +1353,19 @@ export function ListView({
{bulkEditEnabled && selectedTaskIds.size > 0 ? ( {bulkEditEnabled && selectedTaskIds.size > 0 ? (
<> <>
<div className="bulk-edit-toolbar"> <div className="bulk-edit-toolbar">
<button className="btn btn-danger btn-sm" onClick={handleBulkDelete} disabled={isApplying}> <button className="btn btn-sm" onClick={handleBulkPause} disabled={isApplying} title="Pause all selected tasks that are not already paused">
<Pause size={14} />
Pause selected
</button>
<button className="btn btn-sm" onClick={handleBulkUnpause} disabled={isApplying} title="Unpause selected tasks that are currently paused">
<Play size={14} />
Unpause selected
</button>
<button className="btn btn-sm" onClick={handleBulkArchive} disabled={isApplying} title="Archive selected tasks that are in Done">
<Archive size={14} />
Archive selected
</button>
<button className="btn btn-danger btn-sm" onClick={handleBulkDelete} disabled={isApplying} title="Delete selected tasks">
<Trash2 size={14} /> <Trash2 size={14} />
Delete selected Delete selected
</button> </button>

View File

@@ -2544,6 +2544,98 @@ describe("ListView - Bulk Selection", () => {
expect(applyButton).toBeDisabled(); expect(applyButton).toBeDisabled();
}); });
describe("bulk pause/unpause/archive", () => {
it("shows pause/unpause/archive buttons only when bulk selection is active", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
renderListView({ tasks });
expect(screen.queryByRole("button", { name: /^pause selected$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^unpause selected$/i })).toBeNull();
expect(screen.queryByRole("button", { name: /^archive selected$/i })).toBeNull();
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
expect(screen.getByRole("button", { name: /^pause selected$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^unpause selected$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^archive selected$/i })).toBeInTheDocument();
});
it("pauses only non-paused selected tasks and clears successful selections", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: false }), createMockTask({ id: "FN-002", paused: true })];
const onPauseTask = vi.fn(async () => createMockTask());
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onPauseTask });
enterBulkEditMode();
await user.click(screen.getByRole("button", { name: /^pause selected$/i }));
await waitFor(() => {
expect(onPauseTask).toHaveBeenCalledTimes(1);
expect(onPauseTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Paused 1 · 1 skipped · 0 failed", "success");
expect(screen.getByText("1 selected")).toBeInTheDocument();
});
it("unpauses only paused tasks and reports skipped count", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: true }), createMockTask({ id: "FN-002", paused: false })];
const onUnpauseTask = vi.fn(async () => createMockTask());
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onUnpauseTask });
enterBulkEditMode();
await user.click(screen.getByRole("button", { name: /^unpause selected$/i }));
await waitFor(() => {
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Unpaused 1 · 1 skipped · 0 failed", "success");
});
it("archives only done tasks after confirmation", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", column: "done" }), createMockTask({ id: "FN-002", column: "todo" })];
const onArchiveTask = vi.fn(async () => createMockTask());
mockConfirm.mockResolvedValueOnce(true);
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onArchiveTask });
enterBulkEditMode();
await user.click(screen.getByRole("button", { name: /^archive selected$/i }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(onArchiveTask).toHaveBeenCalledTimes(1);
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
});
it("shows error summary when pause has failures", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", paused: false })];
const onPauseTask = vi.fn(async () => {
throw new Error("boom");
});
renderListView({ tasks, onPauseTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /^pause selected$/i }));
await waitFor(() => {
expect(onPauseTask).toHaveBeenCalledTimes(1);
});
expect(mockAddToast).toHaveBeenCalledWith("Paused 0 · 0 skipped · 1 failed", "error");
expect(screen.getByText("1 selected")).toBeInTheDocument();
});
});
describe("bulk delete", () => { describe("bulk delete", () => {
it("deletes selected tasks and clears selection on success", async () => { it("deletes selected tasks and clears selection on success", async () => {
const user = userEvent.setup(); const user = userEvent.setup();