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.
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.
- Install react-beautiful-dnd into your React app. I'm actually using a GatsbyJS app because that's what I use for this blog.
- Install your Graphql client of choice. I'm using @apollo/client with graphql-tag.
- 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 fileimport 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 pageimport "./style.css"// Function to reorder tasks in the UIconst reorder = (list, startIndex, endIndex) => {// copy the list to a new array "result"const result = [...list]// remove object from "result" arrayconst [removed] = result.splice(startIndex, 1)// update "position" property of removed objectvar newPosition = endIndex + 1const updatedRemoved = { ...removed, position: newPosition }// insert "removed"object back into array in updated placeresult.splice(endIndex, 0, updatedRemoved)// create new array newOrder with updated "position" property in each objectconst newOrder = result.map((res, index) => {const newPos = index + 1return { ...res, position: newPos }})// return the new arrayreturn 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. */}<Cardref={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 uploadsconst 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 tasksconst 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 loadconst uploadedTasks = data.tasksconst 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 updatesuseEffect(() => {if ((newPosition && taskId) || (newPosition === 0 && taskId))// setIsUpdating to true as a stop to disallow further changes until after the mutation completessetIsUpdating(true)repositionTask()}, [newPosition])// Mutation called by the useEffect hook to update the database after taskId, position, and completed state are updatedconst [repositionTask] = useMutation(REPOSITION_TASK_MUTATION, {variables: {taskId: taskId,position: newPosition,completed: isCompleted,},onCompleted: data => {// clean up statesetNewPosition(null)setTaskId(null)// Now we can make more changessetIsUpdating(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 Respondersfunction 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 statesetIsCompleted(true)// Remove the completed task from the listconst filteredTasks = tasks.filter(t => t.id != taskId)// Arrange the remaining tasks in the correct orderconst newFilteredOrder = filteredTasks.map((res, index) => {const newPos = index + 1return { ...res, position: newPos }})// Set the tasks state with updated listsetTasks(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 detailssetNewPosition(0)}if (result.destination.droppableId === "list") {// We are moving tasks up and down to different positions here// See the reorder function aboveconst 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 arrayconst 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 anarea for dropping. We have two Droppable areas, each with a separatedroppableId. The "completed" area is for dropping completed tasks.The "list* area is for rearranging list items. */}<Droppable droppableId="completed">{(provided, snapshot) => (<divref={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 {idtitlecompletedposition}}`// 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 {idtitlepositioncompleted}}}`export default TaskListDemoconst 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