Spring Boot, Vue.js, and PrimeVue - Building a CRUD Todo App

In this section, we will build a Todo CRUD app with Spring Boot, Vue.js, PrimeVue, and PostgreSQL.

Technologies Used

  • Java 17
  • Spring Boot 3.3.0
  • Spring Data JPA
  • Lombok
  • PostgreSQL
  • Vue 3
  • PrimeVue

After completing this tutorial what we will build?

We will build a full-stack web application that is a basic Todo Management Application with CRUD features: 

  • Create Todo 
  • List Todo 
  • Update Todo 
  • Delete Todo 

1. Backend development

First, open the Spring initializr https://start.spring.io

Then, Provide the Group and Artifact name. We have provided Group name com.knf.dev.demo and Artifact todo-service. Here I selected the Maven project - language Java 17 - Spring Boot 3.3.0, Spring Web, PostgreSQL Driver, Spring Data JPA, and Lombok.

Then, click on the Generate button. When we click on the Generate button, it starts packing the project in a .zip(todo-service) file and downloads the project. Then, Extract the Zip file. 

Then, import the project on your favourite IDE. 

Final Project Directory


Complete pom.xml

POM is an XML file that contains information about the project and configuration details utilized by Maven to build the project. It contains default values for most projects. More Info

<?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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.knf.dev.demo</groupId>
<artifactId>todo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>todo-service</name>
<description>Demo project for Spring Boot, Vue.js, and Primevue</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

application.yaml

Let’s configure Spring Boot to use PostgreSQL as our data source. We are simply adding PostgreSQL database URL, username, and password in the src/main/resources/application.yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: root
jpa:
hibernate:
ddl-auto: update # Hibernate ddl auto (create, create-drop, validate, update),production set to none or comment it

server:
port: 8090

Spring Data JPA – Todo Entity

A Todo object as JPA entity.
package com.knf.dev.demo.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Todo {

@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private long id;

@Column(name = "firstname")
private String firstName;

private String email;

@Column(name = "lastname")
private String lastName;

private String gender;
}

Spring Data JPA – Todo Repository

JpaRepository is a JPA-specific extension of Repository. It contains an API for basic CRUD operations and also API for pagination and sorting.
package com.knf.dev.demo.repository;

import com.knf.dev.demo.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TodoRepository extends JpaRepository<Todo,Long> {
}

Create Custom Error Response

package com.knf.dev.demo.exception;

import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;

import java.time.LocalDateTime;

@Data
public class CustomErrorResponse {

@JsonFormat(shape = JsonFormat.Shape.STRING,
pattern = "yyyy-MM-dd hh:mm:ss")
private LocalDateTime timestamp;
private int status;
private String error;

}

Create ResourceNotFoundException

package com.knf.dev.demo.exception;

public class ResourceNotFoundException extends RuntimeException{

private static final long serialVersionUID = 1L;

public ResourceNotFoundException(String message) {
super(message);
}
}

Create GlobalExceptionHandler

@ControllerAdvice allows us to handle exceptions across the whole application, not just to an individual controller. The @ExceptionHandler is an annotation used to handle the specific exceptions and sending the custom responses to the client.
package com.knf.dev.demo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;

import java.time.LocalDateTime;

@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<CustomErrorResponse> globalExceptionHandler
(Exception ex, WebRequest request) {

CustomErrorResponse errors = new CustomErrorResponse();
errors.setTimestamp(LocalDateTime.now());
errors.setError(ex.getMessage());
errors.setStatus(HttpStatus.NOT_FOUND.value());

return new ResponseEntity<>(errors, HttpStatus.NOT_FOUND);
}
}

Create Todo Controller

Using the @CrossOrigin annotation to enable cross-origin calls.

import com.knf.dev.demo.entity.Todo;
import com.knf.dev.demo.exception.ResourceNotFoundException;
import com.knf.dev.demo.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@CrossOrigin(origins = "*")
@RestController
@RequestMapping("/todos")
@RequiredArgsConstructor
public class TodoController {

private final TodoRepository todoRepository;


@GetMapping
public ResponseEntity<List<Todo>> getAllTdo() {
return ResponseEntity.ok(todoRepository.findAll());
}

@GetMapping("/{id}")
public ResponseEntity<Todo> getTodoById(@PathVariable(value = "id")
Long id) throws ResourceNotFoundException {

Todo todo = todoRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException
("Todo not found for this id :: " + id));
return ResponseEntity.ok(todo);
}

@PostMapping
public ResponseEntity<?> createTodo(@RequestBody Todo todo) {

return ResponseEntity.ok(todoRepository.save(todo));
}

