React Drag & Drop Taskboard + Spring Boot 2 + PostgreSQL

May 02, 2021 No comments Spring Boot React Taskboard DragDrop

1. Introduction

In this article, we are going to present how to build React taskboard with Spring Boot and PostgreSQL database. For the Drag and Drop component, we will use one of the GitHub projects that will be adopted to our needs.

2. Spring Boot Project

Let's start with a Spring Boot project which will be responsible for providing REST API and communicating with the PostgreSQL database.

2.1. Maven dependencies

Spring Boot application will be a Maven project with the following dependencies in pom.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.frontbackend.springboot</groupId>
    <artifactId>react-drag-and-drop-taskboard-spring-boot2-postgresql</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <!-- Inherit defaults from Spring Boot -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.1.RELEASE</version>
    </parent>

    <!-- Add typical dependencies for a web application -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <!-- Package as an executable jar -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Used dependencies:

  • spring-boot-starter-web - Spring Boot web container - Tomcat by default,
  • spring-boot-starter-data-jpa - Spring Data for JPA,
  • postgresql - PostgreSQL driver, for connection with the database.

2.2. Project structure

Spring Boot project has the following structure:

├── main
│   ├── java
│   │   └── com
│   │       └── frontbackend
│   │           └── springboot
│   │               ├── Application.java
│   │               ├── controller
│   │               │   └── TaskController.java
│   │               ├── exception
│   │               │   └── TaskNotFoundException.java
│   │               ├── model
│   │               │   ├── Status.java
│   │               │   ├── Task.java
│   │               │   └── TaskRequest.java
│   │               ├── repository
│   │               │   └── TaskRepository.java
│   │               └── service
│   │                   └── TaskService.java
│   └── resources
│       └── application.properties

In the structure we could list the following classes:

  • Application - the main Spring Boot class for starting web container,
  • TaskController - REST controller class that will handle all HTTP requests,
  • TaskNotFoundException - an exception thrown when task with specific id will not be found in db,
  • Status - enum with available task statues (To Do, In Progress, Done),
  • Task - task object with fields like title, description, position, status and id,
  • TaskRequest - object used for communication with the frontend,
  • TaskRepository - repository responsible for performing queries in database,
  • TaskService - class that contains logic use for managing tasks,
  • application.properties - main Spring Boot confiuration file.

2.3. Model

In the model layer we have three objects:

  • Task - main object that represents task,
  • Status - task status,
  • TaskRequest - task used for HTTP requests from frontend (this object will be converted from/to JSON).
package com.frontbackend.springboot.model;

import org.hibernate.annotations.GenericGenerator;

import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(generator = "uuid")
    @GenericGenerator(name = "uuid", strategy = "uuid2")
    private String id;

    private String title;

    private String description;

    @Enumerated(EnumType.STRING)
    private Status status;

    private Long position;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Status getStatus() {
        return status;
    }

    public void setStatus(Status status) {
        this.status = status;
    }

    public Long getPosition() {
        return position;
    }

    public void setPosition(Long position) {
        this.position = position;
    }
}

In the Task class we used specific annotations for a field that holding identifier:

@Id
@GeneratedValue(generator = "uuid")
@GenericGenerator(name = "uuid", strategy = "uuid2")

When the field will be empty engine will fill them with a new UUID.

The Status enum has the following values:

package com.frontbackend.springboot.model;

import com.fasterxml.jackson.annotation.JsonProperty;

public enum Status {

    @JsonProperty("To Do")
    TODO("To Do"),

    @JsonProperty("In Progress")
    IN_PROGRESS("In Progress"),

    @JsonProperty("Done")
    DONE("Done");

    private final String title;

    Status(String title) {
        this.title = title;
    }

    public String getTitle() {
        return this.title;
    }
}

The TaskRequest will be converted to JSON and in that form will be sent to/from REST API:

package com.frontbackend.springboot.model;

public class TaskRequest {

    private String id;
    private String title;
    private String description;
    private Status status;
    private Long position;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public Status getStatus() {
        return status;
    }

    public void setStatus(Status status) {
        this.status = status;
    }

