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)
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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 & 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 & 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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
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