@PutMapping("/{id}")
public ResponseEntity<Todo> updateTodo(@PathVariable(value = "id")
Long id, @RequestBody Todo todoDto)
throws ResourceNotFoundException {

Todo todo = todoRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException
("Todo not found for this id :: " + id));

todo.setEmail(todoDto.getEmail());
todo.setLastName(todoDto.getLastName());
todo.setFirstName(todoDto.getFirstName());
todo.setGender(todoDto.getGender());
todo.setId(id);
final Todo updateTodo = todoRepository.save(todo);
return ResponseEntity.ok(updateTodo);
}

@DeleteMapping("/{id}")
public ResponseEntity<Boolean> deleteTodo(@PathVariable(value = "id")
Long id) throws ResourceNotFoundException {

Todo todo = todoRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException
("Todo not found for this id :: " + id));

todoRepository.delete(todo);

return ResponseEntity.ok(true);
}
}

TodoServiceApplication.java

package com.knf.dev.demo;

import com.knf.dev.demo.entity.Todo;
import com.knf.dev.demo.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
@RequiredArgsConstructor
public class TodoServiceApplication {

private final TodoRepository todoRepository;

public static void main(String[] args) {
SpringApplication.run(TodoServiceApplication.class, args);
}

//Load dummy data
@Bean
public CommandLineRunner commandLineRunner() {
if (todoRepository.count() == 0) {
Todo todo = Todo.builder()
.email("alpha@knf.com")
.gender("Male")
.firstName("Alpha")
.lastName("Pro")
.build();
return (args) -> todoRepository.save(todo);
}
return (args) -> System.out.println();
}
}

Run the application and verify REST APIs

Step 1: mvn clean install


Step 2: Run the Spring Boot application - mvn spring-boot:run


2. Frontend Development

First, install the Vue CLI globally on the machine.

npm install -g @vue/cli
Then create the Vue CLI project inside a folder, where primevue-crud-app is our project name.

vue create primevue-crud-app
Now we will setup the PrimeVue library. First, in the same project folder, run the following command to install PrimeVue:

npm install primevue@latest --save
npm install primeicons --save
npm install primeflex --save

Final Project directory:


package.json