    public Long getPosition() {
        return position;
    }

    public void setPosition(Long position) {
        this.position = position;
    }
}

2.4. Spring Rest Controller

The TaskController will provide API with the following endpoints:

URL Method Action
/tasks GET Get list of all created Tasks
/tasks POST Create new Task
/tasks/position POST Change the task position or/and status
/tasks/{id} PUT Update Task data
/tasks/{id} DELETE Delete Task

The class that handles all HTTP requests for /tasks endpoint has the following structure:

package com.frontbackend.springboot.controller;

import java.util.List;

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.frontbackend.springboot.model.Task;
import com.frontbackend.springboot.model.TaskRequest;
import com.frontbackend.springboot.service.TaskService;

@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @PutMapping("{id}")
    public Task update(@PathVariable("id") String id, @RequestBody TaskRequest taskRequest) {
        return taskService.update(taskRequest);
    }

    @PostMapping
    public Task create(@RequestBody TaskRequest taskRequest) {
        return taskService.create(taskRequest);
    }

    @PostMapping("/position")
    public void changePosition(@RequestBody TaskRequest taskRequest) {
        taskService.changePosition(taskRequest);
    }

    @DeleteMapping("{id}")
    public void delete(@PathVariable String id) {
        taskService.delete(id);
    }

    @GetMapping
    public List<Task> list() {
        return taskService.getAll();
    }
}

2.5. Repository

The TaskRepository extends JpaRepository and contains some additional methods:

package com.frontbackend.springboot.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import com.frontbackend.springboot.model.Status;
import com.frontbackend.springboot.model.Task;

@Repository
public interface TaskRepository extends JpaRepository<Task, String> {

    Long countTasksByStatus(Status status);

    @Transactional
    @Modifying
    @Query("UPDATE Task SET position = position + 1 WHERE position >= :position AND status = :status AND id <> :id")
    void incrementBelow(@Param("position") Long position, @Param("status") Status status, @Param("id") String id);

    @Transactional
    @Modifying
    @Query("UPDATE Task SET position = position - 1 WHERE position >= :position AND status = :status AND id <> :id")
    void decrementBelow(@Param("position") Long position, @Param("status") Status status, @Param("id") String id);

    @Transactional
    @Modifying
    @Query("UPDATE Task SET position = position + 1 WHERE position >= :newPosition AND position < :oldPosition AND status = :status AND id <> :id")
    void incrementBelowToPosition(@Param("newPosition") Long newPosition, @Param("oldPosition") Long oldPosition, @Param("status") Status status,
            @Param("id") String id);

    @Transactional
    @Modifying
    @Query("UPDATE Task SET position = position - 1 WHERE position <= :newPosition AND position > :oldPosition AND status = :status AND id <> :id")
    void decrementAboveToPosition(@Param("newPosition") Long newPosition, @Param("oldPosition") Long oldPosition, @Param("status") Status status,
            @Param("id") String id);
}

Provided methods:

  • countTasksByStatus - used for counting all tasks in a specific status, this is used when creating a new Task - it should be the last position in a specific board,
  • incrementBelow - method increments position for all tasks below the current one starting from the current position,
  • decrementBelow - method decrements position for all tasks below the current one starting from the current position,
  • incrementBelowToPosition - method increment position for all tasks to specified position,
  • decrementAboveToPosition - method decrement position for all tasks to specified position,

Why we need those methods will be explained in the next point.

2.6. Service

The TaskService contains the logic required for managing Tasks:

