From b7b127d4775e44604bd37f877d068e158f6f2e2c Mon Sep 17 00:00:00 2001 From: Jonas Honecker <44019407+jonashonecker@users.noreply.github.com> Date: Sat, 29 Jun 2024 14:35:50 +0200 Subject: [PATCH] Feature/4 add put endpoint to update a ticket (#29) **Refactoring:** * Restructured NewTicketForm.tsx * Renamed StatusChip to TicketStatusChip * Added ApiRequests util for uniformity * Add utility class for validation * Renamed NewTicketForm to TicketForm and added component TicketCardsGrid * Renamed type ApiResponseStatusSnackbar to SnackbarStatus * Renamed "openDrawer" and "setOpenDrawer" to "sidepanelStatus" and "setSidepanelStatus" * Changed sidePanelStatus from boolean to new SidePanelStatus type * Refactored SidePanelStatus.ts **Enhancement** * Add functionality, clicking on ticket card opens sidepanel with ticket title and description * Add put endpoint * Add validation, tests and custom exception (for put endpoint) * Added update functionality to frontend * Renamed `NewTicket` and `UpdateTicket` to `...DTO` --- .../backend/error/GlobalExceptionHandler.java | 8 + .../backend/ticket/TicketController.java | 19 +-- .../backend/ticket/TicketService.java | 31 +++- .../{NewTicket.java => NewTicketDTO.java} | 2 +- .../ticket/domain/UpdateTicketDTO.java | 13 ++ .../exception/NoSuchTicketException.java | 9 + .../backend/ticket/TicketControllerTest.java | 95 +++++++++++ .../backend/ticket/TicketServiceTest.java | 83 +++++++++- frontend/src/App.tsx | 5 +- .../src/components/buttons/CancelButton.tsx | 20 +++ .../src/components/buttons/MainMenuButton.tsx | 10 +- .../src/components/buttons/SaveButton.tsx | 18 ++ .../src/components/buttons/UpdateButton.tsx | 20 +++ frontend/src/components/card/TicketCard.tsx | 65 +++++--- .../{StatusChip.tsx => TicketStatusChip.tsx} | 2 +- .../src/components/editor/RichTextEditor.tsx | 10 +- frontend/src/components/editor/styles.css | 8 +- .../src/components/forms/NewTicketForm.tsx | 136 --------------- frontend/src/components/forms/SearchForm.tsx | 8 +- frontend/src/components/forms/TicketForm.tsx | 155 ++++++++++++++++++ .../inputs/TicketDescriptionInput.tsx | 52 ++++++ .../components/inputs/TicketTitleInput.tsx | 30 ++++ .../src/components/layout/TicketCardsGrid.tsx | 63 +++++++ .../src/components/navbars/MainNavBar.tsx | 7 +- frontend/src/components/pages/MainPage.tsx | 66 ++++---- .../src/components/sidepanel/Sidepanel.tsx | 13 +- .../components/snackbar/ApiStatusSnackbar.tsx | 20 +-- frontend/src/components/utils/ApiRequests.tsx | 21 +++ frontend/src/components/utils/Validation.tsx | 17 ++ frontend/src/types/SidepanelStatus.ts | 14 ++ .../src/types/{Api.ts => SnackbarStatus.ts} | 2 +- frontend/src/types/Ticket.ts | 9 + 32 files changed, 778 insertions(+), 253 deletions(-) rename backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/{NewTicket.java => NewTicketDTO.java} (90%) create mode 100644 backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/UpdateTicketDTO.java create mode 100644 backend/src/main/java/com/github/jonashonecker/backend/ticket/exception/NoSuchTicketException.java create mode 100644 frontend/src/components/buttons/CancelButton.tsx create mode 100644 frontend/src/components/buttons/SaveButton.tsx create mode 100644 frontend/src/components/buttons/UpdateButton.tsx rename frontend/src/components/chip/{StatusChip.tsx => TicketStatusChip.tsx} (95%) delete mode 100644 frontend/src/components/forms/NewTicketForm.tsx create mode 100644 frontend/src/components/forms/TicketForm.tsx create mode 100644 frontend/src/components/inputs/TicketDescriptionInput.tsx create mode 100644 frontend/src/components/inputs/TicketTitleInput.tsx create mode 100644 frontend/src/components/layout/TicketCardsGrid.tsx create mode 100644 frontend/src/components/utils/ApiRequests.tsx create mode 100644 frontend/src/components/utils/Validation.tsx create mode 100644 frontend/src/types/SidepanelStatus.ts rename frontend/src/types/{Api.ts => SnackbarStatus.ts} (63%) diff --git a/backend/src/main/java/com/github/jonashonecker/backend/error/GlobalExceptionHandler.java b/backend/src/main/java/com/github/jonashonecker/backend/error/GlobalExceptionHandler.java index b9dafab..d2004ec 100644 --- a/backend/src/main/java/com/github/jonashonecker/backend/error/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/github/jonashonecker/backend/error/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.github.jonashonecker.backend.error; import com.github.jonashonecker.backend.error.domain.ApiErrorResponse; +import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException; import com.github.jonashonecker.backend.user.exception.UserAuthenticationException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -34,5 +35,12 @@ public ApiErrorResponse handleUserAuthenticationException(UserAuthenticationExce ); } + @ExceptionHandler(NoSuchTicketException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiErrorResponse handleNoSuchTicketException(NoSuchTicketException ex) { + return new ApiErrorResponse( + ex.getMessage() + ); + } } diff --git a/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketController.java b/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketController.java index 587aa24..5592e8d 100644 --- a/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketController.java +++ b/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketController.java @@ -1,7 +1,8 @@ package com.github.jonashonecker.backend.ticket; -import com.github.jonashonecker.backend.ticket.domain.NewTicket; +import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO; import com.github.jonashonecker.backend.ticket.domain.Ticket; +import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO; import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @@ -22,14 +23,12 @@ public List getAllTickets() { } @PostMapping - public Ticket createTicket(@Valid @RequestBody NewTicket newTicket) { - return ticketService.createTicket(new Ticket( - null, - null, - newTicket.title(), - newTicket.description(), - null, - null - )); + public Ticket createTicket(@Valid @RequestBody NewTicketDTO newTicketDTO) { + return ticketService.createTicket(newTicketDTO); + } + + @PutMapping + public Ticket updateTicket(@Valid @RequestBody UpdateTicketDTO updateTicketDTO) { + return ticketService.updateTicket(updateTicketDTO); } } diff --git a/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketService.java b/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketService.java index aa35326..84ed6bd 100644 --- a/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketService.java +++ b/backend/src/main/java/com/github/jonashonecker/backend/ticket/TicketService.java @@ -1,7 +1,10 @@ package com.github.jonashonecker.backend.ticket; +import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO; import com.github.jonashonecker.backend.ticket.domain.Status; import com.github.jonashonecker.backend.ticket.domain.Ticket; +import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO; +import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException; import com.github.jonashonecker.backend.user.UserService; import org.springframework.stereotype.Service; @@ -23,16 +26,32 @@ public List getAllTickets() { return ticketRepository.findAll(); } - public Ticket createTicket(Ticket ticket) { + public Ticket getTicketById(String id) { + return ticketRepository.findById(id).orElseThrow(() -> new NoSuchTicketException("Could not find ticket with id: " + id)); + } + + public Ticket createTicket(NewTicketDTO newTicketDTO) { String defaultProjectName = "Default Project"; Status defaultStatus = Status.OPEN; - Ticket newTicket = new Ticket(idService.getUUID(), + return ticketRepository.insert(new Ticket( + idService.getUUID(), defaultProjectName, - ticket.title(), - ticket.description(), + newTicketDTO.title(), + newTicketDTO.description(), defaultStatus, userService.getCurrentUser() - ); - return ticketRepository.insert(newTicket); + )); + } + + public Ticket updateTicket(UpdateTicketDTO updateTicketDTO) { + Ticket existingTicket = getTicketById(updateTicketDTO.id()); + return ticketRepository.save(new Ticket( + existingTicket.id(), + existingTicket.projectName(), + updateTicketDTO.title(), + updateTicketDTO.description(), + existingTicket.status(), + existingTicket.author() + )); } } \ No newline at end of file diff --git a/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicket.java b/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicketDTO.java similarity index 90% rename from backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicket.java rename to backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicketDTO.java index 620c9a9..9a4bdef 100644 --- a/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicket.java +++ b/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/NewTicketDTO.java @@ -2,7 +2,7 @@ import jakarta.validation.constraints.NotBlank; -public record NewTicket( +public record NewTicketDTO( @NotBlank(message = "Title must not be empty") String title, @NotBlank(message = "Description must not be empty") diff --git a/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/UpdateTicketDTO.java b/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/UpdateTicketDTO.java new file mode 100644 index 0000000..78fc2e0 --- /dev/null +++ b/backend/src/main/java/com/github/jonashonecker/backend/ticket/domain/UpdateTicketDTO.java @@ -0,0 +1,13 @@ +package com.github.jonashonecker.backend.ticket.domain; + +import jakarta.validation.constraints.NotBlank; + +public record UpdateTicketDTO( + @NotBlank(message = "Id must not be empty") + String id, + @NotBlank(message = "Title must not be empty") + String title, + @NotBlank(message = "Description must not be empty") + String description +) { +} diff --git a/backend/src/main/java/com/github/jonashonecker/backend/ticket/exception/NoSuchTicketException.java b/backend/src/main/java/com/github/jonashonecker/backend/ticket/exception/NoSuchTicketException.java new file mode 100644 index 0000000..f537551 --- /dev/null +++ b/backend/src/main/java/com/github/jonashonecker/backend/ticket/exception/NoSuchTicketException.java @@ -0,0 +1,9 @@ +package com.github.jonashonecker.backend.ticket.exception; + +import java.util.NoSuchElementException; + +public class NoSuchTicketException extends NoSuchElementException { + public NoSuchTicketException(String message) { + super(message); + } +} diff --git a/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketControllerTest.java b/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketControllerTest.java index dc62cc5..3e1f499 100644 --- a/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketControllerTest.java +++ b/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketControllerTest.java @@ -17,6 +17,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @@ -230,4 +231,98 @@ void createTicket_whenInvalidUser_thenReturnApiErrorMessage() throws Exception { .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error").value("There is an issue with your user login. Please contact support.")); } + + @Test + void updateTicket_whenUnauthenticated_returnUnauthorized() throws Exception { + //GIVEN + //WHEN + mockMvc.perform(put("/api/ticket")) + //THEN + .andExpect(status().isUnauthorized()) + .andExpect(content().string("") + ); + } + + @Test + @DirtiesContext + @WithMockUser + void updateTicket_whenUpdateTitleAndDescription_returnTicketWithUpdatedTitleAndDescription() throws Exception { + //GIVEN + TicketScoutUser ticketScoutUser = new TicketScoutUser("test-name", "test-avatarUrl"); + ticketRepository.insert(new Ticket( + "test-id", + "test-projectName", + "test-title", + "test-description", + Status.IN_PROGRESS, + ticketScoutUser + )); + + //WHEN + mockMvc.perform(put("/api/ticket") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "id": "test-id", + "title": "updated-title", + "description": "updated-description" + } + """)) + //THEN + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "id": "test-id", + "projectName": "test-projectName", + "title": "updated-title", + "description": "updated-description", + "status": "IN_PROGRESS", + "author": {"name": "test-name", "avatarUrl": "test-avatarUrl"} + } + """)); + } + + @Test + @WithMockUser + void updateTicket_whenTicketNotInRepository_returnApiErrorMessage() throws Exception { + //GIVEN + //WHEN + mockMvc.perform(put("/api/ticket") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "id": "test-id", + "title": "test-title", + "description": "test-description" + } + """)) + //THEN + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Could not find ticket with id: test-id")); + } + + @Test + @WithMockUser + @DirtiesContext + void updateTicket_whenInvalidIdAndTitleAndDescription_thenReturnApiErrorMessage() throws Exception { + //GIVEN + //WHEN + mockMvc.perform(put("/api/ticket") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "id": "", + "title": "", + "description": "" + } + """)) + //THEN + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error", containsString("Input validation failed for"))) + .andExpect(jsonPath("$.error", containsString("and"))) + .andExpect(jsonPath("$.error", containsString("description (Description must not be empty)"))) + .andExpect(jsonPath("$.error", containsString("id (Id must not be empty)"))) + .andExpect(jsonPath("$.error", containsString("title (Title must not be empty)"))); + } + } \ No newline at end of file diff --git a/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketServiceTest.java b/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketServiceTest.java index 8dbdd97..c93e51f 100644 --- a/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketServiceTest.java +++ b/backend/src/test/java/com/github/jonashonecker/backend/ticket/TicketServiceTest.java @@ -1,12 +1,16 @@ package com.github.jonashonecker.backend.ticket; +import com.github.jonashonecker.backend.ticket.domain.NewTicketDTO; import com.github.jonashonecker.backend.ticket.domain.Status; import com.github.jonashonecker.backend.ticket.domain.Ticket; +import com.github.jonashonecker.backend.ticket.domain.UpdateTicketDTO; +import com.github.jonashonecker.backend.ticket.exception.NoSuchTicketException; import com.github.jonashonecker.backend.user.UserService; import com.github.jonashonecker.backend.user.domain.TicketScoutUser; import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -31,6 +35,41 @@ void getAllTickets_whenRepositoryEmpty_returnEmptyList() { assertEquals(List.of(), actual); } + @Test + void getTicketById_whenTicketNotInRepository_throwNoSuchTicketException() { + //GIVEN + String id = "test-id"; + when(ticketRepository.findById(id)).thenReturn(Optional.empty()); + + //WHEN + NoSuchTicketException actual = assertThrows(NoSuchTicketException.class, () -> ticketService.getTicketById(id)); + + //THEN + assertEquals("Could not find ticket with id: " + id, actual.getMessage()); + } + + @Test + void getTicketById_whenTicketInRepository_returnTicket() { + //GIVEN + String id = "test-id"; + Ticket expected = new Ticket( + id, + "test-projectName", + "test-title", + "test-description", + Status.OPEN, + new TicketScoutUser("test-name", "test-avatarUrl") + ); + + when(ticketRepository.findById(id)).thenReturn(Optional.of(expected)); + + //WHEN + Ticket actual = ticketService.getTicketById(id); + + //THEN + assertEquals(expected, actual); + } + @Test void getAllTickets_whenRepositoryContainsTickets_returnTickets() { //GIVEN @@ -59,13 +98,9 @@ void createTicket_whenNewTicket_thenReturnsTicketWithDefaultProjectAndStatus() { String defaultProject = "Default Project"; Status defaultStatus = Status.OPEN; TicketScoutUser ticketScoutUser = new TicketScoutUser("test-name", "test-avatarUrl"); - Ticket ticket = new Ticket( - "1", - "test-projectName", + NewTicketDTO newTicketDTO = new NewTicketDTO( "test-title", - "test-description", - Status.IN_PROGRESS, - ticketScoutUser + "test-description" ); Ticket expected = new Ticket( @@ -82,7 +117,7 @@ void createTicket_whenNewTicket_thenReturnsTicketWithDefaultProjectAndStatus() { when(ticketRepository.insert(any(Ticket.class))).thenReturn(expected); //WHEN - Ticket actual = ticketService.createTicket(ticket); + Ticket actual = ticketService.createTicket(newTicketDTO); //THEN verify(ticketRepository, times(1)).insert(expected); @@ -90,4 +125,38 @@ void createTicket_whenNewTicket_thenReturnsTicketWithDefaultProjectAndStatus() { verify(userService, times(1)).getCurrentUser(); assertEquals(expected, actual); } + + @Test + void updateTicket_whenUpdateTicketTitle_returnTicketWithUpdatedTitle() { + //GIVEN + String id = "test-id"; + String description = "test-description"; + UpdateTicketDTO updateTicketDTO = new UpdateTicketDTO(id, "new-updated-title", description); + Ticket ticketInDb = new Ticket( + id, + "test-projectName", + "test-title", + description, + Status.OPEN, + new TicketScoutUser("test-name", "test-avatarUrl") + ); + Ticket expected = new Ticket( + id, + ticketInDb.projectName(), + "new-updated-title", + ticketInDb.description(), + ticketInDb.status(), + ticketInDb.author() + ); + when(ticketRepository.findById(id)).thenReturn(Optional.of(ticketInDb)); + when(ticketRepository.save(expected)).thenReturn(expected); + + //WHEN + Ticket actual = ticketService.updateTicket(updateTicketDTO); + + //THEN + verify(ticketRepository, times(1)).save(expected); + verify(ticketRepository, times(1)).findById(id); + assertEquals(expected, actual); + } } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0d3b446..0766d77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,11 +2,11 @@ import LoginPage from "./components/pages/LoginPage.tsx"; import { Route, Routes } from "react-router-dom"; import MainPage from "./components/pages/MainPage.tsx"; import { useEffect, useState } from "react"; -import axios from "axios"; import ProtectedRoute from "./components/utils/ProtectedRoute.tsx"; import { User } from "./types/User.ts"; import Theme from "./components/theme/Theme.tsx"; import { Ticket } from "./types/Ticket.ts"; +import ApiUtils from "./components/utils/ApiRequests.tsx"; export default function App() { const [user, setUser] = useState(undefined); @@ -15,8 +15,7 @@ export default function App() { ); const loadUser = () => { - axios - .get("/api/auth/me") + ApiUtils.getUser() .then((response) => { setUser(response.data); }) diff --git a/frontend/src/components/buttons/CancelButton.tsx b/frontend/src/components/buttons/CancelButton.tsx new file mode 100644 index 0000000..4559ccf --- /dev/null +++ b/frontend/src/components/buttons/CancelButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@mui/material"; + +type CancelButtonProps = { + onClick: () => void; +}; + +export default function CancelButton({ onClick }: Readonly) { + return ( + + ); +} diff --git a/frontend/src/components/buttons/MainMenuButton.tsx b/frontend/src/components/buttons/MainMenuButton.tsx index a66ce91..e774b73 100644 --- a/frontend/src/components/buttons/MainMenuButton.tsx +++ b/frontend/src/components/buttons/MainMenuButton.tsx @@ -11,13 +11,14 @@ import { Dispatch, MouseEvent, SetStateAction, useState } from "react"; import { useNavigate } from "react-router-dom"; import SearchIcon from "@mui/icons-material/Search"; import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; type MainMenuButtonProps = { - setOpenDrawer: Dispatch>; + setSidepanelStatus: Dispatch>; }; export default function MainMenuButton({ - setOpenDrawer, + setSidepanelStatus, }: Readonly) { const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); @@ -30,7 +31,10 @@ export default function MainMenuButton({ }; function openNewTicketForm() { - setOpenDrawer(true); + setSidepanelStatus({ + open: true, + formType: "NewTicket", + }); setAnchorEl(null); } diff --git a/frontend/src/components/buttons/SaveButton.tsx b/frontend/src/components/buttons/SaveButton.tsx new file mode 100644 index 0000000..5a759a6 --- /dev/null +++ b/frontend/src/components/buttons/SaveButton.tsx @@ -0,0 +1,18 @@ +import { Button } from "@mui/material"; + +type SaveButtonProps = { + onClick: () => void; +}; + +export default function SaveButton({ onClick }: Readonly) { + return ( + + ); +} diff --git a/frontend/src/components/buttons/UpdateButton.tsx b/frontend/src/components/buttons/UpdateButton.tsx new file mode 100644 index 0000000..44e600e --- /dev/null +++ b/frontend/src/components/buttons/UpdateButton.tsx @@ -0,0 +1,20 @@ +import { Button } from "@mui/material"; + +type UpdateButtonProps = { + onClick: () => void; +}; + +export default function UpdateButton({ onClick }: Readonly) { + return ( + + ); +} diff --git a/frontend/src/components/card/TicketCard.tsx b/frontend/src/components/card/TicketCard.tsx index 1f533ba..ffa73c1 100644 --- a/frontend/src/components/card/TicketCard.tsx +++ b/frontend/src/components/card/TicketCard.tsx @@ -1,34 +1,57 @@ -import { Avatar, Card, CardContent, Stack, Typography } from "@mui/material"; -import StatusChip from "../chip/StatusChip.tsx"; +import { + Avatar, + Card, + CardActionArea, + CardContent, + Stack, + Typography, +} from "@mui/material"; +import TicketStatusChip from "../chip/TicketStatusChip.tsx"; import { Ticket } from "../../types/Ticket.ts"; +import { Dispatch, SetStateAction } from "react"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; type TicketCardProps = { ticket: Ticket; + setSidepanelStatus: Dispatch>; }; -export default function TicketCard({ ticket }: Readonly) { +export default function TicketCard({ + ticket, + setSidepanelStatus, +}: Readonly) { return ( - + setSidepanelStatus({ + open: true, + formType: "UpdateTicket", + ticket: ticket, + }) + } > - - - {ticket.projectName} + + + + {ticket.projectName} + + + + + {ticket.title} - - - - {ticket.title} - - - - - + + + + + ); } diff --git a/frontend/src/components/chip/StatusChip.tsx b/frontend/src/components/chip/TicketStatusChip.tsx similarity index 95% rename from frontend/src/components/chip/StatusChip.tsx rename to frontend/src/components/chip/TicketStatusChip.tsx index 4bee5c5..fc3a372 100644 --- a/frontend/src/components/chip/StatusChip.tsx +++ b/frontend/src/components/chip/TicketStatusChip.tsx @@ -16,7 +16,7 @@ const statusStyles: Record< IN_PROGRESS: { label: "In Progress", statusKey: "inProgress" }, }; -export default function StatusChip({ +export default function TicketStatusChip({ ticketStatus, }: Readonly) { const theme = useTheme(); diff --git a/frontend/src/components/editor/RichTextEditor.tsx b/frontend/src/components/editor/RichTextEditor.tsx index 750756d..e1fdc37 100644 --- a/frontend/src/components/editor/RichTextEditor.tsx +++ b/frontend/src/components/editor/RichTextEditor.tsx @@ -4,23 +4,29 @@ import Image from "@tiptap/extension-image"; import "./styles.css"; import MenuBar from "./menu/MenuBar.tsx"; import { User } from "../../types/User.ts"; -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, SetStateAction, useEffect } from "react"; import Placeholder from "@tiptap/extension-placeholder"; type RichTextEditorProps = { user: User | null | undefined; + initialDescription: string; description: string; setDescription: Dispatch>; }; export default function RichTextEditor({ user, + initialDescription, setDescription, description, }: Readonly) { + useEffect(() => { + editor?.commands.setContent(initialDescription); + }, [initialDescription]); + const extensions = [ StarterKit, - Image, + Image.configure({ inline: true }), Placeholder.configure({ placeholder: ({ node }) => { if (node.type.name === "heading") { diff --git a/frontend/src/components/editor/styles.css b/frontend/src/components/editor/styles.css index 5fd05f5..517a26e 100644 --- a/frontend/src/components/editor/styles.css +++ b/frontend/src/components/editor/styles.css @@ -1,8 +1,8 @@ :root { --black: #2e2b29; --white: #ffffff; - --purple: #6a00f5; - --purple-light: #6a00f5; + --blue: #1976d2; + --blue-light: #e3f2fd; --gray-3: #e7e4e2; --gray-2: #f7f6f5; --gray-4: #c2bdba; @@ -72,7 +72,7 @@ /* Code and preformatted text styles */ .tiptap code { - background-color: var(--purple-light); + background-color: var(--blue-light); border-radius: 0.4rem; color: var(--black); font-size: 0.85rem; @@ -115,7 +115,7 @@ max-width: 100%; } .tiptap img.ProseMirror-selectednode { - outline: 3px solid var(--purple); + outline: 3px solid var(--blue); } /*Placeholder*/ diff --git a/frontend/src/components/forms/NewTicketForm.tsx b/frontend/src/components/forms/NewTicketForm.tsx deleted file mode 100644 index 56b09bf..0000000 --- a/frontend/src/components/forms/NewTicketForm.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Box, Button, FormHelperText, Stack, TextField } from "@mui/material"; -import StatusChip from "../chip/StatusChip.tsx"; -import RichTextEditor from "../editor/RichTextEditor.tsx"; -import { Dispatch, SetStateAction, useState } from "react"; -import { User } from "../../types/User.ts"; -import axios from "axios"; -import { ApiResponseStatusSnackbar } from "../../types/Api.ts"; - -type NewTicketFormProps = { - user: User | null | undefined; - setOpenDrawer: Dispatch>; - apiRequestStatusSnackbar: ApiResponseStatusSnackbar; - setApiRequestStatusSnackbar: Dispatch< - SetStateAction - >; -}; - -export default function NewTicketForm({ - user, - setOpenDrawer, - setApiRequestStatusSnackbar, -}: Readonly) { - const [title, setTitle] = useState(""); - const [titleError, setTitleError] = useState(false); - const [description, setDescription] = useState(""); - const [descriptionError, setDescriptionError] = useState(false); - function cancel() { - setOpenDrawer(false); - } - - function save() { - const isTitleError = !title.trim(); - const isDescriptionError = !checkIfHtmlContainsValue(description); - - setTitleError(isTitleError); - setDescriptionError(isDescriptionError); - - if (!isTitleError && !isDescriptionError) { - axios - .post("/api/ticket", { title: title, description: description }) - .then(() => { - setApiRequestStatusSnackbar({ - open: true, - severity: "success", - message: "Ticket created successfully!", - }); - setOpenDrawer(false); - }) - .catch((error) => { - setApiRequestStatusSnackbar({ - open: true, - severity: "error", - message: error.response.data.error, - }); - }); - } - } - - function checkIfHtmlContainsValue(htmlString: string) { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlString, "text/html"); - const allElements = doc.querySelectorAll("*"); - for (const element of allElements) { - if (element.textContent) { - return true; - } - } - return false; - } - - return ( - <> - { - setTitle(event.target.value); - }} - /> - - - - - descriptionError - ? `1px solid ${theme.palette.error.main}` - : `1px solid ${theme.palette.divider}`, - }} - > - - - {descriptionError && ( - - A description is required - - )} - - - - - - ); -} diff --git a/frontend/src/components/forms/SearchForm.tsx b/frontend/src/components/forms/SearchForm.tsx index 6f98c81..cfe218f 100644 --- a/frontend/src/components/forms/SearchForm.tsx +++ b/frontend/src/components/forms/SearchForm.tsx @@ -2,7 +2,7 @@ import { Divider, IconButton, InputBase, Paper } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import { Ticket } from "../../types/Ticket.ts"; import { Dispatch, SetStateAction, FormEvent } from "react"; -import axios from "axios"; +import ApiUtils from "../utils/ApiRequests.tsx"; type SearchFormProps = { setSearchResults: Dispatch>; @@ -13,9 +13,9 @@ export default function SearchForm({ }: Readonly) { function searchTickets(event: FormEvent) { event.preventDefault(); - axios - .get("/api/ticket") - .then((response) => setSearchResults(response.data)); + ApiUtils.getAllTickets().then((response) => + setSearchResults(response.data), + ); } return ( diff --git a/frontend/src/components/forms/TicketForm.tsx b/frontend/src/components/forms/TicketForm.tsx new file mode 100644 index 0000000..368e452 --- /dev/null +++ b/frontend/src/components/forms/TicketForm.tsx @@ -0,0 +1,155 @@ +import { Stack } from "@mui/material"; +import TicketStatusChip from "../chip/TicketStatusChip.tsx"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { User } from "../../types/User.ts"; +import { SnackbarStatus } from "../../types/SnackbarStatus.ts"; +import CancelButton from "../buttons/CancelButton.tsx"; +import SaveButton from "../buttons/SaveButton.tsx"; +import TicketTitleInput from "../inputs/TicketTitleInput.tsx"; +import TicketDescriptionInput from "../inputs/TicketDescriptionInput.tsx"; +import ApiUtils from "../utils/ApiRequests.tsx"; +import Validation from "../utils/Validation.tsx"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; +import UpdateButton from "../buttons/UpdateButton.tsx"; +import { Ticket } from "../../types/Ticket.ts"; + +type TicketFormProps = { + user: User | null | undefined; + sidePanelStatus: SidepanelStatus; + setSidepanelStatus: Dispatch>; + setSnackbarStatus: Dispatch>; + searchResults: Ticket[] | undefined; + setSearchResults: Dispatch>; +}; + +export default function TicketForm({ + user, + sidePanelStatus, + setSidepanelStatus, + setSnackbarStatus, + searchResults, + setSearchResults, +}: Readonly) { + const [title, setTitle] = useState(""); + const [titleError, setTitleError] = useState(false); + const [initialDescription, setInitialDescription] = useState(""); + const [description, setDescription] = useState(""); + const [descriptionError, setDescriptionError] = useState(false); + + useEffect(() => { + if (sidePanelStatus.formType == "UpdateTicket") { + setTitle(sidePanelStatus.ticket.title); + setDescription(sidePanelStatus.ticket.description); + setInitialDescription(sidePanelStatus.ticket.description); + } else { + setTitle(""); + setDescription(""); + setInitialDescription(""); + } + }, [sidePanelStatus]); + + function cancel() { + setSidepanelStatus({ ...sidePanelStatus, open: false }); + } + + function save() { + const [isTitleError, isDescriptionError] = validateTitleAndDescription(); + + if (!isTitleError && !isDescriptionError) { + ApiUtils.createNewTicket({ title: title, description: description }) + .then(() => { + setSnackbarStatus({ + open: true, + severity: "success", + message: "Ticket created successfully!", + }); + setSidepanelStatus({ ...sidePanelStatus, open: false }); + }) + .catch((error) => { + setSnackbarStatus({ + open: true, + severity: "error", + message: error.response.data.error, + }); + }); + } + } + + function update() { + const [isTitleError, isDescriptionError] = validateTitleAndDescription(); + + if (!isTitleError && !isDescriptionError) { + ApiUtils.updateTicket({ + id: + sidePanelStatus.formType == "UpdateTicket" + ? sidePanelStatus.ticket.id + : "", + title: title, + description: description, + }) + .then((response) => { + setSearchResults( + searchResults?.map((ticket) => { + if (ticket.id === response.data.id) { + return response.data; + } else { + return ticket; + } + }), + ); + setSnackbarStatus({ + open: true, + severity: "success", + message: "Ticket updated successfully!", + }); + setSidepanelStatus({ ...sidePanelStatus, open: false }); + }) + .catch((error) => { + setSnackbarStatus({ + open: true, + severity: "error", + message: error.response.data.error, + }); + }); + } + } + + function validateTitleAndDescription() { + const isTitleError = !title.trim(); + const isDescriptionError = !Validation.checkIfHtmlContainsText(description); + + setTitleError(isTitleError); + setDescriptionError(isDescriptionError); + + return [isTitleError, isDescriptionError]; + } + + return ( + <> + + + + + + + + {sidePanelStatus.formType == "NewTicket" && ( + + )} + {sidePanelStatus.formType == "UpdateTicket" && ( + + )} + + + ); +} diff --git a/frontend/src/components/inputs/TicketDescriptionInput.tsx b/frontend/src/components/inputs/TicketDescriptionInput.tsx new file mode 100644 index 0000000..7f7c80a --- /dev/null +++ b/frontend/src/components/inputs/TicketDescriptionInput.tsx @@ -0,0 +1,52 @@ +import { Box, FormHelperText } from "@mui/material"; +import RichTextEditor from "../editor/RichTextEditor.tsx"; +import { Dispatch, SetStateAction } from "react"; +import { User } from "../../types/User.ts"; + +type TicketDescriptionInputProps = { + user: User | null | undefined; + description: string; + setDescription: Dispatch>; + initialDescription: string; + descriptionError: boolean; +}; + +export default function TicketDescriptionInput({ + user, + initialDescription, + description, + setDescription, + descriptionError, +}: Readonly) { + return ( + <> + + descriptionError + ? `1px solid ${theme.palette.error.main}` + : `1px solid ${theme.palette.divider}`, + }} + > + + + {descriptionError && ( + + A description is required + + )} + + ); +} diff --git a/frontend/src/components/inputs/TicketTitleInput.tsx b/frontend/src/components/inputs/TicketTitleInput.tsx new file mode 100644 index 0000000..e93ce5f --- /dev/null +++ b/frontend/src/components/inputs/TicketTitleInput.tsx @@ -0,0 +1,30 @@ +import { TextField } from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; + +type TicketTitleInputProps = { + titleError: boolean; + setTitle: Dispatch>; + title: string; +}; + +export default function TicketTitleInput({ + titleError, + setTitle, + title, +}: Readonly) { + return ( + { + setTitle(event.target.value); + }} + /> + ); +} diff --git a/frontend/src/components/layout/TicketCardsGrid.tsx b/frontend/src/components/layout/TicketCardsGrid.tsx new file mode 100644 index 0000000..40534a8 --- /dev/null +++ b/frontend/src/components/layout/TicketCardsGrid.tsx @@ -0,0 +1,63 @@ +import { Box, Grow, useMediaQuery, useTheme } from "@mui/material"; +import { Ticket } from "../../types/Ticket.ts"; +import TicketCard from "../card/TicketCard.tsx"; +import { Dispatch, SetStateAction } from "react"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; + +type TicketCardsGridProps = { + searchResults: Ticket[] | undefined; + setSidepanelStatus: Dispatch>; +}; + +export default function TicketCardsGrid({ + searchResults, + setSidepanelStatus, +}: Readonly) { + const theme = useTheme(); + const isXs = useMediaQuery(theme.breakpoints.only("xs")); + const isSm = useMediaQuery(theme.breakpoints.only("sm")); + const isMd = useMediaQuery(theme.breakpoints.only("md")); + + let columnsCount; + if (isXs) { + columnsCount = 1; + } else if (isSm) { + columnsCount = 2; + } else if (isMd) { + columnsCount = 3; + } else { + columnsCount = 4; + } + + const columns: Ticket[][] = Array.from({ length: columnsCount }, () => []); + + // Distribute items into columns + searchResults?.forEach((ticket, index) => { + columns[index % columnsCount].push(ticket); + }); + + return ( + + {columns.map((column, colIndex) => ( + + {column.map((ticket, index) => ( + + + + + + ))} + + ))} + + ); +} diff --git a/frontend/src/components/navbars/MainNavBar.tsx b/frontend/src/components/navbars/MainNavBar.tsx index bf458e9..acfec5a 100644 --- a/frontend/src/components/navbars/MainNavBar.tsx +++ b/frontend/src/components/navbars/MainNavBar.tsx @@ -5,10 +5,11 @@ import UserMenuButton from "../buttons/UserMenuButton.tsx"; import MainMenuButton from "../buttons/MainMenuButton.tsx"; import { User } from "../../types/User.ts"; import { Dispatch, SetStateAction } from "react"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; type NavBarProps = { user: User | null | undefined; - setOpenDrawer: Dispatch>; + setSidepanelStatus: Dispatch>; }; const StyledAppBar = styled(AppBar)({ @@ -17,12 +18,12 @@ const StyledAppBar = styled(AppBar)({ export default function MainNavBar({ user, - setOpenDrawer, + setSidepanelStatus, }: Readonly) { return ( - {user && } + {user && } diff --git a/frontend/src/components/pages/MainPage.tsx b/frontend/src/components/pages/MainPage.tsx index b65409d..eee4e5d 100644 --- a/frontend/src/components/pages/MainPage.tsx +++ b/frontend/src/components/pages/MainPage.tsx @@ -2,13 +2,14 @@ import MainNavBar from "../navbars/MainNavBar.tsx"; import { User } from "../../types/User.ts"; import { Ticket } from "../../types/Ticket.ts"; import { Dispatch, SetStateAction, useState } from "react"; -import TicketCard from "../card/TicketCard.tsx"; -import { Box, Container, Grid, Grow } from "@mui/material"; +import { Box, Container } from "@mui/material"; import SearchForm from "../forms/SearchForm.tsx"; -import NewTicketForm from "../forms/NewTicketForm.tsx"; +import TicketForm from "../forms/TicketForm.tsx"; import ApiStatusSnackbar from "../snackbar/ApiStatusSnackbar.tsx"; -import { ApiResponseStatusSnackbar } from "../../types/Api.ts"; +import { SnackbarStatus } from "../../types/SnackbarStatus.ts"; import Sidepanel from "../sidepanel/Sidepanel.tsx"; +import TicketCardsGrid from "../layout/TicketCardsGrid.tsx"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; type MainPageProps = { user: User | null | undefined; @@ -21,49 +22,46 @@ export default function MainPage({ searchResults, setSearchResults, }: Readonly) { - const [openDrawer, setOpenDrawer] = useState(false); - const [apiRequestStatusSnackbar, setApiRequestStatusSnackbar] = - useState({ - open: false, - severity: "error", - message: "Initial value", - }); + const [sidepanelStatus, setSidepanelStatus] = useState({ + open: false, + formType: "NewTicket", + }); + const [snackbarStatus, setSnackbarStatus] = useState({ + open: false, + severity: "error", + message: "Initial value", + }); return ( <> - - + + - - {searchResults?.map((ticket, index) => ( - - - - - - ))} - + - + - ); diff --git a/frontend/src/components/sidepanel/Sidepanel.tsx b/frontend/src/components/sidepanel/Sidepanel.tsx index 1de09cf..ce27af6 100644 --- a/frontend/src/components/sidepanel/Sidepanel.tsx +++ b/frontend/src/components/sidepanel/Sidepanel.tsx @@ -1,15 +1,16 @@ import { Drawer, useMediaQuery, useTheme } from "@mui/material"; import { Dispatch, ReactNode, SetStateAction } from "react"; +import { SidepanelStatus } from "../../types/SidepanelStatus.ts"; type SidepanelProps = { - openDrawer: boolean; - setOpenDrawer: Dispatch>; + sidepanelStatus: SidepanelStatus; + setSidepanelStatus: Dispatch>; children: ReactNode; }; export default function Sidepanel({ - openDrawer, - setOpenDrawer, + sidepanelStatus, + setSidepanelStatus, children, }: Readonly) { const theme = useTheme(); @@ -26,9 +27,9 @@ export default function Sidepanel({ return ( { - setOpenDrawer(false); + setSidepanelStatus({ ...sidepanelStatus, open: false }); }} PaperProps={{ sx: { diff --git a/frontend/src/components/snackbar/ApiStatusSnackbar.tsx b/frontend/src/components/snackbar/ApiStatusSnackbar.tsx index 76359bd..34e3229 100644 --- a/frontend/src/components/snackbar/ApiStatusSnackbar.tsx +++ b/frontend/src/components/snackbar/ApiStatusSnackbar.tsx @@ -1,41 +1,39 @@ import Snackbar from "@mui/material/Snackbar"; import Alert from "@mui/material/Alert"; import { Dispatch, SetStateAction, SyntheticEvent } from "react"; -import { ApiResponseStatusSnackbar } from "../../types/Api.ts"; +import { SnackbarStatus } from "../../types/SnackbarStatus.ts"; type ApiStatusSnackbarProps = { - apiRequestStatusSnackbar: ApiResponseStatusSnackbar; - setApiRequestStatusSnackbar: Dispatch< - SetStateAction - >; + snackbarStatus: SnackbarStatus; + setSnackbarStatus: Dispatch>; }; export default function ApiStatusSnackbar({ - apiRequestStatusSnackbar, - setApiRequestStatusSnackbar, + snackbarStatus, + setSnackbarStatus, }: Readonly) { const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => { if (reason === "clickaway") { return; } - setApiRequestStatusSnackbar({ ...apiRequestStatusSnackbar, open: false }); + setSnackbarStatus({ ...snackbarStatus, open: false }); }; return (
- {apiRequestStatusSnackbar.message} + {snackbarStatus.message}
diff --git a/frontend/src/components/utils/ApiRequests.tsx b/frontend/src/components/utils/ApiRequests.tsx new file mode 100644 index 0000000..d57c28c --- /dev/null +++ b/frontend/src/components/utils/ApiRequests.tsx @@ -0,0 +1,21 @@ +import axios, { AxiosResponse } from "axios"; +import { NewTicket, Ticket, UpdateTicket } from "../../types/Ticket.ts"; +import { User } from "../../types/User.ts"; + +export default class ApiUtils { + static getUser(): Promise> { + return axios.get("/api/auth/me"); + } + + static getAllTickets(): Promise> { + return axios.get("/api/ticket"); + } + + static createNewTicket(data: NewTicket): Promise> { + return axios.post("/api/ticket", data); + } + + static updateTicket(data: UpdateTicket): Promise> { + return axios.put("/api/ticket", data); + } +} diff --git a/frontend/src/components/utils/Validation.tsx b/frontend/src/components/utils/Validation.tsx new file mode 100644 index 0000000..e1d4675 --- /dev/null +++ b/frontend/src/components/utils/Validation.tsx @@ -0,0 +1,17 @@ +export default class Validation { + static checkIfHtmlContainsText(htmlString: string) { + function hasCharacters(element: string) { + return Boolean(element.trim()); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, "text/html"); + const allElements = doc.querySelectorAll("*"); + for (const element of allElements) { + if (element.textContent && hasCharacters(element.textContent)) { + return true; + } + } + return false; + } +} diff --git a/frontend/src/types/SidepanelStatus.ts b/frontend/src/types/SidepanelStatus.ts new file mode 100644 index 0000000..6b4a93c --- /dev/null +++ b/frontend/src/types/SidepanelStatus.ts @@ -0,0 +1,14 @@ +import { Ticket } from "./Ticket.ts"; + +type NewTicketStatus = { + formType: "NewTicket"; + open: boolean; +}; + +type UpdateTicketStatus = { + formType: "UpdateTicket"; + open: boolean; + ticket: Ticket; +}; + +export type SidepanelStatus = NewTicketStatus | UpdateTicketStatus; diff --git a/frontend/src/types/Api.ts b/frontend/src/types/SnackbarStatus.ts similarity index 63% rename from frontend/src/types/Api.ts rename to frontend/src/types/SnackbarStatus.ts index bf1b78a..2035cc3 100644 --- a/frontend/src/types/Api.ts +++ b/frontend/src/types/SnackbarStatus.ts @@ -1,4 +1,4 @@ -export type ApiResponseStatusSnackbar = { +export type SnackbarStatus = { open: boolean; severity: "success" | "error"; message: string; diff --git a/frontend/src/types/Ticket.ts b/frontend/src/types/Ticket.ts index cc2e81b..10a2bde 100644 --- a/frontend/src/types/Ticket.ts +++ b/frontend/src/types/Ticket.ts @@ -10,3 +10,12 @@ export type Ticket = { status: TicketStatus; author: User; }; + +export type NewTicket = { + title: string; + description: string; +}; + +export type UpdateTicket = NewTicket & { + id: string; +};