The package. json file contains descriptive and functional metadata about a project, such as a name, version, and dependencies. The file provides the npm package manager with various information to help identify the project and handle dependencies.
{
  "name": "primevue-crud-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "@vuelidate/core": "^2.0.3",
    "@vuelidate/validators": "^2.0.4",
    "core-js": "^3.8.3",
    "primeflex": "^3.3.1",
    "primeicons": "^7.0.0",
    "primevue": "^3.52.0",
    "vue": "^3.2.13",
    "vue-router": "^4.3.2"
  },
  "devDependencies": {
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

todoservice.js

The todoservice will be used to get the data from the backend by calling APIs. 

Path - src/views/services/todoservice.js

export async function getAllTodos() {
    // debugger
    const response = await fetch('http://localhost:8090/todos');
    return await response.json();
}

export async function createTodo(data) {
    const response = await fetch(`http://localhost:8090/todos`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
    return await response.json();
}

export async function updateTodo(data) {
    //TODO: Test this
    const response = await fetch(`http://localhost:8090/todos/` + data.id, {
        method: 'PUT',
        body: JSON.stringify(data),
        headers: { 'Content-type': 'application/json' }
    })
    return await response.json();
}

export async function deleteTodo(data) {
    //debugger
    const response = await fetch('http://localhost:8090/todos/' + data.id, {
        method: 'DELETE',
        headers: { 'Content-Type': 'application/json' }
    })
    return await response.json();
}

HomePage.vue

HomePage component which will be used to add, update, and List todo.

Path - src/views/HomePage.vue

<template>
  <div>
    <Dialog header="Add Todo" v-model:visible="addDialog"
      :breakpoints="{'960px': '75vw', '640px': '90vw'}"
      :style="{width: '50vw'}" :modal="true">

      <TodoForm ref="addTodoFormRef" :forminp="addTodoInput">
        <template></template>
      </TodoForm>

      <template #footer>
        <Button label="Cancel" icon="pi pi-ban" @click="toggleAddDialog()"
         class="p-button-text" style="margin-right:10px"></Button>
        <Button label="Save" icon="pi pi-check" @click="addTodoBtn()" autofocus></Button>
      </template>

    </Dialog>
  </div>
  <Dialog header="Edit Todo" v-model:visible="editDialog"
   :breakpoints="{'960px': '75vw', '640px': '90vw'}"
    :style="{width: '50vw'}" :modal="true">

    <TodoForm ref="editTodoFormRef" :forminp="editTodoInput">
      <template></template>
    </TodoForm>

    <template #footer>
      <Button label="Cancel" icon="pi pi-ban" @click="hideEditDialog()"
        class="p-button-text" style="margin-right:10px"></Button>
      <Button label="Edit" icon="pi pi-check" @click="editTodoBtn()" autofocus></Button>
    </template>
  </Dialog>

  <div>
    <DataTable :value="todos" :paginator="true" :rows="4" dataKey="id"
      v-model:selection="selectedTodos" removableSort
      v-model:filters="filters" filterDisplay="row" :globalFilterFields="getColumnFields()">
      <template #header>
        <div class="flex justify-content-end">
          <span class="p-input-icon-left ">
            <i class="pi pi-search" />
            <InputText v-model="filters['global'].value" placeholder="Search" />
          </span>
        </div>
      </template>
      <Column v-for="col of columns" :field="col.field" filterField="col.field"
        :header="col.header" :key="col.field"
        :sortable="true">
      </Column>
      <Column field="editor" header="Actions">
        <template #body="slotProps">
          <Button style="margin-right:10px" icon="pi pi-pencil"
           class="p-button-rounded p-button-secondary"
            @click="showEditDialog(slotProps.data)"></Button>
          <Button icon="pi pi-trash" class="p-button-rounded p-button-danger"
            @click="removeTodo(slotProps.data)"></Button>
        </template>
      </Column>
    </DataTable>
  </div>
  <div>
    <Button style = "padding: 37px 37px;" title="Add Todo" icon="pi pi-plus-circle"
      class="p-button-rounded" @click="toggleAddDialog()"></Button>
  </div>
</template>

<script setup>
//imports
import { getAllTodos, deleteTodo } from './services/todoservice.js';
import { ref, onMounted, reactive } from 'vue';
import TodoForm from '../components/TodoForm.vue';
import { FilterMatchMode } from 'primevue/api';
// columns
const columns = ref([
  { field: 'firstName', header: 'First-Name' },
  { field: 'lastName', header: 'Last-Name' },
  { field: 'email', header: 'Email' },
  { field: 'gender', header: 'Gender' }
]);
//vars
const addTodoFormRef = ref(null);
const editTodoFormRef = ref(null);
const selectedTodos = ref();
const todos = ref([]);
const editTodoInput = reactive({
  firstName: '',
  lastName: '',
  email: '',
  gender: ''
});
const addTodoInput = reactive({
  firstName: '',
  lastName: '',
  email: '',
  gender: ''
});
const editDialog = ref(false);
const addDialog = ref(false);

//filter stuff
const filters = ref({
  'global': { value: null, matchMode: FilterMatchMode.CONTAINS },
  'firstName': { value: null, matchMode: FilterMatchMode.STARTS_WITH },
  'lastName': { value: null, matchMode: FilterMatchMode.STARTS_WITH },
  'email': { value: null, matchMode: FilterMatchMode.CONTAINS },
  'gender': { value: null, matchMode: FilterMatchMode.EQUALS }
});
//helper and button functions
function getColumnFields() {
  let colfields = [];
  for (const element of columns.value) {
    colfields.push(element.field);
  }
  return colfields;
}
function updateTableContent() {
  getAllTodos().then(data => (todos.value = data));
}

function toggleAddDialog() {
  addDialog.value = !addDialog.value;
}

function showEditDialog(data) {
  editTodoInput.id = data.id;
  editTodoInput.firstName = data.firstName;
  editTodoInput.lastName = data.lastName;
  editTodoInput.email = data.email;
  editTodoInput.gender = data.gender;
  //
  editDialog.value = !editDialog.value;
}
function hideEditDialog() {
  editDialog.value = !editDialog.value;
}

async function removeTodo(data) {
 
  await deleteTodo(data);
  updateTableContent();
}

async function editTodoBtn() {
  const status = await editTodoFormRef.value.handleEditEvent();
  if (status) {
    hideEditDialog();
    await updateTableContent();
  }
}
async function addTodoBtn() {
  const status = await addTodoFormRef.value.handleAddEvent();
  if (status) {
    toggleAddDialog();
    await updateTableContent();
  }
}
onMounted(() => {
  getAllTodos().then(data => (todos.value = data));
})

</script>

TodoForm Component

Path - src/components/TodoForm.vue

<template>
   <div class="flex flex-wrap align-Todos-center mb-3 gap-2">
        <InputText placeholder="First Name" id="firstName"
          v-model="formTodos.firstName" @blur="v$.firstName.$touch" />
        <div class="input-errors" v-for="error of v$.firstName.$errors" :key="error.$uid">
            <div class="error-msg">{{ error.$message }}</div>
        </div>
    </div>

    <div class="flex flex-wrap align-Todos-center mb-3 gap-2">
        <InputText placeholder="Last Name" id="lastName"
          v-model="formTodos.lastName" @blur="v$.lastName.$touch" />
        <div class="input-errors" v-for="error of v$.lastName.$errors" :key="error.$uid">
            <div class="error-msg">{{ error.$message }}</div>
        </div>
    </div>

    <div class="flex flex-wrap align-Todos-center mb-3 gap-2">
        <InputText placeholder="Email" id="email" type="email"
          v-model="formTodos.email" @blur="v$.email.$touch" />
        <div class="input-errors" v-for="error of v$.email.$errors" :key="error.$uid">
            <div class="error-msg">{{ error.$message }}</div>
        </div>
    </div>

    <div class="flex flex-wrap align-Todos-center mb-3 gap-2">
        <InputText placeholder="Gender" id="gender" v-model="formTodos.gender"
          @blur="v$.gender.$touch" />
        <div class="input-errors" v-for="error of v$.gender.$errors" :key="error.$uid">
            <div class="error-msg">{{ error.$message }}</div>
        </div>
    </div>
   
</template>

<script setup>

import { defineProps, defineExpose, onMounted, reactive } from 'vue';
import { createTodo, updateTodo } from '../views/services/todoservice.js';
import { useVuelidate } from '@vuelidate/core'
import { required, email } from '@vuelidate/validators'

const props = defineProps({
    forminp: {
        firstName: '',
        lastName: '',
        email: '',
        gender: '',
        id: ''
    }
});

const formTodos = reactive({
    firstName: '',
    lastName: '',
    email: '',
    gender: ''

})

const rules = {
    firstName: { required },
    lastName: { required },
    email: { email, required },
    gender: { required }
}
const v$ = useVuelidate(rules, formTodos)


//functions
function clearForm() {
    formTodos.firstName = '';
    formTodos.lastName = '';
    formTodos.email = '';
    formTodos.gender = '';
    formTodos.id = '';
}

async function handleEditEvent() {
   
    const isFormCorrect = await v$.value.$validate();
    if (!isFormCorrect) { return false; }
    await updateTodo(formTodos);
    clearForm();
    return true;
}
async function handleAddEvent() {
 
    const isFormCorrect = await v$.value.$validate();
    if (!isFormCorrect) { return false;}
    await createTodo(formTodos);
    clearForm();
    return true;
}

defineExpose({
    handleAddEvent,
    handleEditEvent
})
onMounted(() => {
    formTodos.firstName = props.forminp.firstName;
    formTodos.lastName = props.forminp.lastName;
    formTodos.email = props.forminp.email;
    formTodos.gender = props.forminp.gender;
    formTodos.id = props.forminp.id;
})
</script>

App Component

Path - src/App.vue

<template>
  <!---Dont need router for SPA but may be useful later-->
  <router-view />
</template>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
}
</style>

index.js

Path - src/router/index.js

import { createRouter, createWebHistory } from 'vue-router'
import HomePage from '../views/HomePage.vue'

const routes = [
  {
    path: '/',
    name: 'home',
    component: HomePage
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

main.js

import "primeflex/primeflex.css";
import "primevue/resources/themes/mdc-light-deeppurple/theme.css";
import "primevue/resources/primevue.min.css";
import "primeicons/primeicons.css";

import PrimeVue from "primevue/config";

import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';

import DataTable from "primevue/datatable"
import Column from 'primevue/column';
import ColumnGroup from 'primevue/columngroup';    
import Row from 'primevue/row';                    

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)

app.use(router)
app.use(PrimeVue, { ripple: true })

app.component('DataTable', DataTable)
    .component('Column', Column)
    .component('ColumnGroup', ColumnGroup)
    .component('Row', Row)
    .component('Button', Button)
    .component('Dialog', Dialog)
    .component('InputText', InputText)


app.mount('#app')

Run the application

Step 1: npm install


Step 2: Run the Spring Boot application - npm run serve

From the browser call the endpoint http://localhost:8080

Download Source Code

Popular posts from this blog

Learn Java 8 streams with an example - print odd/even numbers from Array and List

Java Stream API - How to convert List of objects to another List of objects using Java streams?

Registration and Login with Spring Boot + Spring Security + Thymeleaf

Java, Spring Boot Mini Project - Library Management System - Download

ReactJS, Spring Boot JWT Authentication Example

Top 5 Java ORM tools - 2024

Java - Blowfish Encryption and decryption Example

Spring boot video streaming example-HTML5

Google Cloud Storage + Spring Boot - File Upload, Download, and Delete