Implementing Kanban Drag and Drop with dnd-kit
in a Next.js Project
This guide will take you through the process of implementing a drag and drop in a Kanban Board.
Prerequisites
Make sure you have Node.js and npm installed. Also, have a basic understanding of React and Next.js.
Step 1: Setting Up the Next.js Project
First, create a new Next.js project if you don't already have one:
npx create-next-app@latest dnd-kit-demo
cd dnd-kit-demo
Install the required packages
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Step 2: Creating 3 Necessary Components
- TaskBoard (This is the entire board, its has the board title and other props but importantly it should have tasks). It is also the container for the three columns
- Column (This is the column holding the Tasks according to status)
- Draggable Item (This is the Item that will be Dragged over the columns)
Step 3.1: Creating Taskboard
Irrespective of where your getting your data, this componenent expects some data and here is the type of data thst specifically my Taskboard
required.
It expected a prop of active Module, which of type moduleData
export type moduleData = {
id: string;
name: string;
userName: string;
userId: string;
projectId: string;
tasks: Task[];
createdAt: Date;
updatedAt: Date;
};
ModuleData
also has tasks that is of type Task
and here is the Task
export type Task = {
id: string;
title: string;
status: TaskStatus;
moduleId: string;
createdAt: Date;
updatedAt: Date;
};
Here is the Full TaskBoard Component, It is this component where you initialise the dnd context
TaskBoard.tsx
// components/Taskboard
"use client";
import { moduleData, Task } from "@/types/types";
import React, { useState } from "react";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import Column from "./Column";
import {
closestCorners,
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
MouseSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { TaskStatus } from "@prisma/client";
import { updateTaskStatus } from "@/actions/tasks";
import TaskForm from "@/components/Forms/TaskForm";
import { Progress } from "@/components/ui/progress";
import DraggableItem from "./DraggableItem";
export default function TaskBoard({
activeModule,
}: {
activeModule: moduleData;
}) {
function calculatePercentageCompletion(tasks: Task[]): number {
const allTasks = tasks.length;
const completedTasks = tasks.filter(
(task) => task.status === "COMPLETE"
).length;
return allTasks === 0 ? 0 : Math.round((completedTasks / allTasks) * 100);
}
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor)
);
const [module, setModule] = useState<moduleData>(activeModule);
const percentageCompletion = calculatePercentageCompletion(module.tasks);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
if (over && active.id !== over.id) {
const activeTask = module.tasks.find((task) => task.id === active.id);
const overContainer = over.id as TaskStatus;
if (activeTask && activeTask.status !== overContainer) {
const updatedTasks = module.tasks.map((task) =>
task.id === activeTask.id ? { ...task, status: overContainer } : task
);
setModule((prevModule) => ({
...prevModule,
tasks: updatedTasks,
}));
try {
// Update the database
await updateTaskStatus(active.id as string, overContainer);
} catch (error) {
console.error("Error updating task status:", error);
// Revert the optimistic update if the API call fails
setModule((prevModule) => ({
...prevModule,
tasks: activeModule.tasks,
}));
}
}
}
setActiveId(null);
};
const activeTask = activeId
? module.tasks.find((task) => task.id === activeId)
: null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="mb-6 flex items-center justify-between flex-wrap">
<div className="">
<h1 className="text-3xl font-bold mb-2">{activeModule.name}</h1>
<div className="flex items-center">
<Progress value={percentageCompletion} className="w-64 mr-4" />
<span className="text-sm text-gray-500">
{percentageCompletion}% complete
</span>
</div>
</div>
<div className="">
{activeModule.tasks.length > 0 && (
<p>({activeModule.tasks.length} Tasks)</p>
)}
<TaskForm
moduleId={activeModule.id}
initialStatus="TODO"
isDefault={true}
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{(["TODO", "INPROGRESS", "COMPLETE"] as const).map((status) => (
<Column
key={status}
moduleId={activeModule.id}
tasks={module.tasks}
status={status}
activeId={activeId}
/>
))}
</div>
<DragOverlay>
{activeId && activeTask ? (
<DraggableItem id={activeId} task={activeTask} isDragging={false} />
) : null}
</DragOverlay>
</DndContext>
);
}
3.2 Create the Column Component
In this Componet you initialise Droppable Id
// components/Column.tsx
import { DeleteTask } from "@/components/Forms/DeleteTask";
import TaskForm from "@/components/Forms/TaskForm";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Task } from "@/types/types";
import { useDroppable } from "@dnd-kit/core";
import { TaskStatus } from "@prisma/client";
import React from "react";
import DraggableItem from "./DraggableItem";
export default function Column({
tasks,
status,
moduleId,
activeId,
}: {
tasks: Task[];
status: TaskStatus;
moduleId: string;
activeId: string | null;
}) {
const { setNodeRef } = useDroppable({
id: status,
});
return (
<div className="rounded-tl-lg rounded-tr-lg border overflow-hidden">
<div
className={cn(
"flex flex-row items-center justify-between space-y-0 px-3 ",
status === "TODO"
? "bg-orange-50"
: status === "INPROGRESS"
? "bg-blue-50"
: "bg-green-50"
)}
>
<h2 className="text-sm font-bold">
{status
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</h2>
<div className="flex items-center space-x-2">
<TaskForm moduleId={moduleId} initialStatus={status} />
</div>
</div>
<div className="px-2">
<ScrollArea ref={setNodeRef} className="h-[calc(100vh-16rem)]">
{tasks
.filter((task) => task.status === status)
.map((task) => (
<DraggableItem
key={task.id}
id={task.id}
task={task}
isDragging={activeId === task.id}
/>
))}
</ScrollArea>
</div>
</div>
);
}
3.3 Create the Draggable Item
This is the Componet that will be Dragged
// components/DraggableItem.tsx
import React, { CSSProperties, memo } from "react";
import { useDraggable } from "@dnd-kit/core";
import { Button } from "@/components/ui/button";
import { MoreVertical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import TaskForm from "@/components/Forms/TaskForm";
import { DeleteTask } from "@/components/Forms/DeleteTask";
import { Task } from "@/types/types";
interface DraggableProps {
id: string;
task: Task;
isDragging: boolean;
}
export default memo(function DraggableItem({
id,
task,
isDragging,
}: DraggableProps) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: id,
});
const style: CSSProperties | undefined = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`my-2 p-3 bg-white rounded-md shadow transition-all duration-200 cursor-move ${
isDragging ? "opacity-0" : ""
}`}
>
<div className="flex justify-between items-center">
<span className="text-sm font-medium line-clamp-1">{task.title}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<TaskForm
moduleId={task.moduleId}
initialStatus={task.status}
initialTitle={task.title}
editingId={task.id}
/>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<DeleteTask id={task.id} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
});