Desishub Lessons
Next JS and TypeScript
Kanban Drag and Drop

Implementing Kanban Drag and Drop with dnd-kit in a Next.js Project

Beautiful Landscape

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>
  );
});