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
<?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
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
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
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
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
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
npm install -g @vue/cli
vue create primevue-crud-app
npm install primevue@latest --save
npm install primeicons --save
npm install primeflex --save
Final Project directory:
{ "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
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
<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>//importsimport { getAllTodos, deleteTodo } from './services/todoservice.js';import { ref, onMounted, reactive } from 'vue';import TodoForm from '../components/TodoForm.vue';import { FilterMatchMode } from 'primevue/api';// columnsconst columns = ref([ { field: 'firstName', header: 'First-Name' }, { field: 'lastName', header: 'Last-Name' }, { field: 'email', header: 'Email' }, { field: 'gender', header: 'Gender' }]);//varsconst 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 stuffconst 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 functionsfunction 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
<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)
//functionsfunction 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
<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
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