feat(FN-3889): add bulk delete with dependency conflict detection to ListVi

Bulk delete in ListView is now complete with dependency conflict detection and user-facing recovery options, backed by shared utility logic and tests. The feature touched ListView's core behavior, companion tests, and a small changeset documenting the user-facing recovery behavior.

Fusion-Task-Id: FN-3889
This commit is contained in:
Fusion
2026-05-09 17:50:10 -07:00
committed by gsxdsm
parent d74197ebf3
commit 00b35b8ca5
8 changed files with 322 additions and 96 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Restore ListView bulk-delete: select multiple tasks and delete them together, with archived selections skipped automatically and a per-task force-delete prompt for dependency conflicts.

View File

@@ -55,6 +55,7 @@ Features:
- Sortable columns (ID/title/status/column) - Sortable columns (ID/title/status/column)
- Column visibility toggles and optional hide-done filtering - Column visibility toggles and optional hide-done filtering
- Bulk selection + batch model updates - Bulk selection + batch model updates
- Bulk delete from the selection toolbar (`Delete selected`): archived selections are skipped automatically, and dependency-conflict failures can be force-deleted per task after a danger confirmation that removes dependency references.
![List view](./screenshots/list-view.png) ![List view](./screenshots/list-view.png)

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 } from "lucide-react"; import { ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight, Zap, Trash2 } from "lucide-react";
import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } from "@fusion/core"; import type { Task, TaskDetail, Column, TaskCreateInput, MergeResult } 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";
@@ -16,6 +16,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { getUnifiedTaskProgress } from "../utils/taskProgress"; import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
const COLUMN_COLOR_MAP: Record<Column, string> = { const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)", triage: "var(--triage)",
@@ -643,6 +644,88 @@ export function ListView({
}, [selectedTaskIds.size]); }, [selectedTaskIds.size]);
// Handle apply bulk model update // Handle apply bulk model update
const handleBulkDelete = useCallback(async () => {
if (selectedTaskIds.size === 0) return;
const selectedTasks = Array.from(selectedTaskIds)
.map((id) => tasks.find((task) => task.id === id))
.filter((task): task is Task => Boolean(task));
const archivedTasks = selectedTasks.filter((task) => task.column === "archived");
const deletableTasks = selectedTasks.filter((task) => task.column !== "archived");
if (deletableTasks.length === 0) {
addToast("No selected tasks can be deleted (archived tasks are excluded)", "error");
return;
}
const confirmed = await confirm({
title: "Delete Selected Tasks",
message: `Delete ${deletableTasks.length} selected task${deletableTasks.length === 1 ? "" : "s"}?`,
confirmLabel: "Delete",
cancelLabel: "Cancel",
danger: true,
});
if (!confirmed) return;
setIsApplying(true);
const deletedIds: string[] = [];
const failedIds: string[] = [];
const skippedIds = archivedTasks.map((task) => task.id);
try {
for (const task of deletableTasks) {
try {
await onDeleteTask(task.id);
deletedIds.push(task.id);
} catch (err) {
const conflict = extractDependencyDeleteConflict(err);
if (!conflict) {
failedIds.push(task.id);
continue;
}
const forceDelete = await confirm({
title: "Force Delete Task",
message: `Task ${task.id} has dependents: ${conflict.dependentIds.join(", ")}. Remove dependency references and force delete?`,
confirmLabel: "Force Delete",
cancelLabel: "Skip",
danger: true,
});
if (!forceDelete) {
failedIds.push(task.id);
continue;
}
try {
await onDeleteTask(task.id, { removeDependencyReferences: true });
deletedIds.push(task.id);
} catch {
failedIds.push(task.id);
}
}
}
} finally {
setIsApplying(false);
}
if (deletedIds.length > 0) {
setSelectedTaskIds((previous) => {
const next = new Set(previous);
for (const id of deletedIds) {
next.delete(id);
}
return next;
});
}
addToast(
`Deleted ${deletedIds.length} task${deletedIds.length === 1 ? "" : "s"} · ${skippedIds.length} archived skipped · ${failedIds.length} failed`,
failedIds.length > 0 ? "error" : "success",
);
}, [addToast, confirm, onDeleteTask, selectedTaskIds, tasks]);
const handleApplyBulkUpdate = useCallback(async () => { const handleApplyBulkUpdate = useCallback(async () => {
if (selectedTaskIds.size === 0) return; if (selectedTaskIds.size === 0) return;
@@ -1098,7 +1181,15 @@ export function ListView({
View options View options
</button> </button>
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")} {viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
{bulkEditEnabled && selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && ( {bulkEditEnabled && selectedTaskIds.size > 0 ? (
<>
<div className="bulk-edit-toolbar">
<button className="btn btn-danger btn-sm" onClick={handleBulkDelete} disabled={isApplying}>
<Trash2 size={14} />
Delete selected
</button>
</div>
{availableModels && availableModels.length > 0 ? (
<div className="bulk-edit-toolbar"> <div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span> <span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span>
<div className="bulk-edit-dropdown"> <div className="bulk-edit-dropdown">
@@ -1155,7 +1246,9 @@ export function ListView({
{isApplying ? "Applying..." : "Apply"} {isApplying ? "Applying..." : "Apply"}
</button> </button>
</div> </div>
)} ) : null}
</>
) : null}
</aside> </aside>
)} )}
<div className="list-quick-entry-above-table"> <div className="list-quick-entry-above-table">

View File

@@ -16,6 +16,7 @@ import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming"; import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseTimestampToMs } from "../utils/taskTiming";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
// ── Mission title caching ─────────────────────────────────────────────────── // ── Mission title caching ───────────────────────────────────────────────────
@@ -315,24 +316,6 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined; return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
} }
function extractDependencyDeleteConflict(err: unknown): { dependentIds: string[] } | null {
if (!(err instanceof Error)) {
return null;
}
const details = (err as { details?: { code?: string; dependentIds?: unknown } }).details;
if (details?.code === "TASK_HAS_DEPENDENTS" && Array.isArray(details.dependentIds)) {
return { dependentIds: details.dependentIds.filter((id): id is string => typeof id === "string") };
}
const idsInMessage = err.message.match(/[A-Z]+-\d+/g) ?? [];
if (idsInMessage.length > 1) {
return { dependentIds: [...new Set(idsInMessage.slice(1))] };
}
return null;
}
function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean { function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean {
if (!previous && !next) return true; if (!previous && !next) return true;
if (!previous || !next) return false; if (!previous || !next) return false;

View File

@@ -39,6 +39,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus"; import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots"; import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth"; import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
interface ModelSelection { interface ModelSelection {
provider?: string; provider?: string;
@@ -256,24 +257,6 @@ export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
onRequestClose?: () => void; onRequestClose?: () => void;
}; };
function extractDependencyDeleteConflict(err: unknown): { dependentIds: string[] } | null {
if (!(err instanceof Error)) {
return null;
}
const details = (err as { details?: { code?: string; dependentIds?: unknown } }).details;
if (details?.code === "TASK_HAS_DEPENDENTS" && Array.isArray(details.dependentIds)) {
return { dependentIds: details.dependentIds.filter((id): id is string => typeof id === "string") };
}
const idsInMessage = err.message.match(/[A-Z]+-\d+/g) ?? [];
if (idsInMessage.length > 1) {
return { dependentIds: [...new Set(idsInMessage.slice(1))] };
}
return null;
}
function truncate(s: string, max: number): string { function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + "…" : s; return s.length > max ? s.slice(0, max) + "…" : s;
} }

View File

@@ -2391,7 +2391,7 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(checkbox); fireEvent.click(checkbox);
expect(screen.getByText("1 selected")).toBeDefined(); expect(screen.getByText("1 selected")).toBeDefined();
const clearButton = screen.getByRole("button", { name: /selected/i }); const clearButton = screen.getByRole("button", { name: /^1 selected$/i });
fireEvent.click(clearButton); fireEvent.click(clearButton);
expect(screen.queryByText("1 selected")).toBeNull(); expect(screen.queryByText("1 selected")).toBeNull();
@@ -2408,7 +2408,7 @@ describe("ListView - Bulk Selection", () => {
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks"); const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
fireEvent.click(selectAllCheckbox); fireEvent.click(selectAllCheckbox);
expect(screen.getByRole("button", { name: /selected/i })).toBeDefined(); expect(screen.getByRole("button", { name: /^2 selected$/i })).toBeDefined();
}); });
it("accepts favoriteProviders and favoriteModels props", () => { it("accepts favoriteProviders and favoriteModels props", () => {
@@ -2488,6 +2488,112 @@ describe("ListView - Bulk Selection", () => {
expect(applyButton).toBeDisabled(); expect(applyButton).toBeDisabled();
}); });
describe("bulk delete", () => {
it("deletes selected tasks and clears selection on success", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" }), createMockTask({ id: "FN-002" })];
const onDeleteTask = vi.fn(async () => createMockTask());
mockConfirm.mockResolvedValueOnce(true);
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByLabelText("Select FN-002"));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(2);
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-001");
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-002");
});
expect(mockAddToast).toHaveBeenCalledWith("Deleted 2 tasks · 0 archived skipped · 0 failed", "success");
expect(screen.queryByText("2 selected")).toBeNull();
});
it("skips archived tasks and reports summary", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001", column: "todo" }), createMockTask({ id: "FN-002", column: "archived" })];
const onDeleteTask = vi.fn(async () => createMockTask());
mockConfirm.mockResolvedValueOnce(true);
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(1);
expect(onDeleteTask).toHaveBeenCalledWith("FN-001");
});
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 1 archived skipped · 0 failed", "success");
expect(screen.getByText("1 selected")).toBeInTheDocument();
});
it("does nothing when delete confirm is cancelled", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
const onDeleteTask = vi.fn(async () => createMockTask());
mockConfirm.mockResolvedValueOnce(false);
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledTimes(1);
});
expect(onDeleteTask).not.toHaveBeenCalled();
});
it("force deletes when dependency conflict is confirmed", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
const conflictError = Object.assign(new Error("dependency conflict"), {
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] },
});
const onDeleteTask = vi
.fn<(...args: [string, { removeDependencyReferences?: boolean }?]) => Promise<Task>>()
.mockRejectedValueOnce(conflictError)
.mockResolvedValueOnce(createMockTask());
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(2);
});
expect(onDeleteTask).toHaveBeenNthCalledWith(1, "FN-001");
expect(onDeleteTask).toHaveBeenNthCalledWith(2, "FN-001", { removeDependencyReferences: true });
expect(mockAddToast).toHaveBeenCalledWith("Deleted 1 task · 0 archived skipped · 0 failed", "success");
});
it("marks failure when force delete is declined", async () => {
const user = userEvent.setup();
const tasks = [createMockTask({ id: "FN-001" })];
const conflictError = Object.assign(new Error("dependency conflict"), {
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-100"] },
});
const onDeleteTask = vi.fn(async () => {
throw conflictError;
});
mockConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
renderListView({ tasks, onDeleteTask });
enterBulkEditMode();
await user.click(screen.getByLabelText("Select FN-001"));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledTimes(1);
});
expect(mockAddToast).toHaveBeenCalledWith("Deleted 0 tasks · 0 archived skipped · 1 failed", "error");
});
});
it("persists selection to localStorage", () => { it("persists selection to localStorage", () => {
const tasks = [createMockTask({ id: "FN-001" })]; const tasks = [createMockTask({ id: "FN-001" })];
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />); render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { extractDependencyDeleteConflict } from "../taskDelete";
describe("extractDependencyDeleteConflict", () => {
it("returns dependent ids from details.code payload", () => {
const err = Object.assign(new Error("conflict"), {
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: ["FN-1", "FN-2", 3] },
});
expect(extractDependencyDeleteConflict(err)).toEqual({ dependentIds: ["FN-1", "FN-2"] });
});
it("returns null for missing or invalid details payload", () => {
const missingDetails = new Error("failed");
const invalidDetails = Object.assign(new Error("failed"), {
details: { code: "TASK_HAS_DEPENDENTS", dependentIds: "FN-1" },
});
expect(extractDependencyDeleteConflict(missingDetails)).toBeNull();
expect(extractDependencyDeleteConflict(invalidDetails)).toBeNull();
});
it("falls back to parsing ids from message", () => {
const err = new Error("Cannot delete FN-22 because dependent tasks FN-100 and FN-101 block it; FN-100");
expect(extractDependencyDeleteConflict(err)).toEqual({ dependentIds: ["FN-100", "FN-101"] });
});
it("returns null for non-Error inputs", () => {
expect(extractDependencyDeleteConflict(null)).toBeNull();
expect(extractDependencyDeleteConflict({ message: "FN-1 FN-2" })).toBeNull();
expect(extractDependencyDeleteConflict("boom")).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
export interface DependencyDeleteConflict {
dependentIds: string[];
}
export function extractDependencyDeleteConflict(err: unknown): DependencyDeleteConflict | null {
if (!(err instanceof Error)) {
return null;
}
const details = (err as { details?: { code?: string; dependentIds?: unknown } }).details;
if (details?.code === "TASK_HAS_DEPENDENTS" && Array.isArray(details.dependentIds)) {
return { dependentIds: details.dependentIds.filter((id): id is string => typeof id === "string") };
}
const idsInMessage = err.message.match(/[A-Z]+-\d+/g) ?? [];
if (idsInMessage.length > 1) {
return { dependentIds: [...new Set(idsInMessage.slice(1))] };
}
return null;
}