Go Back

Design and Implement Jira Board with Drag and Drop feature using React and Tailwind CSS

Posted on February 5, 2023|12 min read

Introduction

In this article, we are going to learn how to design Jira Board like UI using React with Tailwind CSS and how to manage its state using Context API. Also we are going to implement our own custom Drag and Drop feature by implementing reusable custom hooks.

demo

We are going to implement the whole feature in this post, so it is better to plan step by step how we can approach the problem. I am going to divide the whole problem into three steps:

  • Designing and implementing the UI
    • what should be optimal component hierarchy for this UI.
    • How does the data flow work with the proposed component design considering performance scenarios like minimizing extra re-renders.
  • Managing the state of the component hierarchy
    • What should be the interface of the state for the UI
    • How can we optimize state design
  • Implementing Drag and Drop feature.
    • Implementing custom hooks for the Drag and Drop
    • Using custom hooks in our UI.

Deciding Component Hierarchy 🧐

JIRA COMPONENT HIERARCHY

If you observe the UI, at higher level, we can see three main components:

  • Jira Issue card which displays jira issue details like title, tags, assignee etc. and is draggable so that it can be moved to different status.
  • Jira Issue list which displays list of all jira issues belonging to a particular status so will hold multiple Jira Issue cards.
  • Jira Board which displays a separate list of issues for each jira status thus contains multiple Jira Issue Lists.

so the component hierarchy looks like:

JIRA COMPONENT HIERARCHY TREE

Data Structure and Data Flow through Component hierarchy

Based on the UI, the data for a Jira Issue has following interface:

type JiraIssue = { id: string; title: string; status: JiraIssueStatus; tags: string[]; user?: string; }

JiraIssueStatus:

type JiraIssueStatus = "TODO" | "IN PROGRESS" | "IN REVIEW" | "DONE";

JiraBoard will have a list of jira issues. It will then normalize the data by status and then render list of JiraIssueList for different issue status. JiraIssueList will have a list of jira issues for a given status and then render multiple JiraCard components. This is the simple component hierarchy and data flow among them i can think of.

Before we go ahead with components implementation, let's think and implement the state management for the component hierarchy.

State management

We have two usecases here:

  • JiraBoard component need to get access to the list of jira issues,
  • JiraIssueList need to have a function to update the state i.e. jira issue list whenever user drags or moves a jira issue from one status to a different one.

There can be multiple approaches for managing the state:

  1. Simple one is, We can define state in JiraBoard component and use props system to pass data to the child components.
  2. Another approach is, We can use React Context API for the state management.

I am going with the second approach 🫠.

I am not a fan of keeping referencial state in the context because sometimes it results in unneccessary re-rendering of the components. To know more about this behaviour, you can go through my post on React Context API.

We are going to have two different contexts:

  • one for list of jira issues
  • another one for the update function to update status of a jira issue

Let's see the implementation:

import React, { createContext, useCallback, useState } from "react"; import { Dictionary } from "lodash"; import jiraIssues from "./data"; import { JiraIssue, JiraIssueStatus } from "./type"; type UpdateIssueStatus = ((id: string, status: JiraIssueStatus) => void) | null; export const JiraIssueContext = createContext<Dictionary<JiraIssue>>({}); export const JiraIssueUpdaterContext = createContext<UpdateIssueStatus>(null); const JiraIssueProvider = ({ children }: { children: React.ReactNode }) => { const [issues, setIssues] = useState(jiraIssues); const updateIssueStatus = useCallback((id: string, status: JiraIssueStatus) => { setIssues(issues => ({ ...issues, [id]: { ...issues[id], status, } })); }, []); return ( <JiraIssueUpdaterContext.Provider value={updateIssueStatus}> <JiraIssueContext.Provider value={issues}> {children} </JiraIssueContext.Provider> </JiraIssueUpdaterContext.Provider> ); }; export default JiraIssueProvider;

If we notice the type of data in JiraIssueContext, it is Dictionary<JiraIssue>. Dictionary is a simple key-value pair type defined in lodash having interface as:

interface Dictionary<T> { [index: string]: T; }

so, in our case, data i.e. list of jira issues looks like:

const data: JiraIssue[] = [ { id: "TQT-1", title: "Update Node.js version to latest in all repos and validate the build", user: "Altamash", tags: ["Product Team", "KTLO", "Tech Dept"], status: "TODO" }, { id: "TQT-2", title: "Update React version to latest in all repos and validate the build", tags: ["Product Team", "Frontend", "KTLO", "Tech Dept"], status: "TODO" }, { id: "TQT-3", title: "Improve performance of the Review page by eliminating extra re-renders", tags: ["Product Team", "Frontend", "KTLO", "Performance"], status: "IN PROGRESS" }, ];

We are converting the data into dictionary with key as Jira Issue id using lodash.keyby method:

import _keyBy from 'lodash.keyby'; const jiraIssues = _keyBy(data, 'id');

After that, our data look like:

{ "TQT-1": { id: "TQT-1", title: "Update Node.js version to latest in all repos and validate the build", user: "Altamash", tags: [ "Product Team", "KTLO", "Tech Dept" ], status: "TODO" }, "TQT-2": { id: "TQT-2", title: "Update React version to latest in all repos and validate the build", tags: [ "Product Team", "Frontend", "KTLO", "Tech Dept" ], status: "TODO" }, "TQT-3": { id: "TQT-3", title: "Improve performance of the Review page by eliminating extra re-renders", tags: [ "Product Team", "Frontend", "KTLO", "Performance" ], status: "IN PROGRESS" }, }

Idea behind converting the list to dictionary is: it will be more efficient to update the list in case of dictionary as we don't have to iterate over the list of issues to find the issue by id. We can directly use id as index to update the state:

const updateIssueStatus = useCallback((id: string, status: JiraIssueStatus) => { setIssues(issues => ({ ...issues, [id]: { ...issues[id], status, } })); }, []);

You can also notice, we are using useCallback hook so that function reference remains the same whenever components re-render.

Also, ordering of the Providers matter because as a thumb rule, whenever parent component renders, its child components will also renders. Since updateIssueStatus reference is going to be same during multiple rerenders, and issues state will be updated frequently, it makes sense to keep JiraIssueUpdaterContext.Provider ahead of JiraIssueContext.Provider.

return ( <JiraIssueUpdaterContext.Provider value={updateIssueStatus}> <JiraIssueContext.Provider value={issues}> {children} </JiraIssueContext.Provider> </JiraIssueUpdaterContext.Provider> );

Now, we are done with the state implementation. Let's go ahead with the components implementation:

Components implementation

JiraCard

If you look at the JiraCard, we need to display:

  • a jira card title,
  • list of tags
  • ticket id
  • an avatar

I will be using tailwind-styled-components to style components with tailwind css.

import tw from "tailwind-styled-components";

let's implement sub-components Tag and Avatar first:

Tag

export const Tag = tw.span` inline-block bg-purple-200 rounded-full px-3 py-1 text-xs font-semibold text-purple-700 mr-2 mb-2 uppercase `;

Avatar

const Avatar = tw.div` p-3 w-4 h-4 rounded-full flex justify-center items-center bg-blue-300 text-blue-700 `;

Now, let's see JiraCard implementation:

Prop type will be same as JiraIssue type:

export type Props = JiraIssue;

We can define container for the card using styled component to have some shadow and rounded corners:

const JiraCardContainer = tw.div` max-w-sm p-2 rounded overflow-hidden shadow-xl bg-white `;
const JiraCard = ({ title, tags, id, user }: Props) => { return ( <JiraCardContainer key={id}> <div className="p-4 flex flex-col justify-around items-start gap-4"> <div className=" text-lg">{title}</div> <div> {tags.map(tag => ( <TagStyledComponent key={tag}>{tag}</TagStyledComponent> ))} </div> <div className="flex justify-between w-full"> <div className=" font-semibold text-gray-400">{id}</div> <Avatar>{user?.charAt(0) ?? "U"} </Avatar> </div> </div> </JiraCardContainer> ); } export default memo(JiraCard);

You may notice that how simple it is to style components using tailwind CSS 😍 and flex is what i use almost everywhere 😝. Also, I have wrapped the JiraCard component with React.memo to avoid re-rendering in case props remains the same.

Next component is 🥸

JiraIssueList

export type Props = { status: JiraIssueStatus; issues: JiraIssue[]; } const Container = tw.div<>` p-2 flex flex-col gap-2 bg-gray-100 min-w-[300px] min-h-screen `; const JiraIssueList = ({ status, issues }: Props) => { const numberOfIssues = `${issues?.length ?? 0} ${(issues?.length ?? 0) > 1 ? "issues" : "issue"}`; const renderedIssues = useMemo(() => { return issues && issues.map(issue => <JiraCard key={issue.id} {...issue} /> ) }, [issues]); return ( <Container key={status}> <div className="font-semibold text-gray-400 uppercase mb-4 p-2"> <span>{status}</span> <span className="ml-2">{numberOfIssues}</span> </div> {renderedIssues} </Container> ); }; export default JiraIssueList;

Now that, we have JiraIssueList component implemented, let's go for final JiraBoard component:

JiraBoard

import { useContext, useMemo } from "react"; import { JiraIssueContext } from "./context"; import groupBy from "lodash.groupby"; import { JiraIssue, JiraIssueStatus } from "./type"; import JiraIssueList from "./jira-issue-list"; const jiraIssueStatusList: JiraIssueStatus[] = ["TODO", "IN PROGRESS", "IN REVIEW", "DONE"]; const JiraBoard = () => { const issues = useContext(JiraIssueContext); // group the data by status const issuesByStatus = useMemo(() => { return groupBy(Object.values(issues), 'status') as Record<JiraIssueStatus, JiraIssue[]>; }, [issues]); return ( <div className=" w-full flex items-center justify-around"> {jiraIssueStatusList.map((status) => <JiraIssueList key={status} status={status} issues={issuesByStatus[status]} /> )} </div> ); } export default JiraBoard;

Here, we are getting access to the state i.e. jira issues using JiraIssueContext. Then we are grouping the issues by status using lodash.groupby method. Then iterating over the status list and rendering JiraIssueList for each status.

Also, ✋ don't forget to wrapped JiraBoard with JiraIssueProvider to access the context we have defined:

<JiraIssueProvider> <JiraBoard /> </JiraIssueProvider>

With that, we are done with component design and implementations 😮‍💨.

Now, let's implement drag and drop feature:

Drag and Drop feature implementation

If you are not aware of HTML5 Drag and Drop API, you can go through this awesome blog.

In our usecase, we need to drag (i.e. move) a jira card from one status to another status. As per our component, JiraCard component is going to be dragged🪡 so it is drag target and we are going to drop the JiraCard into JiraIssueList component, so JiraIssueList is drop target.

Events fired on the draggable targets are:

  • drag - fired every few hundred milliseconds as an element or text selection is being dragged by the user.
  • dragstart - fired when the user starts dragging an element
  • dragend - fired when a drag operation is being ended (by releasing a mouse button)

and Events fired on the drop targets are:

  • dragover: fired when an element is being dragged over a valid drop target (every few hundred milliseconds)
  • dragenter: fired when a dragged element enters a valid drop target
  • dragleave: fired when a dragged element leaves a valid drop target
  • drop: fired when an element is dropped on a valid drop target.

We are going to implement two different reusable custom hooks for both drag and drop targets:

  • useDraggableTarget
  • useDropTarget

useDraggableTarget hook

This hook can be used when you need to make any element as draggable.

To make an object draggable we need to set draggable=true on that element. Therefore, we need to take ref as a parameter so that our custom hook can set draggable attribute and register/deregister event listeners. Along with that, we need to get optional set of event handlers needed for draggable targets in case user wants to customize the behaviour based on different events. Our hook is going to expose a single boolean state isDragging whenever the element is being dragged by the user.

Here is the implementation:

import { useEffect, useState } from "react"; export type DraggableTargetEventHandlers = { drag?: (event: DragEvent) => void; dragstart?: (event: DragEvent) => void; dragend?: (event: DragEvent) => void; }; const useDraggableTarget = ( ref: React.RefObject<HTMLElement>, { drag, dragstart, dragend }: DraggableTargetEventHandlers = {}, ) => { const [isDragging, setIsDragging] = useState(false); useEffect(() => { if (ref.current) { // set the draggable attribute to true to make it draggable ref.current.setAttribute('draggable', "true"); /* register events fired on the draggable target */ // drag event drag && ref.current.addEventListener('drag', drag); // dragstart event const handleDragStart = (event: DragEvent) => { setIsDragging(true); dragstart?.(event); } ref.current.addEventListener('dragstart', handleDragStart); // dragend event const handleDragEnd = (event: DragEvent) => { setIsDragging(false); dragend?.(event); } ref.current.addEventListener('dragend', handleDragEnd); /* unsubscribe drag events before unmount */ return () => { drag && ref.current?.removeEventListener('drag', drag); ref.current?.removeEventListener('dragstart', handleDragStart); ref.current?.removeEventListener('dragend', handleDragEnd); }; } }, [ref, drag, dragstart, dragend]); return isDragging; }; export default useDraggableTarget;

useDropTarget hook

This hook can be used when you need to make any element as a drop target.

It is having similar implementation as useDraggableTarget as we would need ref to the element to register event handlers needed for drop targets and an optional custom events handlers config object. We have one boolean state isDragOver defined to indicate if any element is being dragged over our drop target.

Here is the implementation:

import { useEffect, useState } from "react"; export type DropTargetEventHandlers = { dragover?: (event: DragEvent) => void; dragenter?: (event: DragEvent) => void; dragleave?: (event: DragEvent) => void; drop?: (event: DragEvent) => void; }; const useDropTarget = ( ref: React.RefObject<HTMLElement>, { dragover, dragenter, dragleave, drop }: DropTargetEventHandlers = {}, ) => { const [isDragOver, setIsDragOver] = useState(false); useEffect(() => { if (ref.current) { /* register events fired on the drop target */ // dragover event const handleDragOver = (event: DragEvent) => { event.preventDefault(); setIsDragOver(true); dragover?.(event); } ref.current.addEventListener('dragover', handleDragOver); // dragenter event const handleDragEnter = (event: DragEvent) => { dragenter?.(event); } ref.current.addEventListener('dragenter', handleDragEnter); // dragleave event const handleDragLeave = (event: DragEvent) => { setIsDragOver(false); dragleave?.(event); } ref.current.addEventListener('dragleave', handleDragLeave); // drop event const handleDrop = (event: DragEvent) => { setIsDragOver(false); drop?.(event); } ref.current.addEventListener('drop', handleDrop); /* unsubscribe drag events before unmount */ return () => { ref.current?.removeEventListener('dragover', handleDragOver); ref.current?.removeEventListener('dragenter', handleDragEnter); ref.current?.removeEventListener('dragleave', handleDragLeave); ref.current?.removeEventListener('drop', handleDrop); }; } }, [ref, dragover, dragenter, dragleave, drop]); return isDragOver; }; export default useDropTarget;

Now that, we have both hooks implemented. Let's see now how easy it is to implement drag and drop feature.

Implementing dragging in jira Card component

We will be using useDraggableTarget() hook to implement dragging in JiraCard component.

We need to define a ref to the JiraCardContainer using useRef.

Also we need to set the data corresponding to dragged element on the drag event which is id of the jira issue so that we can retrieve it whenever user drops it on the target and update the list. For that, we can add a handler for dragstart event and pass it to the hook. Note that, we are using useMemo so that config object reference doesn't change when id is same and component rerenders.

Also if you check out the gif above, we are setting its opacity to 30% while dragging and set back to 100% when drag is finished. To implement this, We can use the return value of our custom hook dragging and pass it as props to JiraCardContainer styled component to add tailwind classes opacity-30 and opacity-100 based on state.

Check out the changes in JiraCard and JiraCardContainer components:

interface JiraCardContainerProps { $dragging: boolean; } const JiraCardContainer = tw.div<JiraCardContainerProps>` ... ${props => props.$dragging ? "opacity-30" : "opacity-100"} `; const JiraCard = ({ title, tags, id, user }: Props) => { const ref = useRef<HTMLDivElement>(null); const dragConfig: DraggableTargetEventHandlers = useMemo(() => ({ dragstart: ev => { if (ref.current && ev.dataTransfer) { ev.dataTransfer.effectAllowed = "move"; ev.dataTransfer.setData("jiraIssueId", id); } } }), [ref.current, id]); const dragging = useDraggableTarget(ref, dragConfig); return ( <JiraCardContainer key={id} ref={ref} $dragging={dragging}> ... </JiraCardContainer> ); }

Implementing drop in JiraIssueList component

To implement drop feature in JiraIssueList, we can use useDropTarget hook which needs a reference to the drop target element. We will create a reference to Container component and pass the same ref to the hook.

We need to update the state whenever user drops the card to the list. For that, we need to get access to the updateJiraIssueStatus function using JiraIssueUpdaterContext context, then we need to implement drop event handler and retrieve id from the event object and call the update function with id and new status of the card.

Also whenever user drags over an element to the target element, we are setting the border of the targer list to show something is being dragged over here. To implement that, we can use isDragOver boolean state returned by our custom hook and pass it to the Container styled component where we add class border-4 when isDragOver is true.

Here are the changes done for implementing drop feature:

interface ContainerProps { $isDraggingover: boolean; } const Container = tw.div<ContainerProps>` ... ${props => props.$isDraggingover ? "border-4" : "border-0"} `; const JiraIssueList = ({ status, issues }: Props) => { const ref = useRef<HTMLDivElement>(null); const updateJiraIssueStatus = useContext(JiraIssueUpdaterContext); const numberOfIssues = `${issues?.length ?? 0} ${(issues?.length ?? 0) > 1 ? "issues" : "issue"}`; const dropConfig: DropTargetEventHandlers = useMemo(() => ({ drop(event) { if (event.dataTransfer && updateJiraIssueStatus) { const jiraId = event.dataTransfer?.getData("jiraIssueId"); updateJiraIssueStatus(jiraId, status); } }, }), [status]); const isDragOver = useDropTarget(ref, dropConfig); const renderedIssues = useMemo(() => { return ... }, [issues]); return ( <Container key={status} ref={ref} $isDraggingover={isDragOver}> ... </Container> ); };

Conclusion

That's it 😇. I know it is a long post but we have gone through the whole process in depth: how to implement a React Component from scratch, how to design components, state management, custom hooks, and much more. It took me a lot of time to complete this 😅. You can check out the code here.

I hope you have enjoyed 🥳 reading this article and found it interesting and useful :) If so, share the post with your friends and coworkers :) .

Thanks and see you later !