feat(dashboard): add drag-to-delete and mobile delete actions

This commit is contained in:
Chneemann 2026-08-11 11:32:29 +02:00
parent 0fd1fa377b
commit 2d2a4388e2
No known key found for this signature in database
6 changed files with 137 additions and 16 deletions

View file

@ -1,6 +1,6 @@
/** /**
* @file dashboard/KanbanBoard.tsx * @file dashboard/KanbanBoard.tsx
* @description Client component wrapping the kanban columns grid, tracking individual task update states, and handling asynchronous status mutations via API. * @description Client component wrapping the kanban columns grid, tracking individual task update/deletion states, and handling asynchronous mutations via API.
*/ */
"use client"; "use client";
@ -13,7 +13,7 @@ import KanbanBoardHeader from "./components/KanbanBoardHeader";
/** /**
* Renders the responsive grid container of kanban columns, coordinating state tracking * Renders the responsive grid container of kanban columns, coordinating state tracking
* for active task updates and triggering status mutation API requests. * for active task updates/deletions and triggering mutation API requests.
* *
* @param {Object} props - The component props. * @param {Object} props - The component props.
* @param {Task[]} props.tasks - The array of task items displayed across the board. * @param {Task[]} props.tasks - The array of task items displayed across the board.
@ -61,10 +61,39 @@ export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
}); });
}; };
/**
* Deletes a specific task by sending a DELETE request to the API,
* managing loading states, and refreshing the router upon success.
*
* @param {string} taskId - The unique identifier of the task to delete.
*/
const deleteTask = (taskId: string) => {
setUpdatingTaskIds((prev) => new Set(prev).add(taskId));
startTransition(async () => {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete task");
await router.refresh();
} catch (error) {
console.error("Error during task deletion:", error);
} finally {
setUpdatingTaskIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
}
});
};
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Workspace Header */} {/* Workspace Header */}
<KanbanBoardHeader /> <KanbanBoardHeader onTaskDelete={deleteTask} />
{/* Kanban Columns Grid */} {/* Kanban Columns Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
@ -80,6 +109,7 @@ export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
color={col.color} color={col.color}
updatingTaskIds={updatingTaskIds} updatingTaskIds={updatingTaskIds}
onTaskMove={updateTaskStatus} onTaskMove={updateTaskStatus}
onTaskDelete={deleteTask}
/> />
); );
})} })}

View file

@ -23,6 +23,7 @@ export default function KanbanCard({
task, task,
isUpdating = false, isUpdating = false,
onStatusChange, onStatusChange,
onDelete,
}: KanbanCardProps) { }: KanbanCardProps) {
/** /**
* Initiates the drag action on a task card if not currently updating, storing its ID and status payload. * Initiates the drag action on a task card if not currently updating, storing its ID and status payload.
@ -88,7 +89,11 @@ export default function KanbanCard({
creator={task.creator} creator={task.creator}
assignees={task.assignees} assignees={task.assignees}
/> />
<KanbanCardActions currentStatus={task.status} onMove={handleMove} /> <KanbanCardActions
currentStatus={task.status}
onMove={handleMove}
onDelete={() => onDelete?.(task.id)}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -97,6 +97,7 @@ export default function KanbanColumn(props: KanbanColumnProps) {
task={task} task={task}
isUpdating={props.updatingTaskIds?.has(task.id)} isUpdating={props.updatingTaskIds?.has(task.id)}
onStatusChange={props.onTaskMove} onStatusChange={props.onTaskMove}
onDelete={props.onTaskDelete}
/> />
)) ))
)} )}

View file

@ -1,16 +1,45 @@
/** /**
* @file dashboard/components/KanbanBoardHeader.tsx * @file dashboard/components/KanbanBoardHeader.tsx
* @description Component rendering the top title header and action buttons for the dashboard view. * @description Component rendering the top title header with an integrated delete drop zone that appears during drag-and-drop interactions.
*/ */
import { Plus, Sparkles } from "lucide-react"; "use client";
import { useState, useEffect } from "react";
import { Plus, Sparkles, Trash2 } from "lucide-react";
/** /**
* Renders the dashboard header section including title, subtitle, workspace indicator, and action triggers. * Renders the dashboard header featuring a workspace title, subtitle, new task action button,
* and a dynamic drop zone for deleting tasks during drag operations.
* *
* @returns {JSX.Element} The rendered dashboard header component. * @param {Object} props - The component props.
* @param {(taskId: string) => void} props.onTaskDelete - Callback function invoked when a task is dropped into the delete zone.
* @returns {JSX.Element} The rendered kanban board header component.
*/ */
export default function KanbanBoardHeader() { export default function KanbanBoardHeader({
onTaskDelete,
}: {
onTaskDelete: (taskId: string) => void;
}) {
const [isDragging, setIsDragging] = useState(false);
const [isOver, setIsOver] = useState(false);
useEffect(() => {
const handleDragStart = () => setIsDragging(true);
const handleDragEnd = () => {
setIsDragging(false);
setIsOver(false);
};
window.addEventListener("dragstart", handleDragStart);
window.addEventListener("dragend", handleDragEnd);
return () => {
window.removeEventListener("dragstart", handleDragStart);
window.removeEventListener("dragend", handleDragEnd);
};
}, []);
return ( return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
@ -25,10 +54,38 @@ export default function KanbanBoardHeader() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isDragging ? (
<div
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsOver(true);
}}
onDragLeave={() => setIsOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsOver(false);
setIsDragging(false);
const taskId = e.dataTransfer.getData("text/plain");
if (taskId) {
onTaskDelete(taskId);
}
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm border-2 border-dashed cursor-pointer select-none transition-all duration-300 ease-out transform ${
isOver
? "bg-destructive border-destructive scale-110 shadow-lg animate-pulse"
: "bg-destructive/10 border-destructive/40 text-destructive scale-100 opacity-90 hover:opacity-100 animate-in fade-in zoom-in-95 duration-200"
}`}
>
<Trash2 size={16} className={isOver ? "animate-bounce" : ""} />
<span>Drop to Delete</span>
</div>
) : (
<button className="inline-flex items-center gap-2 px-3.5 py-2.5 rounded-xl font-medium text-sm text-black hover:text-foreground bg-primary hover:bg-primary-hover active:scale-95 transition-colors duration-200 cursor-pointer"> <button className="inline-flex items-center gap-2 px-3.5 py-2.5 rounded-xl font-medium text-sm text-black hover:text-foreground bg-primary hover:bg-primary-hover active:scale-95 transition-colors duration-200 cursor-pointer">
<Plus size={16} /> <Plus size={16} />
New Task New Task
</button> </button>
)}
</div> </div>
</div> </div>
); );

View file

@ -1,29 +1,32 @@
/** /**
* @file dashboard/components/KanbanCardActions.tsx * @file dashboard/components/KanbanCardActions.tsx
* @description Client component rendering the mobile status transition dropdown for a kanban card. * @description Client component rendering the mobile status transition and deletion dropdown for a kanban card.
*/ */
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { MoreHorizontal, CornerDownRight } from "lucide-react"; import { MoreHorizontal, CornerDownRight, Trash2 } from "lucide-react";
import { KANBAN_COLUMNS, TaskStatus } from "@/types/tasks"; import { KANBAN_COLUMNS, TaskStatus } from "@/types/tasks";
/** /**
* Renders a mobile-only action menu component allowing users to move a task * Renders a mobile-only action menu component allowing users to move a task
* between different kanban columns via a dropdown interface. * between different kanban columns or delete it entirely via a dropdown interface.
* *
* @param {Object} props - The component props. * @param {Object} props - The component props.
* @param {TaskStatus} props.currentStatus - The current status category of the task. * @param {TaskStatus} props.currentStatus - The current status category of the task.
* @param {(newStatus: TaskStatus) => void} props.onMove - Callback function triggered when a new status column is selected. * @param {(newStatus: TaskStatus) => void} props.onMove - Callback function triggered when a new status column is selected.
* @param {() => void} props.onDelete - Callback function triggered when the delete action is selected.
* @returns {JSX.Element} The rendered mobile card actions component. * @returns {JSX.Element} The rendered mobile card actions component.
*/ */
export default function KanbanCardActions({ export default function KanbanCardActions({
currentStatus, currentStatus,
onMove, onMove,
onDelete,
}: { }: {
currentStatus: TaskStatus; currentStatus: TaskStatus;
onMove: (newStatus: TaskStatus) => void; onMove: (newStatus: TaskStatus) => void;
onDelete: () => void;
}) { }) {
const [showMobileActions, setShowMobileActions] = useState(false); const [showMobileActions, setShowMobileActions] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -59,6 +62,17 @@ export default function KanbanCardActions({
setShowMobileActions(false); setShowMobileActions(false);
}; };
/**
* Handles clicking the delete action item to trigger task deletion.
*
* @param {React.MouseEvent} e - The mouse event object.
*/
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
setShowMobileActions(false);
};
return ( return (
<div className="relative sm:hidden" ref={dropdownRef}> <div className="relative sm:hidden" ref={dropdownRef}>
<button <button
@ -99,6 +113,18 @@ export default function KanbanCardActions({
</button> </button>
); );
})} })}
<div className="h-px bg-border my-1" />
{/* Delete Button */}
<button
onClick={handleDeleteClick}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-destructive hover:text-foreground flex items-center justify-between text-destructive group/del transition-colors cursor-pointer font-medium"
>
<span className="flex items-center gap-2">
<Trash2 size={12} />
Delete Task
</span>
</button>
</div> </div>
</div> </div>
); );

View file

@ -46,12 +46,14 @@ export interface KanbanColumnProps extends KanbanColumnConfig {
tasks: Task[]; tasks: Task[];
updatingTaskIds?: Set<string>; updatingTaskIds?: Set<string>;
onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void; onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void;
onTaskDelete?: (taskId: string) => void;
} }
export interface KanbanCardProps { export interface KanbanCardProps {
task: Task; task: Task;
isUpdating?: boolean; isUpdating?: boolean;
onStatusChange?: (taskId: string, newStatus: TaskStatus) => void; onStatusChange?: (taskId: string, newStatus: TaskStatus) => void;
onDelete?: (taskId: string) => void;
} }
// ========================================== // ==========================================