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:
5
.changeset/fn-3889-listview-bulk-delete.md
Normal file
5
.changeset/fn-3889-listview-bulk-delete.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -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 & Node:</span>
|
<span className="bulk-edit-label">Bulk Edit Models & 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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
34
packages/dashboard/app/utils/__tests__/taskDelete.test.ts
Normal file
34
packages/dashboard/app/utils/__tests__/taskDelete.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
packages/dashboard/app/utils/taskDelete.ts
Normal file
21
packages/dashboard/app/utils/taskDelete.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user