package com.frontbackend.springboot.service;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.frontbackend.springboot.exception.TaskNotFoundException;
import com.frontbackend.springboot.model.Status;
import com.frontbackend.springboot.model.Task;
import com.frontbackend.springboot.model.TaskRequest;
import com.frontbackend.springboot.repository.TaskRepository;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public Task create(TaskRequest taskRequest) {
        Task task = new Task();
        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        task.setStatus(Status.TODO);
        task.setPosition(taskRepository.countTasksByStatus(Status.TODO));

        return taskRepository.save(task);
    }

    @Transactional
    public void changePosition(TaskRequest taskRequest) {
        Task task = taskRepository.findById(taskRequest.getId())
                                  .orElseThrow(TaskNotFoundException::new);

        Long oldPosition = task.getPosition();
        Long newPosition = taskRequest.getPosition();
        Status oldStatus = task.getStatus();

        if (oldStatus.equals(taskRequest.getStatus())) {
            if (newPosition > oldPosition) {
                taskRepository.decrementAboveToPosition(newPosition, oldPosition, oldStatus, task.getId());
            } else {
                taskRepository.incrementBelowToPosition(newPosition, oldPosition, oldStatus, task.getId());
            }

            task.setPosition(taskRequest.getPosition());
            taskRepository.save(task);
        } else {
            Status newStatus = taskRequest.getStatus();

            taskRepository.decrementBelow(task.getPosition(), oldStatus, task.getId());
            taskRepository.incrementBelow(taskRequest.getPosition(), newStatus, task.getId());

            task.setPosition(taskRequest.getPosition());
            task.setStatus(taskRequest.getStatus());
            taskRepository.save(task);
        }
    }

    @Transactional
    public Task update(TaskRequest taskRequest) {
        Task task = taskRepository.findById(taskRequest.getId())
                                  .orElseThrow(TaskNotFoundException::new);
        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        return taskRepository.save(task);
    }

    @Transactional
    public void delete(String id) {
        Task task = taskRepository.findById(id)
                                  .orElseThrow(TaskNotFoundException::new);
        taskRepository.decrementBelow(task.getPosition(), task.getStatus(), task.getId());
        taskRepository.deleteById(id);
    }

    public List<Task> getAll() {
        return taskRepository.findAll();
    }
}

Let's consider 3 scenarios:

1) We want to move Task from one board to another,

In the first scenario we want to move Task 2 from To Do to In Progress board:

Initial state:

To Do In Progress Done
Task 1 (pos 0)
Task 2 (pos 1)
Task 3 (pos 2)

The final result:

To Do In Progress Done
Task 1 (pos 0) Task 2 (pos 0)
Task 3 (pos 1)

In order to achive this we need to decrement positions for all tasks in new board (status) below the new position of a task that has been moved. We must also increment positions for all tasks below old position of a task to move the whole group up:

taskRepository.decrementBelow(task.getPosition(), oldStatus, task.getId());
taskRepository.incrementBelow(taskRequest.getPosition(), newStatus, task.getId());

2) We want to move Task to a different position on the same board,

Initial state:

To Do In Progress Done
Task 1 (pos 0)
Task 2 (pos 1)
Task 3 (pos 2)

The final result:

To Do In Progress Done
Task 3 (pos 0)
Task 1 (pos 1)
Task 2 (pos 2)

In this scenario first we need to check if user moved Task up or down the list:

if (newPosition > oldPosition) {
    taskRepository.decrementAboveToPosition(newPosition, oldPosition, oldStatus, task.getId());
} else {
    taskRepository.incrementBelowToPosition(newPosition, oldPosition, oldStatus, task.getId());
}

When new position is lower then original we used decrementAboveToPosition method otherwise we used incrementBelowToPosition.

3) We want to remove Task.

Initial state:

To Do In Progress Done
Task 1 (pos 0)
Task 2 (pos 1)
Task 3 (pos 2)

The final result:

To Do In Progress Done
Task 2 (pos 0)
Task 3 (pos 1)

After removing Task we need to move all below tasks up on the list using decrementBelow method. We decrement the position of all tasks below the current one that will be removed.

2.7. Configuration

In the configuration file we used a data source that points to the PostgreSQL database:

spring.datasource.url=jdbc:postgresql://localhost:5432/test
spring.datasource.username=postgres
spring.datasource.password=password

spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

spring.jpa.hibernate.ddl-auto=create

In order to recreate database after the restart of an application we used the following property: spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true

3. React project

For Drag and Drop TaskBoard, we used an existing GitHub project that used local storage for persisting Tasks.

The first step is to clone this project using the following command:

git clone https://github.com/onderonur/drag-and-drop-taskboard.git

We will adopt this project to our needs.

