Drag & Drop List for React Apollo Graphql API

Project | Jul 1st, 2021

With or without a guide, it almost always helps to stay organized while you're out exploring. Task lists are an essential means for many of us to manage our priorities and get things done.

In this post, I'll demonstrate and guide you through the code for a simple sortable task list using react-beautiful-dnd. We'll build this simple single-column example. Drag the boxes around to reorder them. Drop them in the top box and they will disappear. Drop them all in the box? Refresh the page and the demo list will re-appear.

Drag Here to Complete Task
Code a new app
Position: 1
Write a blog post
Position: 2
Plan my next trip
Position: 3
Explore the neighborhood
Position: 4
Make a new friend
Position: 5
Call Home
Position: 6
While this post is about creating a full-stack database connected list, the above example drag-and-drop list is not actually hooked up to any backend. What you see here is all React. This is because the blog you are reading is a static site created with GatsbyJS and has no backend server. But, the code below will show you how to use it with React Apollo hooks so you can update your database with each drag and drop.

In addition to task management, drag-and-drop list management like this is useful for a wide range of applications, including:

  • Task lists
  • Kanban style project management
  • User configurable dashboards and workspaces
  • Product configuration in ecommerce (think drag-and-drop pizza toppings)
  • Shopping lists
  • Registration and scheduling, like picking what artists to check out at a music festival or selecting a seminar schedule at a trade show

Time to DIY

If you haven't already, take a look at the backend Python code for reording our tasks server side. You don't need to use it, but it can help to see what's happening on the backend.

Let's go ahead and get started. Since you managed to find this post, I'll assume you've done this sort of thing before.

But, if you're unfamiliar with graphql and/or React Apollo you might find it easier to start by skipping the @apollo/client part. To do this, comment out the @apollo/client and graphql-tag imports. Also, comment out the useQuery, useMutation, useEffect and gql functions. I've included some dummy data, so you should at least be able to move things around in the browser without interacting with the back end.

  1. Install react-beautiful-dnd into your React app. I'm actually using a GatsbyJS app because that's what I use for this blog.
  2. Install your Graphql client of choice. I'm using @apollo/client with graphql-tag.
  3. Hook up your favorite UI library. I'm a frequent MaterialUI user, but today I'll keep it simple with Styled Components.

Show me the code

