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)
- Column visibility toggles and optional hide-done filtering
- 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)

View File

@@ -1,6 +1,6 @@
import "./ListView.css";
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 { COLUMN_LABELS, COLUMNS, DEFAULT_COLUMN, getErrorMessage, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting";
@@ -16,6 +16,7 @@ import { useViewportMode } from "../hooks/useViewportMode";
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
import { getUnifiedTaskProgress } from "../utils/taskProgress";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
const COLUMN_COLOR_MAP: Record<Column, string> = {
triage: "var(--triage)",
@@ -643,6 +644,88 @@ export function ListView({
}, [selectedTaskIds.size]);
// 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 () => {
if (selectedTaskIds.size === 0) return;
@@ -1098,64 +1181,74 @@ export function ListView({
View options
</button>
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
{bulkEditEnabled && selectedTaskIds.size > 0 && availableModels && availableModels.length > 0 && (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={executorModel}
onChange={setExecutorModel}
label="Executor Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
{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>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={validatorModel}
onChange={setValidatorModel}
label="Reviewer Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select
className="select bulk-node-select"
value={nodeOverride}
onChange={(e) => setNodeOverride(e.target.value)}
aria-label="Node Override"
disabled={isLoadingNodes}
>
<option value="__no_change__">No change</option>
<option value="">Use project default</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>
</div>
)}
{availableModels && availableModels.length > 0 ? (
<div className="bulk-edit-toolbar">
<span className="bulk-edit-label">Bulk Edit Models &amp; Node:</span>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={executorModel}
onChange={setExecutorModel}
label="Executor Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown">
<CustomModelDropdown
models={availableModels}
value={validatorModel}
onChange={setValidatorModel}
label="Reviewer Model"
noChangeValue="__no_change__"
noChangeLabel="No change"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
<div className="bulk-edit-dropdown bulk-edit-node-wrap">
<select
className="select bulk-node-select"
value={nodeOverride}
onChange={(e) => setNodeOverride(e.target.value)}
aria-label="Node Override"
disabled={isLoadingNodes}
>
<option value="__no_change__">No change</option>
<option value="">Use project default</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>
{`${getNodeStatusSymbol(node.status)} ${node.name || node.id} (${getNodeStatusLabel(node.status)})`}
</option>
))}
</select>
{selectedOverrideNode ? <NodeHealthDot status={selectedOverrideNode.status} showLabel /> : null}
</div>
<button
className="btn btn-primary btn-sm bulk-edit-apply-btn"
onClick={handleApplyBulkUpdate}
disabled={isApplying || (executorModel === "__no_change__" && validatorModel === "__no_change__" && nodeOverride === "__no_change__")}
>
{isApplying ? "Applying..." : "Apply"}
</button>
</div>
) : null}
</>
) : null}
</aside>
)}
<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 type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
// ── Mission title caching ───────────────────────────────────────────────────
@@ -315,24 +316,6 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
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 {
if (!previous && !next) return true;
if (!previous || !next) return false;

View File

@@ -39,6 +39,7 @@ import { ProviderIcon } from "./ProviderIcon";
import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
interface ModelSelection {
provider?: string;
@@ -256,24 +257,6 @@ export type TaskDetailContentProps = Omit<TaskDetailModalProps, "onClose"> & {
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 {
return s.length > max ? s.slice(0, max) + "…" : s;
}

View File

@@ -2391,7 +2391,7 @@ describe("ListView - Bulk Selection", () => {
fireEvent.click(checkbox);
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);
expect(screen.queryByText("1 selected")).toBeNull();
@@ -2408,7 +2408,7 @@ describe("ListView - Bulk Selection", () => {
const selectAllCheckbox = screen.getByLabelText("Select all visible tasks");
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", () => {
@@ -2488,6 +2488,112 @@ describe("ListView - Bulk Selection", () => {
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", () => {
const tasks = [createMockTask({ id: "FN-001" })];
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;
}