3.1. Model

In the model layer we add position and status for TaskboardItem object:

export interface TaskboardItem {
  id: string;
  title: string;
  description: string;
  position: number;
  status: TaskboardItemStatus;
}

export enum TaskboardItemStatus {
  TO_DO = 'To Do',
  IN_PROGRESS = 'In Progress',
  DONE = 'Done',
}


export type TaskboardData = Record<TaskboardItemStatus, TaskboardItem[]>;

3.2. Service

In order to communicate with Spring Boot REST API we provide a list of HTTP requests using axios library:

import axios from 'axios';
import { TaskboardItem } from './TaskboardTypes';

const BASE_URL = 'http://localhost:8080';

export const createTask = (task: TaskboardItem) => axios.post<TaskboardItem>(`${BASE_URL}/tasks`, task);

export const getAllTasks = () => axios.get<TaskboardItem[]>(`${BASE_URL}/tasks`);

export const changeTaskPosition = (task: TaskboardItem) => axios.post(`${BASE_URL}/tasks/position`, task);

export const deleteTask = (id: string) => axios.delete(`${BASE_URL}/tasks/${id}`);

export const updateTask = (task: TaskboardItem) => axios.put<TaskboardItem>(`${BASE_URL}/tasks/${task.id}`, task);

3.3. Taskboard

The Taskboard is now executing HTTP requests and processing responses, in addition to simple persising Taks in the localstorage:

import { DragDropContext, DragDropContextProps } from 'react-beautiful-dnd';
import React, { useEffect, useMemo, useState } from 'react';
import produce from 'immer';
import styled from 'styled-components';
import { TaskboardData, TaskboardItem, TaskboardItemStatus } from './TaskboardTypes';
import TaskboardItemFormModal, { TaskboardItemFormValues, } from './TaskboardItemFormModal';
import TaskboardCol, { TaskboardColProps } from './TaskboardCol';
import { useSyncedState } from '../shared/SharedHooks';
import { changeTaskPosition, createTask, deleteTask, getAllTasks, updateTask } from './TaskService';

const TaskboardRoot = styled.div`
  min-height: 0;
  height: 100%;
  min-width: 800px;
  max-width: 1400px;
  margin: auto;
`;

const TaskboardContent = styled.div`
  height: 100%;
  padding: 0.5rem;
  display: flex;
  justify-content: space-around;
`;

const defaultItems = {
  [TaskboardItemStatus.TO_DO]: [],
  [TaskboardItemStatus.IN_PROGRESS]: [],
  [TaskboardItemStatus.DONE]: [],
};