javascript
// TaskListDemo.js
// React jsx file
import React, { useState, useEffect } from "react"
import { useQuery, useMutation } from "@apollo/client"
import gql from "graphql-tag"
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"
import styled from "styled-components"
// css file content is way down at the bottom of this page
import "./style.css"
// Function to reorder tasks in the UI
const reorder = (list, startIndex, endIndex) => {
// copy the list to a new array "result"
const result = [...list]
// remove object from "result" array
const [removed] = result.splice(startIndex, 1)
// update "position" property of removed object
var newPosition = endIndex + 1
const updatedRemoved = { ...removed, position: newPosition }
// insert "removed"object back into array in updated place
result.splice(endIndex, 0, updatedRemoved)
// create new array newOrder with updated "position" property in each object
const newOrder = result.map((res, index) => {
const newPos = index + 1
return { ...res, position: newPos }
})
// return the new array
return newOrder
}
const Task = ({ task, index }) => {
return (
// Draggable is a react-beautiful-dnd wrapper that makes the contained element draggable throughout DragDropContext
<Draggable draggableId={task.id} index={index}>
{provided => (
{/* Card and CardHeader are defined by styled-components (see below). Feel free to modify the CSS to make it your own. */}
<Card
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<CardHeader>{task.title}</CardHeader>
{/* The task.position prop allows us to reorder the tasks */}
<CardContent>Position: {task.position}</CardContent>
</Card>
)}
</Draggable>
)
}
// Note that DropZone is a made up name I created with styled-components.
// Not to be confused with react-dropzone, which is an awesome react hook for dnd file uploads
const CompleteTask = React.memo(function CompleteTask({ task, tasks }) {
return (
<DropZone>
{tasks && tasks.length > 0
? "Drag Here to Complete Task"
: "Congrats! No more tasks!"}
</DropZone>
)
})
// Literally what it looks like - a list of tasks
const TaskList = React.memo(function TaskList({ tasks }) {
return tasks.map((task, index) => (
<Task task={task} index={index} key={task.id} />
))
})
const TaskListDemo = () => {
const [taskId, setTaskId] = useState(null)
const [selTask, setSelTask] = useState("")
const [newPosition, setNewPosition] = useState(null)
const [isUpdating, setIsUpdating] = useState(false)
const [isCompleted, setIsCompleted] = useState(false)
// Start with this array of task objects for testing or if you don't want to use external data. Otherwise, set it as an empty array [].
const [tasks, setTasks] = useState([
{ id: "1", title: "Code a new app", completed: false, position: 1 },
{ id: "2", title: "Write a blog post", completed: false, position: 2 },
{ id: "3", title: "Plan my next trip", completed: false, position: 3 },
{ id: "4", title: "Explore the 'hood", completed: false, position: 4 },
{ id: "5", title: "Make a new friend", completed: false, position: 5 },
{ id: "6", title: "Call Home", completed: false, position: 6 },
])
// The useQuery hook to query your database.
const { data, loading, error } = useQuery(GET_TASKS_QUERY, {
onCompleted: data => {
// sort queried tasks by position on initial query at page load
const uploadedTasks = data.tasks
const sortedTasks = uploadedTasks.slice().sort(function (a, b) {
return a.position - b.position
})
// set the tasks state (example data is prefilled above)
setTasks(sortedTasks)
},
})
// The useEffect hook is triggered when the newPosition state changes. To prevent it from triggering on page load, it conditionally calls repositionTask() if there is a taskId. The second arguement is necessary because completed tasks have a new position of 0
// The useEffect hook creates an asynchronous order of operations, where the repositionTask() function won't be called until after the critical state updates
useEffect(() => {
if ((newPosition && taskId) || (newPosition === 0 && taskId))
// setIsUpdating to true as a stop to disallow further changes until after the mutation completes
setIsUpdating(true)
repositionTask()
}, [newPosition])
// Mutation called by the useEffect hook to update the database after taskId, position, and completed state are updated
const [repositionTask] = useMutation(REPOSITION_TASK_MUTATION, {
variables: {
taskId: taskId,
position: newPosition,
completed: isCompleted,
},
onCompleted: data => {
// clean up state
setNewPosition(null)
setTaskId(null)
// Now we can make more changes
setIsUpdating(false)
setIsCompleted(false)
},
})
// The DragDropContext uses "Responders", events that allow you to perform your own state updates.
// In this example, I'm only using onDragStart and onDragEnd
// See the documentation for a complete list of Responders
function onDragStart(result) {
const selectedTask = tasks[result.source.index]
setSelTask(selectedTask)
const selectedTaskId = parseInt(selectedTask.id)
setTaskId(selectedTaskId)
}
function onDragEnd(result) {
if (!result.destination) {
return
}
if (
result.destination.index === result.source.index &&
result.destination.droppableId === result.source.droppableId
) {
return
}
if (isUpdating) {
return
}
if (result.destination.droppableId === "completed") {
// Task is completed, so update isCompleted state
setIsCompleted(true)
// Remove the completed task from the list
const filteredTasks = tasks.filter(t => t.id != taskId)
// Arrange the remaining tasks in the correct order
const newFilteredOrder = filteredTasks.map((res, index) => {
const newPos = index + 1
return { ...res, position: newPos }
})
// Set the tasks state with updated list
setTasks(newFilteredOrder)
// This will trigger the useEffect hook and give the completed task a position of 0.
// In my database, all completed tasks have a position of 0
// See the python end for further details
setNewPosition(0)
}
if (result.destination.droppableId === "list") {
// We are moving tasks up and down to different positions here
// See the reorder function above
const updatedTasks = reorder(
tasks,
result.source.index,
result.destination.index
)
// I parseInt here because position comes in as a string in query
// Find and set the position of the task you grabbed and moved
// Set tasks array with the reordered array
const updatedPosition = parseInt(
updatedTasks.find(t => t.id == taskId).position
)
setTasks(updatedTasks)
setNewPosition(updatedPosition)
}
}
return (
// Wrap your main component in DragDropContext. It will not work without this.
<DragDropContext onDragEnd={onDragEnd} onDragStart={onDragStart}>
{/* Droppable is a react-beautiful-dnd component that defines an
area for dropping. We have two Droppable areas, each with a separate
droppableId. The "completed" area is for dropping completed tasks.
The "list* area is for rearranging list items. */}
<Droppable droppableId="completed">
{(provided, snapshot) => (
<div
ref={provided.innerRef}
style={{
backgroundColor: snapshot.isDraggingOver ? "#1a1805" : "",
maxHeight: "100px",
maxWidth: "100%",
padding: "0px",
}}
{...provided.droppableProps}
>
<CompleteTask task={selTask} tasks={tasks} />
{provided.placeholder}
</div>
)}
</Droppable>
<Droppable droppableId="list">
{provided => (
<div ref={provided.innerRef} {...provided.droppableProps}>
<TaskList tasks={tasks} taskId={taskId} />
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)
}
// GraphQl query carried out by the useQuery hook when the page loads.
const GET_TASKS_QUERY = gql`
query {
tasks {
id
title
completed
position
}
}
`
// GraphQl mutation carried out by the useMutation hook.
const REPOSITION_TASK_MUTATION = gql`
mutation($taskId: Int!, $position: Int!, $completed: Boolean) {
positionTask(taskId: $taskId, position: $position, completed: $completed) {
task {
id
title
position
completed
}
}
}
`
export default TaskListDemo
const DropZone = styled.div`
margin: 1rem 0;
height: 100px;
padding: 2rem;
text-align: center;
border: 2px dashed var(--color-text-light);
`
const Card = styled.div`
margin: 1rem;
padding: 0.5rem;
text-align: center;
background: var(--color-quote-background);
border: 2px solid var(--color-primary);
`
const CardContent = styled.div`
color: var(--color-primary);
margin: 0.5rem;
`
const CardHeader = styled.div`
color: var(--color-primary);
line-height: 1;
word-spacing: 3px;
font-size: var(--fontSize-4);
margin-top: 0.5rem;
}
`
css
/* style.css */
/* CSS Custom Properties Definitions */
:root {
--fontSize-0: 0.833rem;
--fontSize-1: 1rem;
--fontSize-2: 1.2rem;
--fontSize-3: 1.44rem;
--fontSize-4: 1.728rem;
--fontSize-5: 1.95rem;
--fontSize-6: 2.488rem;
--fontSize-7: 2.986rem;
--color-primary: #b4dcfd;
--color-text: #cfcfcf;
--color-text-light: #67874e;
--color-heading: #ba834c;
--color-heading-hover: #c99159;
--color-heading-black: #7b9958;
--color-accent: #1e1e1e;
--color-link-hover: #efc92a;
--color-quote-background: #333333;
}

Conclusion

Knowing how to set this up and use it is a solid base for building many kinds of powerful and useful apps. Try thinking beyond task lists. How would you use it in a way that would work specifically for your unique purposes? How could you use this within an app that would help others?

Discuss both frontend and backend posts together here at reddit.

Tags

Topics tagged in this post


Profile picture

Written by brian kerr
"Existentialism Is a Cyborgism"
About Guide Projects || Topics || Glossary || Reddit || Github