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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user