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:
5
.changeset/FN-4412-bulk-list-actions.md
Normal file
5
.changeset/FN-4412-bulk-list-actions.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add bulk Pause / Unpause / Archive actions to List View bulk edit.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user