function Taskboard() {
  const [ itemsByStatus, setItemsByStatus ] = useSyncedState<TaskboardData>(
    'itemsByStatus',
    defaultItems
  );

  useEffect(() => {
    getAllTasks().then(response => {
      setItemsByStatus({
        [TaskboardItemStatus.TO_DO]: response.data.filter(task => task.status == TaskboardItemStatus.TO_DO),
        [TaskboardItemStatus.IN_PROGRESS]: response.data.filter(task => task.status == TaskboardItemStatus.IN_PROGRESS),
        [TaskboardItemStatus.DONE]: response.data.filter(task => task.status == TaskboardItemStatus.DONE),
      });
    });
  }, []);

  const handleDragEnd: DragDropContextProps['onDragEnd'] = ({
                                                              source,
                                                              destination,
                                                            }) => {
    if (destination) {
      const task = itemsByStatus[source.droppableId as TaskboardItemStatus][source.index];

      changeTaskPosition({
        id: task.id,
        status: destination.droppableId as TaskboardItemStatus,
        position: destination.index,
        title: task.title,
        description: task.description
      }).then(result => {

        setItemsByStatus((current) =>
          produce(current, (draft) => {
            // dropped outside the list
            if (!destination) {
              return;
            }
            const [ removed ] = draft[
              source.droppableId as TaskboardItemStatus
              ].splice(source.index, 1);
            draft[destination.droppableId as TaskboardItemStatus].splice(
              destination.index,
              0,
              removed
            );
          })
        );

      }).catch(error => console.error(error));
    }
  };

  const [ isModalVisible, setIsModalVisible ] = useState(false);

  const [ itemToEdit, setItemToEdit ] = useState<TaskboardItem | null>(null);

  const openTaskItemModal = (itemToEdit: TaskboardItem | null) => {
    setItemToEdit(itemToEdit);
    setIsModalVisible(true);
  };

  const closeTaskItemModal = () => {
    setItemToEdit(null);
    setIsModalVisible(false);
  };

  const handleDelete: TaskboardColProps['onDelete'] = ({
                                                         status,
                                                         itemToDelete,
                                                       }) => {

    deleteTask(itemToDelete.id).then(result => {
      setItemsByStatus((current) =>
        produce(current, (draft) => {
          draft[status] = draft[status].filter(
            (item) => item.id !== itemToDelete.id
          );
        })
      );
    }).catch(error => console.error(error));
  };

  const initialValues = useMemo<TaskboardItemFormValues>(
    () => ({
      title: itemToEdit?.title ?? '',
      description: itemToEdit?.description ?? '',
    }),
    [ itemToEdit ]
  );

  return (
    <>
      <DragDropContext onDragEnd={handleDragEnd}>
        <TaskboardRoot>
          <TaskboardContent>
            {Object.values(TaskboardItemStatus).map((status) => (
              <TaskboardCol
                key={status}
                status={status}
                items={itemsByStatus[status]}
                onClickAdd={
                  status === TaskboardItemStatus.TO_DO
                    ? () => openTaskItemModal(null)
                    : undefined
                }
                onEdit={openTaskItemModal}
                onDelete={handleDelete}
              />
            ))}
          </TaskboardContent>
        </TaskboardRoot>
      </DragDropContext>
      <TaskboardItemFormModal
        visible={isModalVisible}
        onCancel={closeTaskItemModal}
        onOk={(values) => {

          if (itemToEdit) {
            updateTask({
              ...values,
              status: itemToEdit.status,
              position: itemToEdit.position,
              id: itemToEdit.id
            }).then(result => {

              setItemsByStatus((current) =>
                produce(current, (draft) => {
                  if (itemToEdit) {
                    // Editing existing item
                    const draftItem = Object.values(draft)
                      .flatMap((items) => items)
                      .find((item) => item.id === itemToEdit.id);
                    if (draftItem) {
                      draftItem.title = values.title;
                      draftItem.description = values.description;
                    }
                  }
                })
              );

            }).catch(error => console.error(error));

          } else {
            createTask({
              ...values,
              status: TaskboardItemStatus.TO_DO,
              position: 1,
              id: ''
            }).then(response => {

              setItemsByStatus((current) =>
                produce(current, (draft) => {
                  // Adding new item as "to do"
                  draft[TaskboardItemStatus.TO_DO].push({
                    ...values,
                    id: response.data.id,
                    status: TaskboardItemStatus.TO_DO,
                    position: response.data.position
                  });
                }));

            }).catch(error => console.error(error));
          }

        }}
        initialValues={initialValues}
      />
    </>
  );
}

export default Taskboard;

4. Starting React and Spring Boot applications

To start React application we need to use the following command:

npm start

To start Spring Boot we need to use one of the following:

mvn spring-boot:run

or

java -jar target/react-drag-and-drop-taskboard-spring-boot2-postgresql-0.0.1-SNAPSHOT.jar

5. Demo

5.1. Creating tasks

React taskboard spring boot2 postgresql creating tasks

Changes on the database:

React taskboard spring boot2 postgresql after saving tasks

5.2. Move task

React taskboard spring boot2 postgresql move task

Changes on the database:

React taskboard spring boot2 postgresql after moved

5.3. Remove task

React taskboard spring boot2 postgresql removing task

Changes on the database:

React taskboard spring boot2 postgresql after removing

6. Conclusion

In this article, we presented React Drag&Drop Taskboard application with Spring Boot and PostgreSQL database to persist tasks.

As usual, code shown in this article is available on our GitHub repository.

{{ message }}

{{ 'Comments are closed.' | trans }}