diff --git a/pom.xml b/pom.xml
index d23aeb7..7cb12b6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -14,7 +14,7 @@
demo
Demo project for Spring Boot
- 15
+ 11
springboot-react-fullstack
@@ -27,6 +27,7 @@
org.projectlombok
lombok
+ 1.18.20
true
@@ -35,6 +36,12 @@
test
+
+ com.h2database
+ h2
+ test
+
+
org.springframework.boot
spring-boot-starter-data-jpa
@@ -50,10 +57,27 @@
org.springframework.boot
spring-boot-starter-validation
+
+
+ com.github.javafaker
+ javafaker
+ 1.0.2
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ 3.0.0-M5
+ maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+
org.springframework.boot
spring-boot-maven-plugin
@@ -72,7 +96,7 @@
2.5.2
- openjdk:15
+ openjdk:11
@@ -98,7 +122,7 @@
2.5.2
- openjdk:15
+ openjdk:11
@@ -150,7 +174,7 @@
2.5.2
- openjdk:15
+ openjdk:11
diff --git a/src/frontend/src/App.js b/src/frontend/src/App.js
index b3477d1..69ba45e 100644
--- a/src/frontend/src/App.js
+++ b/src/frontend/src/App.js
@@ -11,7 +11,7 @@ import {
Badge,
Tag,
Avatar,
- Radio, Popconfirm, Image, Divider
+ Radio, Popconfirm
} from 'antd';
import {
@@ -24,6 +24,7 @@ import {
PlusOutlined
} from '@ant-design/icons';
import StudentDrawerForm from "./StudentDrawerForm";
+import StudentDrawerEditForm from "./StudentDrawerEditForm";
import './App.css';
import {errorNotification, successNotification} from "./Notification";
@@ -61,58 +62,14 @@ const removeStudent = (studentId, callback) => {
})
}
-const columns = fetchStudents => [
- {
- title: '',
- dataIndex: 'avatar',
- key: 'avatar',
- render: (text, student) =>
-
- },
- {
- title: 'Id',
- dataIndex: 'id',
- key: 'id',
- },
- {
- title: 'Name',
- dataIndex: 'name',
- key: 'name',
- },
- {
- title: 'Email',
- dataIndex: 'email',
- key: 'email',
- },
- {
- title: 'Gender',
- dataIndex: 'gender',
- key: 'gender',
- },
- {
- title: 'Actions',
- key: 'actions',
- render: (text, student) =>
-
- removeStudent(student.id, fetchStudents)}
- okText='Yes'
- cancelText='No'>
- Delete
-
- alert("TODO: Implement edit student")} value="small">Edit
-
- }
-];
-
const antIcon = ;
function App() {
const [students, setStudents] = useState([]);
+ const [student, setStudent] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [fetching, setFetching] = useState(true);
+ const [showEditDrawer, setShowEditDrawer] = useState(false);
const [showDrawer, setShowDrawer] = useState(false);
const fetchStudents = () =>
@@ -121,7 +78,7 @@ function App() {
.then(data => {
console.log(data);
setStudents(data);
- }).catch(err => {
+ }).catch(err => {
console.log(err.response)
err.response.json().then(res => {
console.log(res);
@@ -130,12 +87,12 @@ function App() {
`${res.message} [${res.status}] [${res.error}]`
)
});
- }).finally(() => setFetching(false))
+ }).finally(() => setFetching(false));
- useEffect(() => {
- console.log("component is mounted");
- fetchStudents();
- }, []);
+ useEffect(() => {
+ console.log("component is mounted");
+ fetchStudents();
+ }, []);
const renderStudents = () => {
if (fetching) {
@@ -148,6 +105,12 @@ function App() {
type="primary" shape="round" icon={} size="small">
Add New Student
+
}
return <>
+
}
+ const columns = fetchStudents => [
+ {
+ title: '',
+ dataIndex: 'avatar',
+ key: 'avatar',
+ render: (text, student) =>
+
+ },
+ {
+ title: 'Id',
+ dataIndex: 'id',
+ key: 'id',
+ },
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ },
+ {
+ title: 'Email',
+ dataIndex: 'email',
+ key: 'email',
+ },
+ {
+ title: 'Gender',
+ dataIndex: 'gender',
+ key: 'gender',
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ render: (text, student) =>
+
+ removeStudent(student.id, fetchStudents)}
+ okText='Yes'
+ cancelText='No'>
+ Delete
+
+
+ setShowEditDrawer(!showEditDrawer)
+ + setStudent(student)
+ + console.log("Student ID: ", student.id)}
+ value="small">Edit
+
+
+ }
+ ];
+
return
@@ -221,20 +241,7 @@ function App() {
{renderStudents()}
-
+
}
diff --git a/src/frontend/src/StudentDrawerEditForm.js b/src/frontend/src/StudentDrawerEditForm.js
new file mode 100644
index 0000000..c3719d8
--- /dev/null
+++ b/src/frontend/src/StudentDrawerEditForm.js
@@ -0,0 +1,135 @@
+import {Drawer, Input, Col, Select, Form, Row, Button, Spin} from 'antd';
+import {LoadingOutlined} from "@ant-design/icons";
+import {useState, useEffect} from 'react';
+import {editStudent} from "./client";
+import {errorNotification, successNotification} from "./Notification";
+
+const {Option} = Select;
+
+const antIcon = ;
+
+function StudentDrawerEditForm({showDrawer, setShowDrawer, student, fetchStudents}) {
+
+ const [form] = Form.useForm();
+
+ const loadUserData = () => {
+ form.setFieldsValue({
+ id: student.id,
+ name: student.name,
+ email: student.email,
+ gender: student.gender
+ })
+ };
+
+ useEffect(() => {
+ loadUserData();
+ }, [loadUserData, student]);
+
+ const onCLose = () => setShowDrawer(false);
+ const [submitting, setSubmitting] = useState(false);
+ const onFinish = student => {
+ setSubmitting(true)
+ console.log(JSON.stringify(student, null, 2));
+ editStudent(student).
+ then(() => {
+ console.log("student updated")
+ onCLose();
+ successNotification(
+ "Student successfully updated",
+ `${student.name} was updated`
+ )
+ fetchStudents();
+ }).catch(err => {
+ console.log(err);
+ err.response.json().then(res => {
+ console.log(res);
+ errorNotification(
+ "There was an issue",
+ `${res.message} [${res.status}] [${res.error}]`,
+ "bottomLeft"
+ )
+ });
+ }).finally(() => {
+ setSubmitting(false);
+ })
+ };
+ const onFinishFailed = errorInfo => {
+ alert(JSON.stringify(errorInfo, null, 2));
+ };
+
+ return
+
+
+ }
+ >
+
+
+
+}
+
+export default StudentDrawerEditForm;
\ No newline at end of file
diff --git a/src/frontend/src/client.js b/src/frontend/src/client.js
index ad3b90a..55d8f13 100644
--- a/src/frontend/src/client.js
+++ b/src/frontend/src/client.js
@@ -28,3 +28,13 @@ export const deleteStudent = studentId =>
fetch(`api/v1/students/${studentId}`, {
method: 'DELETE'
}).then(checkStatus);
+
+export const editStudent = student =>
+ fetch("api/v1/students", {
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ method: 'PUT',
+ body: JSON.stringify(student)
+ }
+ ).then(checkStatus)
\ No newline at end of file
diff --git a/src/main/java/com/example/demo/student/StudentController.java b/src/main/java/com/example/demo/student/StudentController.java
index 43e7983..30cee96 100644
--- a/src/main/java/com/example/demo/student/StudentController.java
+++ b/src/main/java/com/example/demo/student/StudentController.java
@@ -28,4 +28,7 @@ public void deleteStudent(
@PathVariable("studentId") Long studentId) {
studentService.deleteStudent(studentId);
}
+
+ @PutMapping
+ public void editStudent(@Valid @RequestBody Student student) { studentService.editStudent(student); }
}
diff --git a/src/main/java/com/example/demo/student/StudentRepository.java b/src/main/java/com/example/demo/student/StudentRepository.java
index 8e0a294..99b9d8b 100644
--- a/src/main/java/com/example/demo/student/StudentRepository.java
+++ b/src/main/java/com/example/demo/student/StudentRepository.java
@@ -5,8 +5,7 @@
import org.springframework.stereotype.Repository;
@Repository
-public interface StudentRepository
- extends JpaRepository {
+public interface StudentRepository extends JpaRepository {
@Query("" +
"SELECT CASE WHEN COUNT(s) > 0 THEN " +
"TRUE ELSE FALSE END " +
diff --git a/src/main/java/com/example/demo/student/StudentService.java b/src/main/java/com/example/demo/student/StudentService.java
index 282e376..0a2e5bf 100644
--- a/src/main/java/com/example/demo/student/StudentService.java
+++ b/src/main/java/com/example/demo/student/StudentService.java
@@ -3,11 +3,10 @@
import com.example.demo.student.exception.BadRequestException;
import com.example.demo.student.exception.StudentNotFoundException;
import lombok.AllArgsConstructor;
-import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
-import org.springframework.web.bind.annotation.ResponseStatus;
import java.util.List;
+import java.util.Optional;
@AllArgsConstructor
@Service
@@ -37,4 +36,8 @@ public void deleteStudent(Long studentId) {
}
studentRepository.deleteById(studentId);
}
+
+ public void editStudent(Student student) {
+ studentRepository.save(student);
+ }
}
diff --git a/src/test/java/com/example/demo/integration/StudentIT.java b/src/test/java/com/example/demo/integration/StudentIT.java
new file mode 100644
index 0000000..65cd79b
--- /dev/null
+++ b/src/test/java/com/example/demo/integration/StudentIT.java
@@ -0,0 +1,189 @@
+package com.example.demo.integration;
+
+import com.example.demo.student.Gender;
+import com.example.demo.student.Student;
+import com.example.demo.student.StudentRepository;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.javafaker.Faker;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.ResultActions;
+import org.springframework.util.StringUtils;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest
+@TestPropertySource(
+ locations = "classpath:application-it.properties"
+)
+@AutoConfigureMockMvc
+public class StudentIT {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private StudentRepository studentRepository;
+
+ private final Faker faker = new Faker();
+
+ @Test
+ void canRegisterNewStudent() throws Exception {
+ // given
+ String name = String.format(
+ "%s %s",
+ faker.name().firstName(),
+ faker.name().lastName()
+ );
+
+ Student student = new Student(
+ name,
+ String.format("%s@amigoscode.edu",
+ StringUtils.trimAllWhitespace(name.trim().toLowerCase())),
+ Gender.FEMALE
+ );
+
+ // when
+ ResultActions resultActions = mockMvc
+ .perform(post("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(student)));
+ // then
+ resultActions.andExpect(status().isOk());
+ List students = studentRepository.findAll();
+ assertThat(students)
+ .usingElementComparatorIgnoringFields("id")
+ .contains(student);
+ }
+
+ @Test
+ void canUpdateExistingStudent() throws Exception {
+ // given
+ String name = String.format(
+ "%s %s",
+ faker.name().firstName(),
+ faker.name().lastName()
+ );
+
+ String email = String.format("%s@amigoscode.edu",
+ StringUtils.trimAllWhitespace(name.trim().toLowerCase()));
+
+ Student student = new Student(
+ name,
+ email,
+ Gender.FEMALE
+ );
+
+ mockMvc.perform(post("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(student)))
+ .andExpect(status().isOk());
+
+ MvcResult getStudentsResult = mockMvc.perform(get("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String contentAsString = getStudentsResult
+ .getResponse()
+ .getContentAsString();
+
+ List students = objectMapper.readValue(
+ contentAsString,
+ new TypeReference<>() {
+ }
+ );
+
+ long id = students.stream()
+ .filter(s -> s.getEmail().equals(student.getEmail()))
+ .map(Student::getId)
+ .findFirst()
+ .orElseThrow(() ->
+ new IllegalStateException(
+ "student with email: " + email + " not found"));
+
+ // when
+ student.setId(id);
+ student.setEmail(String.format("%s@amigoscode.edu",
+ StringUtils.trimAllWhitespace(name.trim().toLowerCase())));
+
+ mockMvc.perform(put("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(student)))
+ .andExpect(status().isOk());
+
+ // then
+ boolean exists = studentRepository.selectExistsEmail(student.getEmail());
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ void canDeleteStudent() throws Exception {
+ // given
+ String name = String.format(
+ "%s %s",
+ faker.name().firstName(),
+ faker.name().lastName()
+ );
+
+ String email = String.format("%s@amigoscode.edu",
+ StringUtils.trimAllWhitespace(name.trim().toLowerCase()));
+
+ Student student = new Student(
+ name,
+ email,
+ Gender.FEMALE
+ );
+
+ mockMvc.perform(post("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(student)))
+ .andExpect(status().isOk());
+
+ MvcResult getStudentsResult = mockMvc.perform(get("/api/v1/students")
+ .contentType(MediaType.APPLICATION_JSON))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ String contentAsString = getStudentsResult
+ .getResponse()
+ .getContentAsString();
+
+ List students = objectMapper.readValue(
+ contentAsString,
+ new TypeReference<>() {
+ }
+ );
+
+ long id = students.stream()
+ .filter(s -> s.getEmail().equals(student.getEmail()))
+ .map(Student::getId)
+ .findFirst()
+ .orElseThrow(() ->
+ new IllegalStateException(
+ "student with email: " + email + " not found"));
+
+ // when
+ ResultActions resultActions = mockMvc
+ .perform(delete("/api/v1/students/" + id));
+
+ // then
+ resultActions.andExpect(status().isOk());
+ boolean exists = studentRepository.existsById(id);
+ assertThat(exists).isFalse();
+ }
+}
diff --git a/src/test/java/com/example/demo/student/StudentRepositoryTest.java b/src/test/java/com/example/demo/student/StudentRepositoryTest.java
new file mode 100644
index 0000000..2693607
--- /dev/null
+++ b/src/test/java/com/example/demo/student/StudentRepositoryTest.java
@@ -0,0 +1,50 @@
+package com.example.demo.student;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+
+@DataJpaTest
+class StudentRepositoryTest {
+
+ @Autowired
+ private StudentRepository underTest;
+
+ @AfterEach
+ void tearDown() {
+ underTest.deleteAll();
+ }
+
+ @Test
+ void itShouldCheckWhenStudentEmailExists() {
+ // given
+ String email = "jamila@gmail.com";
+ Student student = new Student(
+ "Jamila",
+ email,
+ Gender.FEMALE
+ );
+ underTest.save(student);
+
+ // when
+ boolean expected = underTest.selectExistsEmail(email);
+
+ // then
+ assertThat(expected).isTrue();
+ }
+
+ @Test
+ void itShouldCheckWhenStudentEmailDoesNotExists() {
+ // given
+ String email = "jamila@gmail.com";
+
+ // when
+ boolean expected = underTest.selectExistsEmail(email);
+
+ // then
+ assertThat(expected).isFalse();
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/example/demo/student/StudentServiceTest.java b/src/test/java/com/example/demo/student/StudentServiceTest.java
new file mode 100644
index 0000000..bd1d365
--- /dev/null
+++ b/src/test/java/com/example/demo/student/StudentServiceTest.java
@@ -0,0 +1,136 @@
+package com.example.demo.student;
+
+import com.example.demo.student.exception.BadRequestException;
+import com.example.demo.student.exception.StudentNotFoundException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class StudentServiceTest {
+
+ @Mock private StudentRepository studentRepository;
+ private StudentService underTest;
+
+ @BeforeEach
+ void setUp() {
+ underTest = new StudentService(studentRepository);
+ }
+
+ @Test
+ void canGetAllStudents() {
+ // when
+ underTest.getAllStudents();
+ // then
+ verify(studentRepository).findAll();
+ }
+
+ @Test
+ void canAddStudent() {
+ // given
+ Student student = new Student(
+ "Jamila",
+ "jamila@gmail.com",
+ Gender.FEMALE
+ );
+
+ // when
+ underTest.addStudent(student);
+
+ // then
+ ArgumentCaptor studentArgumentCaptor =
+ ArgumentCaptor.forClass(Student.class);
+
+ verify(studentRepository)
+ .save(studentArgumentCaptor.capture());
+
+ Student capturedStudent = studentArgumentCaptor.getValue();
+
+ assertThat(capturedStudent).isEqualTo(student);
+ }
+
+ @Test
+ void canEditStudent() {
+ // given
+ Student student = new Student(
+ "Jamiroquai",
+ "jamiroquai@gmail.com",
+ Gender.MALE
+ );
+
+ // when
+ underTest.editStudent(student);
+
+ // then
+ ArgumentCaptor studentArgumentCaptor =
+ ArgumentCaptor.forClass(Student.class);
+
+ verify(studentRepository)
+ .save(studentArgumentCaptor.capture());
+
+ Student capturedStudent = studentArgumentCaptor.getValue();
+
+ assertThat(capturedStudent).isEqualTo(student);
+ }
+
+ @Test
+ void willThrowWhenEmailIsTaken() {
+ // given
+ Student student = new Student(
+ "Jamila",
+ "jamila@gmail.com",
+ Gender.FEMALE
+ );
+
+ given(studentRepository.selectExistsEmail(anyString()))
+ .willReturn(true);
+
+ // when
+ // then
+ assertThatThrownBy(() -> underTest.addStudent(student))
+ .isInstanceOf(BadRequestException.class)
+ .hasMessageContaining("Email " + student.getEmail() + " taken");
+
+ verify(studentRepository, never()).save(any());
+
+ }
+
+ @Test
+ void canDeleteStudent() {
+ // given
+ long id = 10;
+ given(studentRepository.existsById(id))
+ .willReturn(true);
+ // when
+ underTest.deleteStudent(id);
+
+ // then
+ verify(studentRepository).deleteById(id);
+ }
+
+ @Test
+ void willThrowWhenDeleteStudentNotFound() {
+ // given
+ long id = 10;
+ given(studentRepository.existsById(id))
+ .willReturn(false);
+ // when
+ // then
+ assertThatThrownBy(() -> underTest.deleteStudent(id))
+ .isInstanceOf(StudentNotFoundException.class)
+ .hasMessageContaining("Student with id " + id + " does not exists");
+
+ verify(studentRepository, never()).deleteById(any());
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/application-it.properties b/src/test/resources/application-it.properties
new file mode 100644
index 0000000..3241d4d
--- /dev/null
+++ b/src/test/resources/application-it.properties
@@ -0,0 +1,11 @@
+server.error.include-message=always
+server.error.include-binding-errors=always
+
+spring.datasource.url=jdbc:postgresql://localhost:5432/amigoscode
+spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.username=postgres
+spring.datasource.password=password
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.properties.hibernate.format_sql=true
\ No newline at end of file
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
new file mode 100644
index 0000000..81c8606
--- /dev/null
+++ b/src/test/resources/application.properties
@@ -0,0 +1,8 @@
+spring.datasource.url=jdbc:h2://mem:db;DB_CLOSE_DELAY=-1
+spring.datasource.username=sa
+spring.datasource.password=sa
+spring.datasource.driver-class-name=org.h2.Driver
+spring.jpa.hibernate.ddl-auto=create-drop
+spring.jpa.show-sql=true
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.properties.hibernate.format_sql=true
\ No newline at end of file