diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/exception/UserFavoriteNotFoundException.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/exception/UserFavoriteNotFoundException.java new file mode 100644 index 00000000..5d931aa3 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/exception/UserFavoriteNotFoundException.java @@ -0,0 +1,9 @@ +package ca.bc.gov.restapi.results.common.exception; + +public class UserFavoriteNotFoundException extends NotFoundGenericException { + + public UserFavoriteNotFoundException() { + super("UserFavoriteEntity"); + } + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpoint.java new file mode 100644 index 00000000..d766e9f0 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpoint.java @@ -0,0 +1,40 @@ +package ca.bc.gov.restapi.results.postgres.endpoint; + +import ca.bc.gov.restapi.results.postgres.service.UserOpeningService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(path = "/api/openings/favorites", produces = MediaType.APPLICATION_JSON_VALUE) +@RequiredArgsConstructor +public class OpeningFavoriteEndpoint { + + private final UserOpeningService userOpeningService; + + @GetMapping + public List getFavorites() { + return userOpeningService.listUserFavoriteOpenings(); + } + + @PutMapping("/{id}") + @ResponseStatus(HttpStatus.ACCEPTED) + public void addToFavorites(@PathVariable Long id) { + userOpeningService.addUserFavoriteOpening(id); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void removeFromFavorites(@PathVariable Long id) { + userOpeningService.removeUserFavoriteOpening(id); + } + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserOpeningEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserOpeningEndpoint.java index c1bb619f..3b519f43 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserOpeningEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/UserOpeningEndpoint.java @@ -45,8 +45,8 @@ public ResponseEntity> getUserTrackedOpenings() * @return HTTP status code 201 if success, no response body. */ @PostMapping("/{id}") - public ResponseEntity saveUserOpening(Long id) { - userOpeningService.saveOpeningToUser(id); + public ResponseEntity saveUserOpening(@PathVariable Long id) { + userOpeningService.addUserFavoriteOpening(id); return ResponseEntity.status(HttpStatus.CREATED).build(); } @@ -60,7 +60,7 @@ public ResponseEntity saveUserOpening(Long id) { public ResponseEntity deleteUserOpening( @PathVariable Long id) { - userOpeningService.deleteOpeningFromUserFavourite(id); + userOpeningService.removeUserFavoriteOpening(id); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningService.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningService.java index 421ca6b5..448892c2 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningService.java @@ -1,7 +1,14 @@ package ca.bc.gov.restapi.results.postgres.service; +import ca.bc.gov.restapi.results.common.exception.OpeningNotFoundException; +import ca.bc.gov.restapi.results.common.exception.UserFavoriteNotFoundException; import ca.bc.gov.restapi.results.common.exception.UserOpeningNotFoundException; import ca.bc.gov.restapi.results.common.security.LoggedUserService; +import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; +import ca.bc.gov.restapi.results.oracle.entity.OpeningEntity; +import ca.bc.gov.restapi.results.oracle.enums.OpeningCategoryEnum; +import ca.bc.gov.restapi.results.oracle.enums.OpeningStatusEnum; +import ca.bc.gov.restapi.results.oracle.repository.OpeningRepository; import ca.bc.gov.restapi.results.postgres.dto.MyRecentActionsRequestsDto; import ca.bc.gov.restapi.results.postgres.entity.OpeningsActivityEntity; import ca.bc.gov.restapi.results.postgres.entity.UserOpeningEntity; @@ -11,13 +18,14 @@ import jakarta.transaction.Transactional; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.ocpsoft.prettytime.PrettyTime; import org.springframework.stereotype.Service; -/** This class contains methods for handling User favourite Openings. */ +/** + * This class contains methods for handling User favourite Openings. + */ @Slf4j @Service @RequiredArgsConstructor @@ -29,6 +37,8 @@ public class UserOpeningService { private final OpeningsActivityRepository openingsActivityRepository; + private final OpeningRepository openingRepository; + /** * Gets user's tracked Openings. * @@ -78,46 +88,63 @@ public List getUserTrackedOpenings() { return resultList; } - /** - * Saves one or more Openings IDs to an user. - * - * @param openingId The opening ID. - */ - @Transactional - public void saveOpeningToUser(Long openingId) { - log.info("Opening ID to save in the user favourites: {}", openingId); + public List listUserFavoriteOpenings() { + log.info("Loading user favorite openings for {}", loggedUserService.getLoggedUserId()); - final String userId = loggedUserService.getLoggedUserId(); + List userList = userOpeningRepository + .findAllByUserId(loggedUserService.getLoggedUserId()); - UserOpeningEntity entity = new UserOpeningEntity(); - entity.setUserId(userId); - entity.setOpeningId(openingId); + if (userList.isEmpty()) { + log.info("No saved openings for {}", loggedUserService.getLoggedUserId()); + return List.of(); + } - userOpeningRepository.saveAndFlush(entity); - log.info("Opening ID saved in the user's favourites!"); + return + userList + .stream() + .map(UserOpeningEntity::getOpeningId) + .toList(); } - /** - * Deletes one or more user opening from favourite. - * - * @param openingId The opening ID. - */ @Transactional - public void deleteOpeningFromUserFavourite(Long openingId) { - log.info("Opening ID to delete from the user's favourites: {}", openingId); - String userId = loggedUserService.getLoggedUserId(); - - UserOpeningEntityId openingPk = new UserOpeningEntityId(userId, openingId); + public void addUserFavoriteOpening(Long openingId) { + log.info("Adding opening ID {} as favorite for user {}", openingId, + loggedUserService.getLoggedUserId()); - Optional userOpeningsOp = userOpeningRepository.findById(openingPk); - - if (userOpeningsOp.isEmpty()) { - log.info("Opening id {} not found in the user's favourite list!", openingId); - throw new UserOpeningNotFoundException(); + if (openingRepository.findById(openingId).isEmpty()) { + log.info("Opening ID not found: {}", openingId); + throw new OpeningNotFoundException(); } - userOpeningRepository.delete(userOpeningsOp.get()); - userOpeningRepository.flush(); - log.info("Opening ID deleted from the favourites!"); + log.info("Opening ID {} added as favorite for user {}", openingId, + loggedUserService.getLoggedUserId()); + userOpeningRepository.saveAndFlush( + new UserOpeningEntity( + loggedUserService.getLoggedUserId(), + openingId + ) + ); + } + + @Transactional + public void removeUserFavoriteOpening(Long openingId) { + log.info("Removing opening ID {} from the favorites for user {}", openingId, + loggedUserService.getLoggedUserId()); + userOpeningRepository.findById( + new UserOpeningEntityId( + loggedUserService.getLoggedUserId(), + openingId + ) + ).ifPresentOrElse( + userOpening -> { + userOpeningRepository.delete(userOpening); + userOpeningRepository.flush(); + log.info("Opening ID deleted from the favourites!"); + }, + () -> { + log.info("Opening id {} not found in the user's favourite list!", openingId); + throw new UserFavoriteNotFoundException(); + } + ); } } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpointIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpointIntegrationTest.java new file mode 100644 index 00000000..3f064214 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/OpeningFavoriteEndpointIntegrationTest.java @@ -0,0 +1,133 @@ +package ca.bc.gov.restapi.results.postgres.endpoint; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import ca.bc.gov.restapi.results.postgres.repository.UserOpeningRepository; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +@DisplayName("Integration Test | Favorite Openings Endpoint") +@TestMethodOrder(OrderAnnotation.class) +@WithMockJwt +@AutoConfigureMockMvc +class OpeningFavoriteEndpointIntegrationTest extends AbstractTestContainerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserOpeningRepository userOpeningRepository; + + @Test + @Order(1) + @DisplayName("No favorites to begin with") + void shouldBeEmpty() throws Exception { + + mockMvc + .perform( + MockMvcRequestBuilders.get("/api/openings/favorites") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @Order(2) + @DisplayName("Should add to favorite") + void shouldAddToFavorite() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.put("/api/openings/favorites/{openingId}", 101) + .with(csrf().asHeader()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isAccepted()) + .andExpect(content().string(StringUtils.EMPTY)); + + assertThat(userOpeningRepository.findAll()) + .isNotNull() + .isNotEmpty() + .hasSize(1); + } + + @Test + @Order(3) + @DisplayName("Should not add to favorite if doesn't exist") + void shouldNotAddIfDoesNotExist() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.put("/api/openings/favorites/{openingId}", 987) + .with(csrf().asHeader()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string(StringUtils.EMPTY)); + //.andExpect(content().string("UserOpening record(s) not found!")); + } + + @Test + @Order(4) + @DisplayName("Multiple requests to add to favorite should not fail, nor duplicate") + void shouldAddToFavoriteAgain() throws Exception { + shouldAddToFavorite(); + } + + @Test + @Order(5) + @DisplayName("Should see list of favorites") + void shouldBeAbleToSeeOpening() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.get("/api/openings/favorites") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.[0]").value(101)); + } + + @Test + @Order(6) + @DisplayName("Should remove from favorite") + void shouldRemoveFromFavorites() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.delete("/api/openings/favorites/{openingId}", 101) + .with(csrf().asHeader()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andExpect(content().string(StringUtils.EMPTY)); + + assertThat(userOpeningRepository.findAll()) + .isNotNull() + .isEmpty(); + } + + @Test + @Order(7) + @DisplayName("Should thrown an error if trying to remove entry that doesn't exist") + void shouldThrownErrorIfNoFavoriteFound() throws Exception { + mockMvc + .perform( + MockMvcRequestBuilders.delete("/api/openings/favorites/{openingId}", 101) + .with(csrf().asHeader()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(content().string(StringUtils.EMPTY)); + + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningServiceTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningServiceTest.java index 89bbcc27..eb6a30b5 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/UserOpeningServiceTest.java @@ -4,8 +4,11 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import ca.bc.gov.restapi.results.common.exception.UserFavoriteNotFoundException; import ca.bc.gov.restapi.results.common.exception.UserOpeningNotFoundException; import ca.bc.gov.restapi.results.common.security.LoggedUserService; +import ca.bc.gov.restapi.results.oracle.entity.OpeningEntity; +import ca.bc.gov.restapi.results.oracle.repository.OpeningRepository; import ca.bc.gov.restapi.results.postgres.dto.MyRecentActionsRequestsDto; import ca.bc.gov.restapi.results.postgres.entity.OpeningsActivityEntity; import ca.bc.gov.restapi.results.postgres.entity.UserOpeningEntity; @@ -31,6 +34,8 @@ class UserOpeningServiceTest { @Mock OpeningsActivityRepository openingsActivityRepository; + @Mock OpeningRepository openingRepository; + private UserOpeningService userOpeningService; private static final String USER_ID = "TEST"; @@ -39,7 +44,7 @@ class UserOpeningServiceTest { void setup() { this.userOpeningService = new UserOpeningService( - loggedUserService, userOpeningRepository, openingsActivityRepository); + loggedUserService, userOpeningRepository, openingsActivityRepository,openingRepository); } @Test @@ -90,15 +95,16 @@ void getUserTrackedOpenings_noData_shouldSucceed() { @Test @DisplayName("Save opening to user happy path should succeed") - void saveOpeningToUser_happyPath_shouldSucceed() { + void addUser_FavoriteOpening_happyPath_shouldSucceed() { when(loggedUserService.getLoggedUserId()).thenReturn(USER_ID); + when(openingRepository.findById(any())).thenReturn(Optional.of(new OpeningEntity())); when(userOpeningRepository.saveAndFlush(any())).thenReturn(new UserOpeningEntity()); - userOpeningService.saveOpeningToUser(112233L); + userOpeningService.addUserFavoriteOpening(112233L); } @Test @DisplayName("Delete opening from user's favourite happy path should succeed") - void deleteOpeningFromUserFavourite_happyPath_shouldSucceed() { + void removeUserFavoriteOpening_happyPath_shouldSucceed() { when(loggedUserService.getLoggedUserId()).thenReturn(USER_ID); UserOpeningEntity userEntity = new UserOpeningEntity(); @@ -107,19 +113,19 @@ void deleteOpeningFromUserFavourite_happyPath_shouldSucceed() { doNothing().when(userOpeningRepository).delete(any()); doNothing().when(userOpeningRepository).flush(); - userOpeningService.deleteOpeningFromUserFavourite(112233L); + userOpeningService.removeUserFavoriteOpening(112233L); } @Test @DisplayName("Delete opening from user's favourite not found should fail") - void deleteOpeningFromUserFavourite_notFound_shouldFail() { + void removeUserFavoriteOpening_notFound_shouldFail() { when(loggedUserService.getLoggedUserId()).thenReturn(USER_ID); when(userOpeningRepository.findById(any())).thenReturn(Optional.empty()); Assertions.assertThrows( - UserOpeningNotFoundException.class, + UserFavoriteNotFoundException.class, () -> { - userOpeningService.deleteOpeningFromUserFavourite(112233L); + userOpeningService.removeUserFavoriteOpening(112233L); }); } } diff --git a/frontend/src/__test__/components/FavoriteButton.test.tsx b/frontend/src/__test__/components/FavoriteButton.test.tsx index 152a8a3a..39e31f27 100644 --- a/frontend/src/__test__/components/FavoriteButton.test.tsx +++ b/frontend/src/__test__/components/FavoriteButton.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import FavoriteButton from '../../components/FavoriteButton'; import '@testing-library/jest-dom'; +import { on } from 'events'; describe('FavoriteButton Component', () => { const props = { @@ -10,6 +11,8 @@ describe('FavoriteButton Component', () => { kind: 'ghost', size: 'md', fill: 'red', + favorited: false, + onFavoriteChange: vi.fn(), }; it('should render the component with default state', () => { @@ -33,5 +36,12 @@ describe('FavoriteButton Component', () => { const imgElement = screen.getByTestId('favourite-button-icon'); expect(imgElement).toHaveStyle('fill: red'); }); + + it('should call onFavoriteChange with the new favorite state', () => { + render(); + const buttonElement = screen.getByRole('button'); + fireEvent.click(buttonElement); + expect(props.onFavoriteChange).toHaveBeenCalledWith(false); + }); }); diff --git a/frontend/src/__test__/components/MyProfile.test.tsx b/frontend/src/__test__/components/MyProfile.test.tsx index 5e723ce3..078e5ce9 100644 --- a/frontend/src/__test__/components/MyProfile.test.tsx +++ b/frontend/src/__test__/components/MyProfile.test.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import React from 'react'; import { render, act, waitFor, fireEvent, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import MyProfile from '../../components/MyProfile'; diff --git a/frontend/src/__test__/components/OpeningHistory.test.tsx b/frontend/src/__test__/components/OpeningHistory.test.tsx new file mode 100644 index 00000000..e65b5a08 --- /dev/null +++ b/frontend/src/__test__/components/OpeningHistory.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import OpeningHistory from '../../components/OpeningHistory'; +import History from '../../types/History'; +import { deleteOpeningFavorite } from '../../services/OpeningFavoriteService'; + +const mockHistories: History[] = [ + { + id: 1, + steps: [], + }, + { + id: 2, + steps: [ + { step: 1, status: 'complete', description: 'Step 1', subtitle: 'Completed' }, + { step: 2, status: 'invalid', description: 'Step 2', subtitle: 'Invalid' }, + { step: 3, status: 'disabled', description: 'Step 3', subtitle: 'Disabled' }, + ], + }, +]; + +vi.mock('../../services/OpeningFavoriteService', () => ({ + deleteOpeningFavorite: vi.fn(), +})); + +describe('OpeningHistory Component', () => { + it('renders correctly with given histories', async () => { + let getByText; + await act(async () => { + ({ getByText } = render( )); + }); + + // Check for the presence of Opening Ids + expect(getByText('Opening Id 1')).toBeInTheDocument(); + expect(getByText('Opening Id 2')).toBeInTheDocument(); + + // Check for the presence of step descriptions + expect(getByText('Step 1')).toBeInTheDocument(); + expect(getByText('Step 2')).toBeInTheDocument(); + expect(getByText('Step 3')).toBeInTheDocument(); + }); + + it('renders correctly with empty histories', async () => { + let container; + await act(async () => { + ({ container } = render( )); + }); + + // Select the div with the specific class + const activityHistoryContainer = container.querySelector('.row.activity-history-container.gx-4'); + + // Check if the container is empty + expect(activityHistoryContainer).toBeInTheDocument(); // Ensure the element exists + expect(activityHistoryContainer?.children.length).toBe(0); // Confirm it's empty by checking for no children + }); + + // check if when clicked on the FavoriteButton, the deleteOpeningFavorite function is called + it('should call deleteOpeningFavorite when FavoriteButton is clicked', async () => { + let container; + await act(async () => { + ({ container } = render( )); + }); + + const favoriteButton = container.querySelector('.favorite-icon button') + favoriteButton && favoriteButton.click(); + expect(deleteOpeningFavorite).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__test__/components/OpeningMetricsTab.test.tsx b/frontend/src/__test__/components/OpeningMetricsTab.test.tsx new file mode 100644 index 00000000..75a27d69 --- /dev/null +++ b/frontend/src/__test__/components/OpeningMetricsTab.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import React from 'react'; +import { render, act, waitFor, fireEvent, screen } from '@testing-library/react'; +import OpeningMetricsTab from '../../components/OpeningMetricsTab'; +import { fetchOpeningTrends } from '../../services/OpeningFavoriteService'; +import { fetchFreeGrowingMilestones, fetchOpeningsPerYear, fetchRecentOpenings } from '../../services/OpeningService'; + +vi.mock('../../services/OpeningFavoriteService', () => ({ + fetchOpeningTrends: vi.fn(), +})); +vi.mock('../../services/OpeningService', async () => { + const actual = await vi.importActual('../../services/OpeningService'); + return { + ...actual, + fetchRecentOpenings: vi.fn(), + fetchOpeningsPerYear: vi.fn(), + fetchFreeGrowingMilestones: vi.fn(), + }; +}); + +describe('OpeningMetricsTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + (fetchRecentOpenings as vi.Mock).mockResolvedValue([{ + id: '123', + openingId: '123', + fileId: '1', + cuttingPermit: '1', + timberMark: '1', + cutBlock: '1', + grossAreaHa: 1, + statusDesc: 'Approved', + categoryDesc: 'Another:Another', + disturbanceStart: '1', + entryTimestamp: '1', + updateTimestamp: '1', + }]); + (fetchOpeningsPerYear as vi.Mock).mockResolvedValue([ + { group: '2022', key: 'Openings', value: 10 }, + { group: '2023', key: 'Openings', value: 15 }, + ]); + (fetchFreeGrowingMilestones as vi.Mock).mockResolvedValue([{ group: '1-5', value: 11 }]); + (fetchOpeningTrends as vi.Mock).mockResolvedValue([1, 2, 3]); + + }); + + it('should render the OpeningMetricsTab component with all sections', async () => { + + await act(async () => render()); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Manage and track silvicultural information about openings')).toBeInTheDocument(); + expect(screen.getByText('Openings submission trends')).toBeInTheDocument(); + expect(screen.getByText('Check quantity and evolution of openings')).toBeInTheDocument(); + expect(screen.getByText('Track Openings')).toBeInTheDocument(); + expect(screen.getByText('Follow your favourite openings')).toBeInTheDocument(); + expect(screen.getByText('Free growing milestone declarations')).toBeInTheDocument(); + expect(screen.getByText('Check opening standards unit for inspections purposes')).toBeInTheDocument(); + expect(screen.getByText('My recent actions')).toBeInTheDocument(); + expect(screen.getByText('Check your recent requests and files')).toBeInTheDocument(); + }); + + it('should call fetchOpeningTrends and set submissionTrends state', async () => { + + + await act(async () => { + render(); + }); + + await waitFor(() => { + expect(fetchOpeningTrends).toHaveBeenCalled(); + expect(screen.getByText('Opening Id 1')).toBeInTheDocument(); + expect(screen.getByText('Opening Id 2')).toBeInTheDocument(); + expect(screen.getByText('Opening Id 3')).toBeInTheDocument(); + }); + }); + + it('should scroll to "Track Openings" section when scrollTo parameter is "trackOpenings"', async () => { + + const mockScrollIntoView = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + + const originalLocation = window.location; + delete window.location; + window.location = { search: '?scrollTo=trackOpenings' } as any; + + await act(async () => render()); + + expect(mockScrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' }); + + window.location = originalLocation; + }); + + it('should not scroll to "Track Openings" section when scrollTo parameter is not "trackOpenings"', async () => { + + const mockScrollIntoView = vi.fn(); + window.HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + + const originalLocation = window.location; + delete window.location; + window.location = { search: '' } as any; + + await act(async () => render()); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + + window.location = originalLocation; + }); +}); \ No newline at end of file diff --git a/frontend/src/__test__/screens/Opening.test.tsx b/frontend/src/__test__/screens/Opening.test.tsx index 0535dc88..8e4d3970 100644 --- a/frontend/src/__test__/screens/Opening.test.tsx +++ b/frontend/src/__test__/screens/Opening.test.tsx @@ -1,11 +1,14 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { render, waitFor, act } from '@testing-library/react'; import Opening from '../../screens/Opening'; import PaginationContext from '../../contexts/PaginationContext'; import { BrowserRouter } from 'react-router-dom'; import * as redux from 'react-redux'; import { RecentOpening } from '../../types/RecentOpening'; +import { getWmsLayersWhitelistUsers } from '../../services/SecretsService'; +import { fetchFreeGrowingMilestones, fetchOpeningsPerYear, fetchRecentOpenings } from '../../services/OpeningService'; +import { fetchOpeningTrends } from '../../services/OpeningFavoriteService'; const data = { "activityType": "Update", @@ -16,53 +19,24 @@ const data = { "lastUpdated": "2024-05-16T19:59:21.635Z" }; -vi.mock('../../services/SecretsService', () => ({ - getWmsLayersWhitelistUsers: vi.fn(() => [ - { - userName: 'TEST' - } - ]) +vi.mock('../../services/OpeningFavoriteService', () => ({ + fetchOpeningTrends: vi.fn(), })); -vi.mock('../../services/OpeningService', () => ({ - fetchRecentOpenings: vi.fn(() => [ - { - id: '123', - openingId: '111', - fileId: 'FS7', - cuttingPermit: 'SS', - timberMark: '207S', - cutBlock: '111', - grossAreaHa: 265, - statusDesc: 'Approved', - categoryDesc: 'FTML', - disturbanceStart: '2023-01-02', - entryTimestamp: '', - updateTimestamp: '' - } - ]), - fetchOpeningsPerYear: vi.fn(() => Promise.resolve([ - { group: '2022', key: 'Openings', value: 10 }, - { group: '2023', key: 'Openings', value: 15 }, - ])), - fetchFreeGrowingMilestones: vi.fn(() => Promise.resolve([ - { - group: '1-5', - value: 11 - } - ])), - fetchRecentActions: vi.fn(() => [ - { - activityType: data.activityType, - openingId: data.openingId.toString(), - statusCode: data.statusCode, - statusDescription: data.statusDescription, - lastUpdated: data.lastUpdated, - lastUpdatedLabel: data.lastUpdatedLabel - } - ]), +vi.mock('../../services/SecretsService', () => ({ + getWmsLayersWhitelistUsers: vi.fn() })); +vi.mock('../../services/OpeningService', async () => { + const actual = await vi.importActual('../../services/OpeningService'); + return { + ...actual, + fetchRecentOpenings: vi.fn(), + fetchOpeningsPerYear: vi.fn(), + fetchFreeGrowingMilestones: vi.fn(), + }; +}); + const state = { userDetails: { id: 1, @@ -100,6 +74,24 @@ const paginationValueMock = { }; describe('Opening screen test cases', () => { + + beforeEach(() => { + vi.clearAllMocks(); + + (getWmsLayersWhitelistUsers as vi.Mock).mockResolvedValue([{userName: 'TEST'}]); + (fetchRecentOpenings as vi.Mock).mockResolvedValue(rows); + (fetchOpeningsPerYear as vi.Mock).mockResolvedValue([ + { group: '2022', key: 'Openings', value: 10 }, + { group: '2023', key: 'Openings', value: 15 }, + ]); + (fetchFreeGrowingMilestones as vi.Mock).mockResolvedValue([{ group: '1-5', value: 11 }]); + (fetchOpeningTrends as vi.Mock).mockResolvedValue([1,2,3]); + + + + + }); + it('should renders Opening Page Title component', async () => { const { getByTestId } = render( @@ -115,4 +107,84 @@ describe('Opening screen test cases', () => { //const subtitle = 'Create, manage or check opening information'; //expect(screen.getByText(subtitle)).toBeDefined(); }); + + describe('FavoriteCards test cases', () => { + + it('should render FavoriteCard component', async () => { + + let container: HTMLElement = document.createElement('div'); + await act(async () => { + ({ container } = render( + + + + + + )); + }); + + // check if first FavoriteCard has the correct title and is active + expect(container.querySelector('#fav-card-1')).toBeDefined(); + expect(container.querySelector('#fav-card-1')?.textContent).toContain('Silviculture search'); + expect(container.querySelector('#fav-card-1')?.className).not.contain('cds--link--disable'); + + // check if the second FavoriteCard has the correct title and is inactive + expect(container.querySelector('#fav-card-2')).toBeDefined(); + expect(container.querySelector('#fav-card-2')?.textContent).toContain('Create an opening'); + expect(container.querySelector('#fav-card-2')?.className).toContain('cds--link--disable'); + + // check if the third FavoriteCard has the correct title and is inactive + expect(container.querySelector('#fav-card-3')).toBeDefined(); + expect(container.querySelector('#fav-card-3')?.textContent).toContain('Reports'); + expect(container.querySelector('#fav-card-3')?.className).toContain('cds--link--disable'); + + // check if the fourth FavoriteCard has the correct title and is inactive + expect(container.querySelector('#fav-card-4')).toBeDefined(); + expect(container.querySelector('#fav-card-4')?.textContent).toContain('Upcoming activities'); + expect(container.querySelector('#fav-card-4')?.className).toContain('cds--link--disable'); + + }); + + it('should not render tab when not selected', async () => { + let container: HTMLElement = document.createElement('div'); + let getByText: any; + await act(async () => { + ({ container, getByText } = render( + + + + + + )); + }); + + // check if the tab is not rendered + expect(container.querySelector('div.tab-openings')?.childNodes).toHaveLength(2); + expect(container.querySelector('div.tab-metrics')?.childNodes).toHaveLength(0); + + }); + + it('should render tab only when selected', async () => { + let container: HTMLElement = document.createElement('div'); + let getByText: any; + await act(async () => { + ({ container, getByText } = render( + + + + + + )); + }); + + await act(async () => getByText('Dashboard').click()); + + expect(container.querySelector('div.tab-openings')?.childNodes).toHaveLength(2); + expect(container.querySelector('div.tab-metrics')?.childNodes).toHaveLength(1); + + }); + + + }); + }); diff --git a/frontend/src/__test__/services/OpeningFavoriteService.test.ts b/frontend/src/__test__/services/OpeningFavoriteService.test.ts new file mode 100644 index 00000000..d24962fa --- /dev/null +++ b/frontend/src/__test__/services/OpeningFavoriteService.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi } from 'vitest'; +import axios from 'axios'; +import { fetchOpeningTrends} from '../../services/OpeningFavoriteService'; +import { getAuthIdToken } from '../../services/AuthService'; +import { env } from '../../env'; +import { fetchOpeningTrends, setOpeningFavorite, deleteOpeningFavorite } from '../../services/OpeningFavoriteService'; + +vi.mock('axios'); +vi.mock('../../services/AuthService'); + +describe('OpeningFavoriteService', () => { + const backendUrl = env.VITE_BACKEND_URL; + const authToken = 'test-token'; + + beforeEach(() => { + vi.clearAllMocks(); + (getAuthIdToken as vi.Mock).mockReturnValue(authToken); + }); + + it('should fetch submission trends successfully', async () => { + const mockData = [1, 2, 3]; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const result = await fetchOpeningTrends(); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual(mockData); + }); + + it('should fetch submission trends with empty results', async () => { + const mockData = []; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const result = await fetchOpeningTrends(); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual(mockData); + }); + + it('should handle error while fetching submission trends', async () => { + (axios.get as vi.Mock).mockRejectedValue(new Error('Network Error')); + + await expect(fetchOpeningTrends()).rejects.toThrow('Network Error'); + }); + + it('should fetch submission trends successfully', async () => { + const mockData = [1, 2, 3]; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const result = await fetchOpeningTrends(); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual(mockData); + }); + + it('should fetch submission trends with empty results', async () => { + const mockData = []; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const result = await fetchOpeningTrends(); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual(mockData); + }); + + it('should handle error while fetching submission trends', async () => { + (axios.get as vi.Mock).mockRejectedValue(new Error('Network Error')); + + await expect(fetchOpeningTrends()).rejects.toThrow('Network Error'); + }); + + it('should set an opening as favorite successfully', async () => { + const openingId = 1; + (axios.put as vi.Mock).mockResolvedValue({ status: 202 }); + + await setOpeningFavorite(openingId); + + expect(axios.put).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites/${openingId}`, null, { + headers: { Authorization: `Bearer ${authToken}` } + }); + }); + + it('should throw an error if setting an opening as favorite fails', async () => { + const openingId = 1; + (axios.put as vi.Mock).mockResolvedValue({ status: 500 }); + + await expect(setOpeningFavorite(openingId)).rejects.toThrow('Failed to set favorite opening. Status code: 500'); + }); + + it('should delete a favorite opening successfully', async () => { + const openingId = 1; + (axios.delete as vi.Mock).mockResolvedValue({ status: 204 }); + + await deleteOpeningFavorite(openingId); + + expect(axios.delete).toHaveBeenCalledWith(`${backendUrl}/api/openings/favorites/${openingId}`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + }); + + it('should throw an error if deleting a favorite opening fails', async () => { + const openingId = 1; + (axios.delete as vi.Mock).mockResolvedValue({ status: 500 }); + + await expect(deleteOpeningFavorite(openingId)).rejects.toThrow('Failed to remove favorite opening. Status code: 500'); + }); + +}); \ No newline at end of file diff --git a/frontend/src/__test__/services/OpeningService.test.ts b/frontend/src/__test__/services/OpeningService.test.ts new file mode 100644 index 00000000..6dd4f320 --- /dev/null +++ b/frontend/src/__test__/services/OpeningService.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi } from 'vitest'; +import axios from 'axios'; +import { + fetchRecentOpenings, + fetchOpeningsPerYear, + fetchFreeGrowingMilestones, + fetchRecentActions +} from '../../services/OpeningService'; +import { getAuthIdToken } from '../../services/AuthService'; +import { env } from '../../env'; + +vi.mock('axios'); +vi.mock('../../services/AuthService'); + +describe('OpeningService', () => { + const backendUrl = env.VITE_BACKEND_URL; + const authToken = 'test-token'; + + beforeEach(() => { + vi.clearAllMocks(); + (getAuthIdToken as vi.Mock).mockReturnValue(authToken); + }); + + describe('fetchRecentOpenings', () => { + it('should fetch recent openings successfully', async () => { + const mockData = { + data: [ + { + openingId: 1, + forestFileId: '123', + cuttingPermit: '456', + timberMark: '789', + cutBlock: 'A', + grossAreaHa: 10, + status: { description: 'Active' }, + category: { description: 'Category1' }, + disturbanceStart: '2023-01-01', + entryTimestamp: '2023-01-01T00:00:00Z', + updateTimestamp: '2023-01-02T00:00:00Z' + } + ] + }; + (axios.get as vi.Mock).mockResolvedValue({ status: 200, data: mockData }); + + const result = await fetchRecentOpenings(); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/recent-openings?page=0&perPage=100`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual([ + { + id: '1', + openingId: '1', + forestFileId: '123', + cuttingPermit: '456', + timberMark: '789', + cutBlock: 'A', + grossAreaHa: '10', + status: 'Active', + category: 'Category1', + disturbanceStart: '2023-01-01', + entryTimestamp: '2023-01-01', + updateTimestamp: '2023-01-02' + } + ]); + }); + + it('should handle error while fetching recent openings', async () => { + (axios.get as vi.Mock).mockRejectedValue(new Error('Network Error')); + + await expect(fetchRecentOpenings()).rejects.toThrow('Network Error'); + }); + }); + + describe('fetchOpeningsPerYear', () => { + it('should fetch openings per year successfully', async () => { + const mockData = [ + { monthName: 'January', amount: 10 }, + { monthName: 'February', amount: 20 } + ]; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const props = { orgUnitCode: '001', statusCode: 'APP', entryDateStart: '2023-01-01', entryDateEnd: '2023-12-31' }; + const result = await fetchOpeningsPerYear(props); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/dashboard-metrics/submission-trends?orgUnitCode=001&statusCode=APP&entryDateStart=2023-01-01&entryDateEnd=2023-12-31`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual([ + { group: 'Openings', key: 'January', value: 10 }, + { group: 'Openings', key: 'February', value: 20 } + ]); + }); + + it('should handle error while fetching openings per year', async () => { + (axios.get as vi.Mock).mockRejectedValue(new Error('Network Error')); + + await expect(fetchOpeningsPerYear({})).rejects.toThrow('Network Error'); + }); + }); + + describe('fetchFreeGrowingMilestones', () => { + it('should fetch free growing milestones successfully', async () => { + const mockData = [ + { label: 'Milestone1', amount: 10 }, + { label: 'Milestone2', amount: 20 } + ]; + (axios.get as vi.Mock).mockResolvedValue({ data: mockData }); + + const props = { orgUnitCode: '001', clientNumber: '123', entryDateStart: '2023-01-01', entryDateEnd: '2023-12-31' }; + const result = await fetchFreeGrowingMilestones(props); + + expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/dashboard-metrics/free-growing-milestones?orgUnitCode=001&clientNumber=123&entryDateStart=2023-01-01&entryDateEnd=2023-12-31`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + expect(result).toEqual([ + { group: 'Milestone1', value: 10 }, + { group: 'Milestone2', value: 20 } + ]); + }); + + it('should handle error while fetching free growing milestones', async () => { + (axios.get as vi.Mock).mockRejectedValue(new Error('Network Error')); + + await expect(fetchFreeGrowingMilestones({})).rejects.toThrow('Network Error'); + }); + }); + + describe('fetchRecentActions', () => { + it('should fetch recent actions successfully', () => { + const result = fetchRecentActions(); + + expect(result).toEqual([ + { + activityType: 'Update', + openingId: '1541297', + statusCode: 'APP', + statusDescription: 'Approved', + lastUpdatedLabel: '1 minute ago', + lastUpdated: '2024-05-16T19:59:21.635Z' + } + ]); + }); + + }); +}); \ No newline at end of file diff --git a/frontend/src/components/DoughnutChartView/index.tsx b/frontend/src/components/DoughnutChartView/index.tsx index 07f52861..0855b351 100644 --- a/frontend/src/components/DoughnutChartView/index.tsx +++ b/frontend/src/components/DoughnutChartView/index.tsx @@ -2,7 +2,8 @@ import React, { useState, useEffect, ChangeEvent, useCallback } from "react"; import { DonutChart } from "@carbon/charts-react"; import { Dropdown, DatePicker, DatePickerInput, TextInput } from "@carbon/react"; import "./DoughnutChartView.scss"; -import { IFreeGrowingChartData, fetchFreeGrowingMilestones } from "../../services/OpeningService"; +import { fetchFreeGrowingMilestones } from "../../services/OpeningService"; +import { IFreeGrowingChartData } from "../../types/OpeningTypes"; interface IDropdownItem { value: string, diff --git a/frontend/src/components/FavoriteButton/index.tsx b/frontend/src/components/FavoriteButton/index.tsx index 9d1c7513..8d8cb11a 100644 --- a/frontend/src/components/FavoriteButton/index.tsx +++ b/frontend/src/components/FavoriteButton/index.tsx @@ -8,6 +8,8 @@ interface FavoriteButtonProps { kind: string; size: string; fill: string; + favorited: boolean; + onFavoriteChange: (newStatus: boolean) => void; } /** @@ -18,6 +20,7 @@ interface FavoriteButtonProps { * @param {string} props.kind - The favourite button kind. * @param {string} props.size - The favourite button size. * @param {string} props.fill - The favourite button fill. + * @param {boolean} props.favorited - The favourite button state. * @returns {JSX.Element} The FavoriteButton element to be rendered. */ function FavoriteButton({ @@ -25,11 +28,14 @@ function FavoriteButton({ kind, size, fill, + favorited = false, + onFavoriteChange }: FavoriteButtonProps): JSX.Element { - const [isFavorite, setIsFavorite] = useState(false); + const [isFavorite, setIsFavorite] = useState(favorited); const handleClick = () => { setIsFavorite(!isFavorite); + onFavoriteChange(!isFavorite); }; const iconName = isFavorite ? 'FavoriteFilled' : 'Favorite'; @@ -39,7 +45,6 @@ function FavoriteButton({ if (!Icon) { return
Invalid icon name
; } - const CustomIcon = () => ; return ( diff --git a/frontend/src/components/OpeningHistory/index.tsx b/frontend/src/components/OpeningHistory/index.tsx index 6dc6d22e..cad8b441 100644 --- a/frontend/src/components/OpeningHistory/index.tsx +++ b/frontend/src/components/OpeningHistory/index.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { ProgressIndicator, ProgressStep @@ -10,10 +8,22 @@ import statusClass from '../../utils/HistoryStatus'; import FavoriteButton from '../FavoriteButton'; import './styles.scss'; +import { deleteOpeningFavorite } from '../../services/OpeningFavoriteService'; + interface OpeningHistoryProps { histories: History[]; } +const handleFavoriteChange = async (newStatus: boolean, openingId: number) => { + try { + if(!newStatus){ + await deleteOpeningFavorite(openingId); + } + } catch (error) { + console.error(`Failed to update favorite status for ${openingId}`); + } +}; + const OpeningHistory = ({ histories }: OpeningHistoryProps) => (
@@ -28,6 +38,8 @@ const OpeningHistory = ({ histories }: OpeningHistoryProps) => ( kind="ghost" size="sm" fill="#0073E6" + favorited={true} + onFavoriteChange={(newStatus: boolean) => handleFavoriteChange(newStatus, history.id)} />
{`Opening Id ${history.id}`} diff --git a/frontend/src/components/OpeningMetricsTab/index.tsx b/frontend/src/components/OpeningMetricsTab/index.tsx index 49203529..44282295 100644 --- a/frontend/src/components/OpeningMetricsTab/index.tsx +++ b/frontend/src/components/OpeningMetricsTab/index.tsx @@ -1,24 +1,35 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState } from "react"; import './styles.scss'; import SectionTitle from "../SectionTitle"; import BarChartGrouped from "../BarChartGrouped"; import ChartContainer from "../ChartContainer"; import DoughnutChartView from "../DoughnutChartView"; import OpeningHistory from "../OpeningHistory"; -import OpeningHistoryItems from "../../mock-data/OpeningHistoryItems"; +import History from "../../types/History"; import MyRecentActions from "../MyRecentActions"; +import { fetchOpeningTrends } from "../../services/OpeningFavoriteService"; const OpeningMetricsTab: React.FC = () => { const trackOpeningRef = useRef(null); + const [submissionTrends, setSubmissionTrends] = useState([]); // Optional: Scroll to "Track Openings" when this component mounts useEffect(() => { + const params = new URLSearchParams(window.location.search); const scrollToSection = params.get('scrollTo'); if (scrollToSection === 'trackOpenings' && trackOpeningRef.current) { trackOpeningRef.current.scrollIntoView({ behavior: "smooth" }); } + + const loadTrends = async () => { + const response = await fetchOpeningTrends(); + setSubmissionTrends(response.map(item => ({ id: item, steps: [] }))); + }; + + loadTrends(); + }, []); return ( @@ -39,7 +50,7 @@ const OpeningMetricsTab: React.FC = () => {
{/* Add ref here to scroll */}
diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index fd3b43c3..99c48fcd 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -42,6 +42,7 @@ import { } from "../../../../utils/fileConversions"; import { Tooltip } from "@carbon/react"; import { useNavigate } from "react-router-dom"; +import { setOpeningFavorite } from '../../../../services/OpeningFavoriteService'; interface ISearchScreenDataTable { rows: OpeningsSearch[]; @@ -83,23 +84,26 @@ const SearchScreenDataTable: React.FC = ({ }, [rows, totalItems]); // Function to handle row selection changes - const handleRowSelectionChanged = (rowId: string) => { + const handleRowSelectionChanged = (openingId: string) => { setSelectedRows((prevSelectedRows) => { - if (prevSelectedRows.includes(rowId)) { + if (prevSelectedRows.includes(openingId)) { // If the row is already selected, remove it from the selected rows - return prevSelectedRows.filter((id) => id !== rowId); + return prevSelectedRows.filter((id) => id !== openingId); } else { // If the row is not selected, add it to the selected rows - return [...prevSelectedRows, rowId]; + return [...prevSelectedRows, openingId]; } }); }; //Function to handle the favourite feature of the opening for a user - const handleFavouriteOpening = (rowId: string) => { - console.log(rowId + " has been added as a favourite for the user") - //make a call to the api for the favourite opening when ready - setToastText(`Following "OpeningID ${rowId}"`); + const handleFavouriteOpening = (openingId: string) => { + try{ + setOpeningFavorite(parseInt(openingId)); + setToastText(`Following "OpeningID ${openingId}"`); + } catch (error) { + console.error(`Failed to update favorite status for ${openingId}`); + } } return ( diff --git a/frontend/src/mock-data/OpeningHistoryItems.ts b/frontend/src/mock-data/OpeningHistoryItems.ts deleted file mode 100644 index 1aeff650..00000000 --- a/frontend/src/mock-data/OpeningHistoryItems.ts +++ /dev/null @@ -1,111 +0,0 @@ -import History from "../types/History"; - -const OpeningHistoryItems: History[] = [ - { - id: 1541297, - steps: [ - { - step: 5, - status: 'invalid', - description: 'Milestone overdue', - subtitle: 'Please, update the milestone' - }, - { - step: 4, - status: 'complete', - description: 'Activity completed', - subtitle: '2023-11-15' - }, - { - step: 3, - status: 'complete', - description: 'Forest cover polygon', - subtitle: '2023-11-02' - }, - { - step: 2, - status: 'complete', - description: 'Disturbance activity', - subtitle: '2023-10-30' - }, - { - step: 1, - status: 'complete', - description: 'Opening ID', - subtitle: '2023-10-18' - } - ] - }, - { - id: 1541298, - steps: [ - { - step: 5, - status: 'complete', - description: 'Forest Cover', - subtitle: 'Now' - }, - { - step: 4, - status: 'complete', - description: 'Activity Planned', - subtitle: 'Now' - }, - { - step: 3, - status: 'complete', - description: 'Forest cover polygon', - subtitle: '2023-10-31' - }, - { - step: 2, - status: 'complete', - description: 'Disturbance Activity', - subtitle: '2023-10-19' - }, - { - step: 1, - status: 'complete', - description: 'Opening ID', - subtitle: '2023-10-01' - } - ] - }, - { - id: 1541299, - steps: [ - { - step: 5, - status: 'invalid', - description: 'Forest cover polygon', - subtitle: 'Please update the forest cover' - }, - { - step: 4, - status: 'invalid', - description: 'Forest cover polygon', - subtitle: 'PLease update the forest cover' - }, - { - step: 3, - status: 'complete', - description: 'Forest cover polygon', - subtitle: '2023-11-01' - }, - { - step: 2, - status: 'complete', - description: 'Disturbance Activity', - subtitle: '2023-10-29' - }, - { - step: 1, - status: 'complete', - description: 'Opening ID', - subtitle: '2023-10-17' - } - ] - } -]; - -export default OpeningHistoryItems; \ No newline at end of file diff --git a/frontend/src/screens/Opening/index.tsx b/frontend/src/screens/Opening/index.tsx index 131ee0fc..b5220d72 100644 --- a/frontend/src/screens/Opening/index.tsx +++ b/frontend/src/screens/Opening/index.tsx @@ -12,6 +12,11 @@ import OpeningMetricsTab from "../../components/OpeningMetricsTab"; const Opening: React.FC = () => { const [showSpatial, setShowSpatial] = useState(false); + const [activeTab, setActiveTab] = useState(0); // Track active tab index + + const tabChange = (tabSelection:{selectedIndex: number}) => { + setActiveTab(tabSelection.selectedIndex); + }; useEffect(() => { // @@ -74,20 +79,20 @@ const Opening: React.FC = () => {
)} - +
Recent Openings
Dashboard
- + - - + + {activeTab === 1 && }
diff --git a/frontend/src/services/OpeningFavoriteService.ts b/frontend/src/services/OpeningFavoriteService.ts new file mode 100644 index 00000000..5c85d5e3 --- /dev/null +++ b/frontend/src/services/OpeningFavoriteService.ts @@ -0,0 +1,73 @@ +import axios from 'axios'; +import { getAuthIdToken } from './AuthService'; +import { env } from '../env'; + +const backendUrl = env.VITE_BACKEND_URL; + +/** + * Fetches the submission trends/favorites from the backend. + * + * This function sends a GET request to the backend API to retrieve the user favorite openings. + * It includes an authorization token in the request headers. + * + * @returns {Promise} A promise that resolves to an array of numbers representing the opening ids. + * If the response data is not an array, it returns an empty array. + */ +export const fetchOpeningTrends = async (): Promise =>{ + const authToken = getAuthIdToken(); + const response = await axios.get( + `${backendUrl}/api/openings/favorites`, { + headers: { + Authorization: `Bearer ${authToken}` + } + }); + + const { data } = response; + if (data && Array.isArray(data)) { + return data; + } else { + return []; + } +} + +/** + * Sets an opening as a favorite for the authenticated user. + * + * @param {number} openingId - The ID of the opening to be set as favorite. + * @returns {Promise} A promise that resolves when the operation is complete. + * @throws {Error} Throws an error if the request fails with a status code other than 202. + */ +export const setOpeningFavorite = async (openingId: number): Promise => { + const authToken = getAuthIdToken(); + const response = await axios.put( + `${backendUrl}/api/openings/favorites/${openingId}`, null, { + headers: { + Authorization: `Bearer ${authToken}` + } + }); + + if (response.status !== 202) { + throw new Error(`Failed to set favorite opening. Status code: ${response.status}`); + } +} + +/** + * Deletes a favorite opening by its ID. + * + * @param {number} openingId - The ID of the opening to be removed from favorites. + * @returns {Promise} A promise that resolves when the favorite opening is successfully deleted. + * @throws {Error} Throws an error if the deletion fails or the response status is not 204. + */ +export const deleteOpeningFavorite = async (openingId: number): Promise => { + const authToken = getAuthIdToken(); + const response = await axios.delete( + `${backendUrl}/api/openings/favorites/${openingId}`, { + headers: { + Authorization: `Bearer ${authToken}` + } + }); + + if (response.status !== 204) { + throw new Error(`Failed to remove favorite opening. Status code: ${response.status}`); + } +} \ No newline at end of file diff --git a/frontend/src/services/OpeningService.ts b/frontend/src/services/OpeningService.ts index 81cda2e7..3ae05474 100644 --- a/frontend/src/services/OpeningService.ts +++ b/frontend/src/services/OpeningService.ts @@ -4,28 +4,15 @@ import { env } from '../env'; import { RecentAction } from '../types/RecentAction'; import { OpeningPerYearChart } from '../types/OpeningPerYearChart'; import { RecentOpening } from '../types/RecentOpening'; +import { + RecentOpeningApi, + IOpeningPerYear, + IFreeGrowingProps, + IFreeGrowingChartData +} from '../types/OpeningTypes'; const backendUrl = env.VITE_BACKEND_URL; -interface statusCategory { - code: string; - description: string; -} - -interface RecentOpeningApi { - openingId: number; - forestFileId: string; - cuttingPermit: string | null; - timberMark: string | null; - cutBlock: string | null; - grossAreaHa: number | null; - status: statusCategory | null; - category: statusCategory | null; - disturbanceStart: string | null; - entryTimestamp: string | null; - updateTimestamp: string | null; -} - /** * Fetch recent openings data from backend. * @@ -70,13 +57,6 @@ export async function fetchRecentOpenings(): Promise { } } -interface IOpeningPerYear { - orgUnitCode: string | null; - statusCode: string | null; - entryDateStart: string | null; - entryDateEnd: string | null; -} - /** * Fetch openings per year data from backend. * @@ -122,18 +102,6 @@ export async function fetchOpeningsPerYear(props: IOpeningPerYear): Promise