feat(dashboard): add drag-to-delete and mobile delete actions
This commit is contained in:
parent
0fd1fa377b
commit
2d2a4388e2
6 changed files with 137 additions and 16 deletions
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<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">
|
{isDragging ? (
|
||||||
<Plus size={16} />
|
<div
|
||||||
New Task
|
onDragOver={(e) => {
|
||||||
</button>
|
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">
|
||||||
|
<Plus size={16} />
|
||||||
|
New Task
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue