Introduction
In today's article we are going to create a similar interface with applications like Trello or ClickUp. The idea is to create a foundation so that you can extend the functionality of the app.
At the end of the article you will have a result similar to the following:
What are we going to use?
Today we are not going to use many tools out of the ordinary, such as:
- Stitches - a css-in-js styling library with phenomenal development experience
- radash - a utility library that provides a set of functions that will help us deal with strings, objects and arrays
- @dnd-kit/core - it's the library we're going to use to implement dnd, it's intuitive, lightweight and it's the new kid on the block
Bear in mind that although these are the libraries used in this article, the same result is also easily replicable with others.
Prerequisites
To follow this tutorial, you need:
- Basic understanding of React
- Basic understanding of TypeScript
You won't have any problem if you don't know TypeScript, you can always "ignore" the data types, however in today's example it makes the whole process much easier.
Getting Started
As a first step, create a project directory and navigate into it:
yarn create vite react-dnd --template react-ts
cd react-dnd
Now we can install the necessary dependencies:
yarn add radash @dnd-kit/core @stitches/react @fontsource/anek-telugu
With the boilerplate generated, we can start working on the application, but before we create the necessary components we need to pay attention to a few things.
First, let's create two primitives that will form the basis of two important components of today's example. The first primitive will be the Droppable.tsx
, which is basically the area that will contain several elements that will be draggable.
// @/src/primitives/Droppable.tsx
import { FC, ReactNode, useMemo } from "react";
import { useDroppable } from "@dnd-kit/core";
interface IDroppable {
id: string;
children: ReactNode;
}
export const Droppable: FC<IDroppable> = ({ id, children }) => {
const { isOver, setNodeRef } = useDroppable({ id });
const style = useMemo(
() => ({
opacity: isOver ? 0.5 : 1,
}),
[isOver]
);
return (
<div ref={setNodeRef} style={style}>
{children}
</div>
);
};
The other primitive that we will need is the Draggable.tsx
that will be used in each of the elements that will be draggable.
// @/src/primitives/Draggable.tsx
import { FC, ReactNode, useMemo } from "react";
import { useDraggable } from "@dnd-kit/core";
interface IDraggable {
id: string;
children: ReactNode;
}
export const Draggable: FC<IDraggable> = ({ id, children }) => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id });
const style = useMemo(() => {
if (transform) {
return {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
};
}
return undefined;
}, [transform]);
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
};
You may have noticed that the two primitives we created have a prop in common, which is the id
. Obviously they reference the identifier, different use cases:
- In the case of
Droppable.tsx
the id corresponds to the stage of the task (backlog, in progress, etc). - While in
Draggable.tsx
it corresponds to the element id (can be an integer or uuid).
Now that we have the primitives we can work on the components that will use them. Starting with the simplest component, called DraggableElement.tsx
will be responsible for rendering the contents of the task.
// @/src/components/DraggableElement.tsx
import { FC, useMemo } from "react";
import { styled } from "@stitches/react";
import { Draggable } from "../primitives";
interface IDraggableElement {
identifier: string;
content: string;
}
export const DraggableElement: FC<IDraggableElement> = ({
identifier,
content,
}) => {
const itemIdentifier = useMemo(() => identifier, [identifier]);
return (
<Draggable id={itemIdentifier}>
<ElementWrapper>
<ElementText>{content}</ElementText>
</ElementWrapper>
</Draggable>
);
};
const ElementWrapper = styled("div", {
background: "#f6f6f6",
borderRadius: 10,
height: 120,
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
marginTop: 12,
});
const ElementText = styled("h3", {
fontSize: 18,
fontWeight: 600,
});
The last component we are going to create is the Column.tsx
, this will be responsible for rendering each of the elements that are associated with a specific task.
Speaking of this component, there is a very important element called <DropPlaceholder />
which is a sensor, that is, it is this element that detects if the element being grabbed goes to a specific column/stage.
// @/src/components/Column.tsx
import { FC, useMemo } from "react";
import { styled } from "@stitches/react";
import * as _ from "radash";
import { Droppable } from "../primitives";
import { DraggableElement } from "./DraggableElement";
export interface IElement {
id: string;
content: string;
column: string;
}
interface IColumn {
heading: string;
elements: IElement[];
}
export const Column: FC<IColumn> = ({ heading, elements }) => {
const columnIdentifier = useMemo(() => _.camal(heading), [heading]);
const amounts = useMemo(
() => elements.filter((elm) => elm.column === columnIdentifier).length,
[elements, columnIdentifier]
);
return (
<ColumnWrapper>
<ColumnHeaderWrapper variant={columnIdentifier as any}>
<Heading>{heading}</Heading>
<ColumnTasksAmout>{amounts}</ColumnTasksAmout>
</ColumnHeaderWrapper>
<Droppable id={columnIdentifier}>
{elements.map((elm, elmIndex) => (
<DraggableElement
key={`draggable-element-${elmIndex}-${columnIdentifier}`}
identifier={elm.id}
content={elm.content}
/>
))}
<DropPlaceholder />
</Droppable>
</ColumnWrapper>
);
};
const Heading = styled("h3", {
color: "#FFF",
});
const ColumnWrapper = styled("div", {
width: 320,
padding: 10,
border: "dashed",
borderWidth: 2,
borderRadius: 10,
});
const DropPlaceholder = styled("div", {
height: 35,
backgroundColor: "transparent",
marginTop: 15,
});
const ColumnHeaderWrapper = styled("div", {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
variants: {
variant: {
backlog: {
background: "#F94892",
},
inProgress: {
background: "#5800FF",
},
inReview: {
background: "#ffb300",
},
done: {
background: "#24A19C",
},
},
},
padding: "0px 10px 0px 10px",
borderRadius: 10,
});
const ColumnTasksAmout = styled("span", {
display: "flex",
justifyContent: "center",
alignItems: "center",
width: 30,
height: 30,
borderRadius: 6,
color: "#FFF",
background: "rgba( 255, 255, 255, 0.25 )",
boxShadow: "0 8px 32px 0 rgba( 255, 255, 255, 0.18 )",
backdropFilter: "blur(5px)",
border: "1px solid rgba( 255, 255, 255, 0.18 )",
});
Last but not least, in App.tsx
we will import the dependencies we need as well as define some variables and states related to the stages that we will have in the app.
Then we need to create a function called handleOnDragEnd()
that will receive in the arguments the data related to the element that was dragged. In order to update it's state (for example to change the stage of inProgress to inReview).
Finally, we can map each of the columns that correspond to each of the stages and we will pass the elements associated with them in the props of each of these columns.
import "@fontsource/anek-telugu";
import { useCallback, useState } from "react";
import { DndContext, DragEndEvent } from "@dnd-kit/core";
import { styled } from "@stitches/react";
import * as _ from "radash";
import { Column, IElement } from "./components";
const COLUMNS = ["Backlog", "In Progress", "In Review", "Done"];
export const DEFAULT_COLUMN = "backlog";
const DEFAULT_DATA_STATE: IElement[] = [
{
id: _.uid(6),
content: "Hello world 1",
column: DEFAULT_COLUMN,
},
{
id: _.uid(6),
content: "Hello world 2",
column: DEFAULT_COLUMN,
},
];
export const App = () => {
const [data, setData] = useState<IElement[]>(DEFAULT_DATA_STATE);
const handleOnDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
const elementId = active.id;
const deepCopy = [...data];
const updatedState = deepCopy.map((elm): IElement => {
if (elm.id === elementId) {
const column = over?.id ? String(over.id) : elm.column;
return { ...elm, column };
}
return elm;
});
setData(updatedState);
},
[data, setData]
);
return (
<DndContext onDragEnd={handleOnDragEnd}>
<MainWrapper>
{COLUMNS.map((column, columnIndex) => (
<Column
key={`column-${columnIndex}`}
heading={column}
elements={_.select(
data,
(elm) => elm,
(f) => f.column === _.camal(column)
)}
/>
))}
</MainWrapper>
</DndContext>
);
};
const MainWrapper = styled("div", {
display: "flex",
justifyContent: "space-evenly",
backgroundColor: "#fff",
paddingTop: 40,
paddingBottom: 40,
fontFamily: "Anek Telugu",
height: "90vh",
});
Conclusion
As usual, I hope you enjoyed the article and that it helped you with an existing project or simply wanted to try it out.
If you found a mistake in the article, please let me know in the comments so I can correct it. Before finishing, if you want to access the source code of this article, I leave here the link to the github repository.
Top comments (1)
Great article Francisco, thanks!. Not sure if its a typo on your end but
_camal(string)
from the rodash library seems to be undefined, but_camel(string)
seems to work to capitalise the string. Good read!.