feat(FN-1624): add delete button to TaskCard for triage column

- Add delete button component to TaskCard for tasks in the triage column
- Thread onDeleteTask prop through App → Board → Column → TaskCard
- Add CSS styles for delete button with hover/active states
- Add comprehensive tests for delete button visibility and click handling
- Hide delete button for non-triage columns (todo, in-progress, etc.)
This commit is contained in:
gsxdsm
2026-04-13 00:21:25 -07:00
parent 322a2629e7
commit 2d7216f366
6 changed files with 257 additions and 3 deletions

View File

@@ -24,6 +24,7 @@ interface BoardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
/** Lazy-load archived tasks. Called the first time the user expands the archived column. */
onLoadArchivedTasks?: () => Promise<void>;
@@ -66,7 +67,7 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
return previous.every((task, index) => task === next[index]);
}
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const archivedLoadedRef = useRef(false);
const { fetchBatch } = useBatchBadgeFetch(projectId);
@@ -173,6 +174,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onOpenDetai
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
allTasks={tasks}
availableModels={availableModels}
onOpenDetailWithTab={onOpenDetailWithTab}

View File

@@ -33,6 +33,7 @@ interface ColumnProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string) => Promise<Task>;
onArchiveAllDone?: () => Promise<Task[]>;
collapsed?: boolean;
onToggleCollapse?: () => void;
@@ -61,7 +62,7 @@ interface ColumnProps {
lastFetchTimeMs?: number;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const countFlashing = useFlashOnIncrease(tasks.length);
@@ -253,6 +254,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onUpdateTask={onUpdateTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2 } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@fusion/core";
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
@@ -86,6 +86,7 @@ interface TaskCardProps {
) => Promise<Task>;
onArchiveTask?: (id: string) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string) => Promise<Task>;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
@@ -181,6 +182,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onUpdateTask === next.onUpdateTask &&
previous.onArchiveTask === next.onArchiveTask &&
previous.onUnarchiveTask === next.onUnarchiveTask &&
previous.onDeleteTask === next.onDeleteTask &&
previous.onOpenDetailWithTab === next.onOpenDetailWithTab &&
previous.onOpenMission === next.onOpenMission &&
previous.onMoveTask === next.onMoveTask &&
@@ -227,6 +229,7 @@ function TaskCardComponent({
onUpdateTask,
onArchiveTask,
onUnarchiveTask,
onDeleteTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
@@ -669,6 +672,19 @@ function TaskCardComponent({
});
}, [addToast, onUnarchiveTask, task.id]);
const handleDeleteClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!onDeleteTask) return;
if (window.confirm(`Delete ${task.id}?`)) {
void onDeleteTask(task.id).then(() => {
addToast(`Deleted ${task.id}`, "success");
}).catch((err: any) => {
addToast(`Failed to delete ${task.id}: ${err.message}`, "error");
});
}
}, [addToast, onDeleteTask, task.id]);
const handleOpenFiles = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onOpenDetailWithTab?.(task, "changes");
@@ -810,6 +826,16 @@ function TaskCardComponent({
<Pencil size={12} />
</button>
)}
{task.column === "triage" && onDeleteTask && (
<button
className="card-delete-btn"
onClick={handleDeleteClick}
title="Delete task"
aria-label="Delete task"
>
<Trash2 size={12} />
</button>
)}
{task.column === "done" && onArchiveTask && (
<button
className="card-archive-btn"

View File

@@ -47,6 +47,7 @@ vi.mock("lucide-react", () => ({
CircleDot: ({ size }: { size?: number }) => <span data-testid="circle-dot-icon"></span>,
Target: ({ size }: { size?: number }) => <span data-testid="target-icon">🎯</span>,
Bot: ({ size }: { size?: number }) => <span data-testid="bot-icon">🤖</span>,
Trash2: ({ size }: { size?: number }) => <span data-testid="trash-icon">🗑</span>,
}));
beforeEach(() => {
@@ -3833,3 +3834,178 @@ describe("TaskCard send-back functionality", () => {
});
});
});
/**
* Tests for delete button functionality in TaskCard.
*/
describe("TaskCard delete button", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows delete button for triage column tasks when onDeleteTask is provided", () => {
const task = makeTask({ column: "triage" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeDefined();
expect(deleteBtn.classList.contains("card-delete-btn")).toBe(true);
});
it("does not show delete button for non-triage column tasks", () => {
for (const column of ["todo", "in-progress", "in-review", "done", "archived"] as const) {
const task = makeTask({ column });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.queryByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeNull();
}
});
it("does not show delete button when onDeleteTask is not provided", () => {
const task = makeTask({ column: "triage" });
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
/>
);
const deleteBtn = screen.queryByRole("button", { name: /Delete task/i });
expect(deleteBtn).toBeNull();
});
it("clicking delete button shows confirmation dialog and calls onDeleteTask on confirm", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-123" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith("Delete FN-123?");
});
await waitFor(() => {
expect(onDeleteTask).toHaveBeenCalledWith("FN-123");
});
await waitFor(() => {
expect(noopToast).toHaveBeenCalledWith("Deleted FN-123", "success");
});
});
it("clicking delete button does not call onDeleteTask when confirmation is cancelled", async () => {
vi.spyOn(window, "confirm").mockReturnValue(false);
const task = makeTask({ column: "triage", id: "FN-456" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={noopToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(window.confirm).toHaveBeenCalledWith("Delete FN-456?");
});
expect(onDeleteTask).not.toHaveBeenCalled();
});
it("delete button click does not propagate to card click (does not open detail)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-789" });
const onDeleteTask = vi.fn().mockResolvedValue(task);
const onOpenDetail = vi.fn();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
// Wait for the click to propagate
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
// onOpenDetail should not have been called because stopPropagation was used
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("shows error toast when delete fails", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const task = makeTask({ column: "triage", id: "FN-ERROR" });
const onDeleteTask = vi.fn().mockRejectedValue(new Error("Network error"));
const addToast = vi.fn();
render(
<TaskCard
task={task}
onOpenDetail={vi.fn()}
addToast={addToast}
onDeleteTask={onDeleteTask}
/>
);
const deleteBtn = screen.getByRole("button", { name: /Delete task/i });
fireEvent.click(deleteBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("Failed to delete FN-ERROR"),
"error"
);
});
});
});