From 004882d2d8d37258560d24d7d8696c78affdb012 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Wed, 20 Nov 2024 15:49:15 -0800 Subject: [PATCH 01/10] added all the missing columns in frontend available from API response --- .../Openings/SearchScreenDataTable/index.tsx | 2 +- frontend/src/constants/tableConstants.ts | 33 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index fb4d3bc9..b253e3eb 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -236,7 +236,7 @@ const SearchScreenDataTable: React.FC = ({ {headers.map((header, index) => index > 0 && index % 2 === 1 ? ( // Start from index 1 and handle even-indexed pairs to skip the actions - + ({ ...col, - selected: col.key !== 'disturbanceStartDate', // Assuming 'Disturbance Date' is not selected + selected: col.key !== 'disturbanceStartDate', })); \ No newline at end of file From 38a478f1bf8123d8a86111be60b2d119f13795b4 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Thu, 21 Nov 2024 10:48:33 -0800 Subject: [PATCH 02/10] reduced duplication --- frontend/src/constants/tableConstants.ts | 116 +++++++---------------- 1 file changed, 33 insertions(+), 83 deletions(-) diff --git a/frontend/src/constants/tableConstants.ts b/frontend/src/constants/tableConstants.ts index 402701d4..e94c5803 100644 --- a/frontend/src/constants/tableConstants.ts +++ b/frontend/src/constants/tableConstants.ts @@ -1,90 +1,40 @@ import { ITableHeader } from "../types/TableHeader"; -export const searchScreenColumns: ITableHeader[] = [ - { - key: 'actions', - header: 'Actions', - selected: true - }, - { - key: 'openingId', - header: 'Opening Id', - selected: true - }, - { - key: 'forestFileId', - header: 'File Id', - selected: true - }, - { - key: 'categoryDescription', - header: 'Category', - selected: true, - elipsis: true - }, - { - key: 'orgUnitName', - header: 'Org unit', - selected: true - }, - { - key: 'statusDescription', - header: 'Status', - selected: true - }, - { - key: 'cuttingPermitId', - header: 'Cutting permit', - selected: true - }, - { - key: 'cutBlockId', - header: 'Cut block', - selected: true - }, - { - key: 'openingGrossAreaHa', - header: 'Gross Area', - selected: true - }, - { - key: 'disturbanceStartDate', - header: 'Disturbance Date', - selected: true - }, - { - key: 'openingNumber', - header: 'Opening Number', - selected: false - }, - { - key: 'timberMark', - header: 'Timber Mark', - selected: false - }, - { - key: 'clientName', - header: 'Client', - selected: false - }, - { - key: 'regenDelayDate', - header: 'Regen Delay Due Date', - selected: false - }, - { - key: 'earlyFreeGrowingDate', - header: 'Free Growing Due Date', - selected: false - }, - { - key: 'updateTimestamp', - header: 'Update Date', - selected: false - } +const searchScreenColumnDefinitions = [ + { key: 'actions', header: 'Actions' }, + { key: 'openingId', header: 'Opening Id' }, + { key: 'forestFileId', header: 'File Id' }, + { key: 'categoryDescription', header: 'Category', elipsis: true }, + { key: 'orgUnitName', header: 'Org unit' }, + { key: 'statusDescription', header: 'Status' }, + { key: 'cuttingPermitId', header: 'Cutting permit' }, + { key: 'cutBlockId', header: 'Cut block' }, + { key: 'openingGrossAreaHa', header: 'Gross Area' }, + { key: 'disturbanceStartDate', header: 'Disturbance Date' }, + { key: 'openingNumber', header: 'Opening Number' }, + { key: 'timberMark', header: 'Timber Mark' }, + { key: 'clientName', header: 'Client' }, + { key: 'regenDelayDate', header: 'Regen Delay Due Date' }, + { key: 'earlyFreeGrowingDate', header: 'Free Growing Due Date' }, + { key: 'updateTimestamp', header: 'Update Date' }, ]; -// List of column definitions with key and header +export const searchScreenColumns: ITableHeader[] = searchScreenColumnDefinitions.map((col) => ({ + ...col, + selected: [ + 'actions', + 'openingId', + 'forestFileId', + 'categoryDescription', + 'orgUnitName', + 'statusDescription', + 'cuttingPermitId', + 'cutBlockId', + 'openingGrossAreaHa', + 'disturbanceStartDate', + ].includes(col.key), +})); + const recentOpeningsColumnDefinitions = [ { key: 'openingId', header: 'Opening Id' }, { key: 'forestFileId', header: 'File Id' }, From ad3ca12a23d24bb207b6083a2502e694b9701243 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Thu, 21 Nov 2024 14:20:48 -0800 Subject: [PATCH 03/10] redisgn to a vertical list for edit columns with arranged rows --- .../Openings/SearchScreenDataTable/index.tsx | 174 ++++++++++-------- .../SearchScreenDataTable/styles.scss | 18 ++ frontend/src/constants/tableConstants.ts | 15 +- 3 files changed, 120 insertions(+), 87 deletions(-) diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index b253e3eb..ae32edfc 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -24,7 +24,7 @@ import { MenuItemDivider, Tooltip, MenuItem, - FlexGrid + FlexGrid, } from "@carbon/react"; import * as Icons from "@carbon/icons-react"; import StatusTag from "../../../StatusTag"; @@ -37,17 +37,16 @@ import { convertToCSV, downloadCSV, downloadPDF, - downloadXLSX + downloadXLSX, } from "../../../../utils/fileConversions"; import { useNavigate } from "react-router-dom"; -import { setOpeningFavorite } from '../../../../services/OpeningFavouriteService'; +import { setOpeningFavorite } from "../../../../services/OpeningFavouriteService"; import { usePostViewedOpening } from "../../../../services/queries/dashboard/dashboardQueries"; import { useNotification } from "../../../../contexts/NotificationProvider"; import TruncatedText from "../../../TruncatedText"; import FriendlyDate from "../../../FriendlyDate"; import ComingSoonModal from "../../../ComingSoonModal"; - interface ISearchScreenDataTable { rows: OpeningsSearch[]; headers: ITableHeader[]; @@ -71,7 +70,7 @@ const SearchScreenDataTable: React.FC = ({ toggleSpatial, showSpatial, totalItems, - setOpeningIds + setOpeningIds, }) => { const { handlePageChange, @@ -84,8 +83,12 @@ const SearchScreenDataTable: React.FC = ({ const [openEdit, setOpenEdit] = useState(false); const [openDownload, setOpenDownload] = useState(false); const [selectedRows, setSelectedRows] = useState([]); // State to store selected rows - const [openingDetails, setOpeningDetails] = useState(''); - const { mutate: markAsViewedOpening, isError, error } = usePostViewedOpening(); + const [openingDetails, setOpeningDetails] = useState(""); + const { + mutate: markAsViewedOpening, + isError, + error, + } = usePostViewedOpening(); const navigate = useNavigate(); // This ref is used to calculate the width of the container for each cell @@ -95,14 +98,18 @@ const SearchScreenDataTable: React.FC = ({ const { displayNotification } = useNotification(); useEffect(() => { - const widths = cellRefs.current.map((cell: ICellRefs) => cell.offsetWidth || 0); + const widths = cellRefs.current.map( + (cell: ICellRefs) => cell.offsetWidth || 0 + ); setCellWidths(widths); const handleResize = () => { - const newWidths = cellRefs.current.map((cell: ICellRefs) => cell.offsetWidth || 0); + const newWidths = cellRefs.current.map( + (cell: ICellRefs) => cell.offsetWidth || 0 + ); setCellWidths(newWidths); }; - + window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); @@ -110,17 +117,19 @@ const SearchScreenDataTable: React.FC = ({ useEffect(() => { setInitialItemsPerPage(itemsPerPage); }, [rows, totalItems]); - + // Function to handle row selection changes const handleRowSelectionChanged = (openingId: string) => { setSelectedRows((prevSelectedRows) => { if (prevSelectedRows.includes(openingId)) { // If the row is already selected, remove it from the selected rows - const selectedValues = prevSelectedRows.filter((id) => id !== openingId); + const selectedValues = prevSelectedRows.filter( + (id) => id !== openingId + ); setOpeningIds(selectedValues.map(parseFloat)); return selectedValues; } else { - // If the row is not selected, add it to the selected rows + // If the row is not selected, add it to the selected rows const selectedValues = [...prevSelectedRows, openingId]; setOpeningIds(selectedValues.map(parseFloat)); return selectedValues; @@ -129,44 +138,44 @@ const SearchScreenDataTable: React.FC = ({ }; const handleRowClick = (openingId: string) => { - // Call the mutation to mark as viewed - markAsViewedOpening(openingId, { - onSuccess: () => { - setOpeningDetails(openingId); - }, - onError: (err: any) => { - displayNotification({ - title: 'Unable to process your request', - subTitle: 'Please try again in a few minutes', - type: "error", - onClose: () => {} - }) - } - }); - }; + // Call the mutation to mark as viewed + markAsViewedOpening(openingId, { + onSuccess: () => { + setOpeningDetails(openingId); + }, + onError: (err: any) => { + displayNotification({ + title: "Unable to process your request", + subTitle: "Please try again in a few minutes", + type: "error", + onClose: () => {}, + }); + }, + }); + }; //Function to handle the favourite feature of the opening for a user const handleFavouriteOpening = (openingId: string) => { - try{ + try { setOpeningFavorite(parseInt(openingId)); displayNotification({ title: `Opening Id ${openingId} favourited`, - subTitle: 'You can follow this opening ID on your dashboard', + subTitle: "You can follow this opening ID on your dashboard", type: "success", buttonLabel: "Go to track openings", onClose: () => { - navigate('/opening?tab=metrics&scrollTo=trackOpenings') - } - }) + navigate("/opening?tab=metrics&scrollTo=trackOpenings"); + }, + }); } catch (error) { displayNotification({ - title: 'Unable to process your request', - subTitle: 'Please try again in a few minutes', + title: "Unable to process your request", + subTitle: "Please try again in a few minutes", type: "error", - onClose: () => {} - }) + onClose: () => {}, + }); } - } + }; return ( <> @@ -233,36 +242,35 @@ const SearchScreenDataTable: React.FC = ({

Select Columns you want to see:

- - {headers.map((header, index) => - index > 0 && index % 2 === 1 ? ( // Start from index 1 and handle even-indexed pairs to skip the actions - - - handleCheckboxChange(header.key)} - /> - - {headers[index + 1] && ( - - - handleCheckboxChange(headers[index + 1].key) - } - /> + + {headers.map( + (header, index) => + header && + header.key !== "actions" && ( + + + {header.key === "openingId" ? ( + + ) : ( + + handleCheckboxChange(header.key) + } + /> + )} - )} - - ) : null + + ) )} @@ -332,7 +340,9 @@ const SearchScreenDataTable: React.FC = ({ {headers.map((header) => header.selected ? ( - {header.header} + + {header.header} + ) : null )} @@ -340,21 +350,17 @@ const SearchScreenDataTable: React.FC = ({ {rows && rows.map((row: any, i: number) => ( - + {headers.map((header) => header.selected ? ( (cellRefs.current[i] = el)} key={header.key} className={ - header.key === "actions" && showSpatial - ? "p-0" - : null + header.key === "actions" && showSpatial ? "p-0" : null } - onClick={() =>{ - if(header.key !== "actions"){ + onClick={() => { + if (header.key !== "actions") { handleRowClick(row.openingId); } }} @@ -417,9 +423,14 @@ const SearchScreenDataTable: React.FC = ({ ) : header.header === "Category" ? ( - ) : header.key === 'disturbanceStartDate' ? ( + text={ + row["categoryCode"] + + " - " + + row["categoryDescription"] + } + parentWidth={cellWidths[i]} + /> + ) : header.key === "disturbanceStartDate" ? ( ) : ( row[header.key] @@ -464,7 +475,10 @@ const SearchScreenDataTable: React.FC = ({ /> )} - + ); }; diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss index badbfad9..5ea10503 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss @@ -153,6 +153,24 @@ } +.dropdown-container { + display: flex; + flex-direction: column; + max-height: 265px; + overflow-y: auto; +} +::-webkit-scrollbar { + -webkit-appearance: none; + width: 7px; +} +::-webkit-scrollbar-thumb { + border-radius: 4px; + background-color: rgba(0, 0, 0, .5); + box-shadow: 0 0 1px rgba(255, 255, 255, .5); +} + + + @media only screen and (max-width: 672px) { .#{vars.$bcgov-prefix}--data-table-content { width: 100%; diff --git a/frontend/src/constants/tableConstants.ts b/frontend/src/constants/tableConstants.ts index e94c5803..bbac9393 100644 --- a/frontend/src/constants/tableConstants.ts +++ b/frontend/src/constants/tableConstants.ts @@ -2,21 +2,22 @@ import { ITableHeader } from "../types/TableHeader"; const searchScreenColumnDefinitions = [ { key: 'actions', header: 'Actions' }, + { key: 'openingId', header: 'Opening Id' }, + { key: 'openingNumber', header: 'Opening number' }, { key: 'forestFileId', header: 'File Id' }, { key: 'categoryDescription', header: 'Category', elipsis: true }, { key: 'orgUnitName', header: 'Org unit' }, { key: 'statusDescription', header: 'Status' }, + { key: 'clientNumber', header: 'Client number' }, + { key: 'timberMark', header: 'Timber mark' }, { key: 'cuttingPermitId', header: 'Cutting permit' }, { key: 'cutBlockId', header: 'Cut block' }, { key: 'openingGrossAreaHa', header: 'Gross Area' }, - { key: 'disturbanceStartDate', header: 'Disturbance Date' }, - { key: 'openingNumber', header: 'Opening Number' }, - { key: 'timberMark', header: 'Timber Mark' }, - { key: 'clientName', header: 'Client' }, - { key: 'regenDelayDate', header: 'Regen Delay Due Date' }, - { key: 'earlyFreeGrowingDate', header: 'Free Growing Due Date' }, - { key: 'updateTimestamp', header: 'Update Date' }, + { key: 'disturbanceStartDate', header: 'Disturbance date' }, + { key: 'regenDelayDate', header: 'Regen delay due date' }, + { key: 'earlyFreeGrowingDate', header: 'Free growing due date' }, + { key: 'updateTimestamp', header: 'Update date' }, ]; export const searchScreenColumns: ITableHeader[] = searchScreenColumnDefinitions.map((col) => ({ From 2092be5a6081b9b6862e7b395c9dca4b90446152 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Thu, 21 Nov 2024 15:32:47 -0800 Subject: [PATCH 04/10] added fake OpeningId Checkbox --- .../Openings/OpeningSearchTab.test.tsx | 10 +++++----- .../Openings/SearchScreenDataTable/index.tsx | 14 ++++++-------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchTab.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchTab.test.tsx index 9f40c749..23a91b08 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchTab.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchTab.test.tsx @@ -189,9 +189,9 @@ describe('OpeningSearchTab', () => { expect(screen.getByTestId('Opening Id')).toBeInTheDocument(); const editColumnsBtn = screen.getByTestId('edit-columns'); await act(async () => fireEvent.click(editColumnsBtn)); - const checkbox = container.querySelector('input[type="checkbox"]#checkbox-label-openingId'); + const checkbox = container.querySelector('#checkbox-label-openingNumber'); await act(async () => fireEvent.click(checkbox)); - expect(screen.queryByTestId('Opening Id')).not.toBeInTheDocument(); + expect(screen.queryByTestId('Opening Number')).not.toBeInTheDocument(); }); @@ -220,7 +220,7 @@ describe('OpeningSearchTab', () => { expect(screen.getByTestId('openings-map')).toBeInTheDocument(); }); - it('should display more or less columns when checkboxes are clicked', async () => { + it('should display openingNumber once users clicks the chekbox', async () => { (useOpeningsQuery as vi.Mock).mockReturnValue({ data, isFetching: false }); let container; @@ -245,9 +245,9 @@ describe('OpeningSearchTab', () => { expect(screen.getByTestId('Opening Id')).toBeInTheDocument(); const editColumnsBtn = screen.getByTestId('edit-columns'); await act(async () => fireEvent.click(editColumnsBtn)); - const checkbox = container.querySelector('input[type="checkbox"]#checkbox-label-openingId'); + const checkbox = container.querySelector('input[type="checkbox"]#checkbox-label-openingNumber'); await act(async () => fireEvent.click(checkbox)); - expect(screen.queryByTestId('Opening Id')).not.toBeInTheDocument(); + expect(screen.queryByTestId('Opening number')).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index ae32edfc..6585356e 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -46,6 +46,7 @@ import { useNotification } from "../../../../contexts/NotificationProvider"; import TruncatedText from "../../../TruncatedText"; import FriendlyDate from "../../../FriendlyDate"; import ComingSoonModal from "../../../ComingSoonModal"; +import { Icon } from "@carbon/icons-react"; interface ISearchScreenDataTable { rows: OpeningsSearch[]; @@ -242,7 +243,7 @@ const SearchScreenDataTable: React.FC = ({

Select Columns you want to see:

- + {headers.map( (header, index) => header && @@ -250,13 +251,10 @@ const SearchScreenDataTable: React.FC = ({ {header.key === "openingId" ? ( - +
+ +

{header.header}

+
) : ( Date: Thu, 21 Nov 2024 14:59:29 -0800 Subject: [PATCH 05/10] fix(SILVA-514 + sec): fixing favorite name on search result and security update (#491) --- .github/workflows/analysis.yml | 2 +- backend/openshift.deploy.yml | 2 + backend/pom.xml | 1 + .../configuration/CorsConfiguration.java | 50 ++++-- .../configuration/SecurityConfiguration.java | 81 ++-------- .../configuration/SilvaConfiguration.java | 32 ++++ .../security/ApiAuthorizationCustomizer.java | 32 ++++ .../security/CsrfSecurityCustomizer.java | 17 ++ .../security/GrantedAuthoritiesConverter.java | 30 ++++ .../security/HeadersSecurityCustomizer.java | 63 ++++++++ .../security/Oauth2SecurityCustomizer.java | 32 ++++ .../oracle/dto/OpeningSearchResponseDto.java | 1 + .../oracle/service/OpeningService.java | 76 +++++---- .../endpoint/DashboardExtractionEndpoint.java | 77 --------- .../repository/UserOpeningRepository.java | 3 +- .../postgres/service/UserOpeningService.java | 14 +- backend/src/main/resources/application.yml | 38 ++++- ...FeatureServiceEndpointIntegrationTest.java | 1 - .../endpoint/OpeningSearchEndpointTest.java | 124 +++----------- .../DashboardExtractionEndpointTest.java | 152 ------------------ .../DashboardMetricsEndpointTest.java | 68 ++------ .../service/UserOpeningServiceTest.java | 22 +++ .../__test__/components/OpeningsTab.test.tsx | 15 +- .../Openings/SearchScreenDataTable.test.tsx | 127 ++++++++++++--- .../src/__test__/screens/Opening.test.tsx | 9 +- .../services/OpeningFavoriteService.test.ts | 24 ++- .../__test__/services/OpeningService.test.ts | 8 +- .../dashboard/dashboardQueries.test.tsx | 8 +- .../services/search/openings.test.tsx | 2 + frontend/src/components/OpeningsMap/index.tsx | 4 +- .../Openings/SearchScreenDataTable/index.tsx | 120 ++++++++------ frontend/src/contexts/AuthProvider.tsx | 70 ++++---- .../src/services/OpeningFavouriteService.ts | 6 + frontend/src/services/OpeningService.ts | 14 +- frontend/src/services/SecretsService.ts | 40 ----- frontend/src/services/TestService.ts | 24 --- .../queries/dashboard/dashboardQueries.ts | 4 +- frontend/src/services/search/openings.ts | 22 ++- 38 files changed, 702 insertions(+), 713 deletions(-) create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/security/ApiAuthorizationCustomizer.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/security/CsrfSecurityCustomizer.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/security/GrantedAuthoritiesConverter.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/security/HeadersSecurityCustomizer.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/security/Oauth2SecurityCustomizer.java delete mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpoint.java delete mode 100644 backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpointTest.java delete mode 100644 frontend/src/services/SecretsService.ts delete mode 100644 frontend/src/services/TestService.ts diff --git a/.github/workflows/analysis.yml b/.github/workflows/analysis.yml index 803462a8..a9f28ee8 100644 --- a/.github/workflows/analysis.yml +++ b/.github/workflows/analysis.yml @@ -25,7 +25,7 @@ jobs: java-distribution: temurin java-version: 21 sonar_args: > - -Dsonar.exclusions=**/configuration/**,**/dto/**,**/entity/**,**/exception/**,**/job/**,**/*$*Builder*,**/ResultsApplication.*,**/*Constants.*, + -Dsonar.exclusions=**/configuration/**,**/dto/**,**/entity/**,**/exception/**,**/job/**,**/*$*Builder*,**/ResultsApplication.*,**/*Constants.*,**/security/*Converter.* -Dsonar.coverage.jacoco.xmlReportPaths=target/coverage-reports/merged-test-report/jacoco.xml -Dsonar.organization=bcgov-sonarcloud -Dsonar.project.monorepo.enabled=true diff --git a/backend/openshift.deploy.yml b/backend/openshift.deploy.yml index 3ba338d2..9c35799b 100644 --- a/backend/openshift.deploy.yml +++ b/backend/openshift.deploy.yml @@ -236,6 +236,8 @@ objects: secretKeyRef: name: ${NAME}-${ZONE}-database key: database-user + - name: SELF_URI + value: https://${NAME}-${ZONE}-${COMPONENT}.${DOMAIN} - name: RANDOM_EXPRESSION value: ${RANDOM_EXPRESSION} resources: diff --git a/backend/pom.xml b/backend/pom.xml index feab53a3..3f51605b 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -347,6 +347,7 @@ **/*$*Builder* **/ResultsApplication.* **/*Constants.* + **/security/*Converter.* diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/CorsConfiguration.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/CorsConfiguration.java index c22b798c..8325205d 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/CorsConfiguration.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/CorsConfiguration.java @@ -1,36 +1,56 @@ package ca.bc.gov.restapi.results.common.configuration; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; +import org.apache.commons.lang3.StringUtils; import org.springframework.context.annotation.Configuration; import org.springframework.lang.NonNull; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -/** This class holds the configuration for CORS handling. */ +/** + * This class holds the configuration for CORS handling. + */ @Slf4j @Configuration +@RequiredArgsConstructor public class CorsConfiguration implements WebMvcConfigurer { - @Value("${server.allowed.cors.origins}") - private String[] allowedOrigins; + private final SilvaConfiguration configuration; - /** - * Adds CORS mappings and allowed origins. - * - * @param registry Spring Cors Registry - */ @Override public void addCorsMappings(@NonNull CorsRegistry registry) { - if (allowedOrigins != null && allowedOrigins.length != 0) { - log.info("allowedOrigins: {}", Arrays.asList(allowedOrigins)); + var frontendConfig = configuration.getFrontend(); + var cors = frontendConfig.getCors(); + String origins = frontendConfig.getUrl(); + List allowedOrigins = new ArrayList<>(); - registry - .addMapping("/**") - .allowedOriginPatterns(allowedOrigins) - .allowedMethods("GET", "PUT", "POST", "DELETE", "PATCH", "OPTIONS", "HEAD"); + if (StringUtils.isNotBlank(origins) && origins.contains(",")) { + allowedOrigins.addAll(Arrays.asList(origins.split(","))); + } else { + allowedOrigins.add(origins); } + + log.info("Allowed origins: {} {}", allowedOrigins,allowedOrigins.toArray(new String[0])); + + registry + .addMapping("/api/**") + .allowedOriginPatterns(allowedOrigins.toArray(new String[0])) + .allowedMethods(cors.getMethods().toArray(new String[0])) + .allowedHeaders(cors.getHeaders().toArray(new String[0])) + .exposedHeaders(cors.getHeaders().toArray(new String[0])) + .maxAge(cors.getAge().getSeconds()) + .allowCredentials(true); + + registry.addMapping("/actuator/**") + .allowedOrigins("*") + .allowedMethods("GET") + .allowedHeaders("*") + .allowCredentials(false); + WebMvcConfigurer.super.addCorsMappings(registry); } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SecurityConfiguration.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SecurityConfiguration.java index 9410eef8..c579261e 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SecurityConfiguration.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SecurityConfiguration.java @@ -1,88 +1,41 @@ package ca.bc.gov.restapi.results.common.configuration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import org.springframework.beans.factory.annotation.Value; +import ca.bc.gov.restapi.results.common.security.ApiAuthorizationCustomizer; +import ca.bc.gov.restapi.results.common.security.CsrfSecurityCustomizer; +import ca.bc.gov.restapi.results.common.security.HeadersSecurityCustomizer; +import ca.bc.gov.restapi.results.common.security.Oauth2SecurityCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.csrf.CookieCsrfTokenRepository; -/** This class contains all configurations related to security and authentication. */ @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfiguration { - @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") - String jwkSetUri; - - /** - * Filters a request to add security checks and configurations. - * - * @param http instance of HttpSecurity containing the request. - * @return SecurityFilterChain with allowed endpoints and all configuration. - * @throws Exception due to bad configuration possibilities. - */ @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.cors(Customizer.withDefaults()) - .csrf( - customize -> - customize.csrfTokenRepository(new CookieCsrfTokenRepository())) - .authorizeHttpRequests( - customize -> - customize - .requestMatchers("/api/**") - .authenticated() - .requestMatchers(HttpMethod.OPTIONS, "/**") - .permitAll() - .anyRequest() - .permitAll()) + public SecurityFilterChain filterChain( + HttpSecurity http, + HeadersSecurityCustomizer headersCustomizer, + CsrfSecurityCustomizer csrfCustomizer, + ApiAuthorizationCustomizer apiCustomizer, + Oauth2SecurityCustomizer oauth2Customizer + ) throws Exception { + http + .headers(headersCustomizer) + .csrf(csrfCustomizer) + .cors(Customizer.withDefaults()) + .authorizeHttpRequests(apiCustomizer) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) - .oauth2ResourceServer( - customize -> - customize.jwt( - jwt -> jwt.jwtAuthenticationConverter(converter()).jwkSetUri(jwkSetUri))); + .oauth2ResourceServer(oauth2Customizer); return http.build(); } - private Converter converter() { - JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); - converter.setJwtGrantedAuthoritiesConverter(roleConverter); - return converter; - } - - private final Converter> roleConverter = - jwt -> { - if (!jwt.getClaims().containsKey("client_roles")) { - return List.of(); - } - Object clientRolesObj = jwt.getClaims().get("client_roles"); - final List realmAccess = new ArrayList<>(); - if (clientRolesObj instanceof List list) { - for (Object item : list) { - realmAccess.add(String.valueOf(item)); - } - } - return realmAccess.stream() - .map(roleName -> "ROLE_" + roleName) - .map(roleName -> (GrantedAuthority) new SimpleGrantedAuthority(roleName)) - .toList(); - }; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SilvaConfiguration.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SilvaConfiguration.java index 5c1c09df..55f96c63 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SilvaConfiguration.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/configuration/SilvaConfiguration.java @@ -1,5 +1,6 @@ package ca.bc.gov.restapi.results.common.configuration; +import java.time.Duration; import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -34,6 +35,8 @@ public class SilvaConfiguration { private ExternalApiAddress openMaps; @NestedConfigurationProperty private SilvaDataLimits limits; + @NestedConfigurationProperty + private FrontEndConfiguration frontend; @Data @Builder @@ -52,4 +55,33 @@ public static class SilvaDataLimits { private Integer maxActionsResults; } + /** + * The Front end configuration. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FrontEndConfiguration { + + private String url; + @NestedConfigurationProperty + private FrontEndCorsConfiguration cors; + + } + + /** + * The Front end cors configuration. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class FrontEndCorsConfiguration { + + private List headers; + private List methods; + private Duration age; + } + } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/security/ApiAuthorizationCustomizer.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/ApiAuthorizationCustomizer.java new file mode 100644 index 00000000..f1fce68c --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/ApiAuthorizationCustomizer.java @@ -0,0 +1,32 @@ +package ca.bc.gov.restapi.results.common.security; + +import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; +import org.springframework.stereotype.Component; + +@Component +public class ApiAuthorizationCustomizer implements + Customizer.AuthorizationManagerRequestMatcherRegistry> { + + @Override + public void customize( + AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authorize) { + + authorize + // Allow actuator endpoints to be accessed without authentication + // This is useful for monitoring and health checks + .requestMatchers(HttpMethod.GET, "/actuator/**") + .permitAll() + // Protect everything under /api with authentication + .requestMatchers("/api/**") + .authenticated() + // Allow OPTIONS requests to be accessed with authentication + .requestMatchers(HttpMethod.OPTIONS, "/**") + .authenticated() + // Deny all other requests + .anyRequest().denyAll(); + + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/security/CsrfSecurityCustomizer.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/CsrfSecurityCustomizer.java new file mode 100644 index 00000000..a87a9ddf --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/CsrfSecurityCustomizer.java @@ -0,0 +1,17 @@ +package ca.bc.gov.restapi.results.common.security; + +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.stereotype.Component; + +@Component +public class CsrfSecurityCustomizer implements Customizer> { + + @Override + public void customize(CsrfConfigurer csrfSpec) { + csrfSpec + .csrfTokenRepository(new CookieCsrfTokenRepository()); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/security/GrantedAuthoritiesConverter.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/GrantedAuthoritiesConverter.java new file mode 100644 index 00000000..1c776493 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/GrantedAuthoritiesConverter.java @@ -0,0 +1,30 @@ +package ca.bc.gov.restapi.results.common.security; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +public class GrantedAuthoritiesConverter implements Converter> { + + @Override + public Collection convert(Jwt jwt) { + final List realmAccess = new ArrayList<>(); + Object clientRolesObj = + jwt + .getClaims() + .getOrDefault("client_roles",List.of()); + + if (clientRolesObj instanceof List list) { + list.forEach(item -> realmAccess.add(String.valueOf(item))); + } + return realmAccess + .stream() + .map(roleName -> "ROLE_" + roleName) + .map(roleName -> (GrantedAuthority) new SimpleGrantedAuthority(roleName)) + .toList(); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/security/HeadersSecurityCustomizer.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/HeadersSecurityCustomizer.java new file mode 100644 index 00000000..8b30fd48 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/HeadersSecurityCustomizer.java @@ -0,0 +1,63 @@ +package ca.bc.gov.restapi.results.common.security; + +import java.time.Duration; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.FrameOptionsConfig; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer.XXssConfig; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class HeadersSecurityCustomizer implements Customizer> { + + @Value("${ca.bc.gov.nrs.self-uri}") + String selfUri; + + /** + * The environment of the application, which is injected from the application properties. The + * default value is "PROD". + */ + @Value("${ca.bc.gov.nrs.environment:PROD}") + String environment; + + @Override + public void customize(HeadersConfigurer headerSpec) { +// Define the policy directives for the Content-Security-Policy header. + String policyDirectives = String.join("; ", + "default-src 'none'", + "connect-src 'self' " + selfUri, + "script-src 'strict-dynamic' 'nonce-" + UUID.randomUUID() + + "' " + ("local".equalsIgnoreCase(environment) ? "http: " : StringUtils.EMPTY) + "https:", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "require-trusted-types-for 'script'", + "report-uri " + selfUri + ); + + // Customize the HTTP headers. + headerSpec + .frameOptions(FrameOptionsConfig::deny) // Set the X-Frame-Options header to "DENY". + .contentSecurityPolicy( + contentSecurityPolicySpec -> contentSecurityPolicySpec.policyDirectives( + policyDirectives)) // Set the Content-Security-Policy header. + .httpStrictTransportSecurity(hstsSpec -> + hstsSpec.maxAgeInSeconds(Duration.ofDays(30).getSeconds()) + .includeSubDomains(true)) // Set the Strict-Transport-Security header. + .xssProtection(XXssConfig::disable) // Disable the X-XSS-Protection header. + .contentTypeOptions( + Customizer.withDefaults()) // Set the X-Content-Type-Options header to its default value. + .referrerPolicy(referrerPolicySpec -> referrerPolicySpec.policy( + ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)) // Set the Referrer-Policy header. + .permissionsPolicy(permissionsPolicySpec -> permissionsPolicySpec.policy( + "geolocation=(), microphone=(), camera=(), speaker=(), usb=(), bluetooth=(), payment=(), interest-cohort=()")) // Set the Permissions-Policy header. + ; + } +} \ No newline at end of file diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/security/Oauth2SecurityCustomizer.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/Oauth2SecurityCustomizer.java new file mode 100644 index 00000000..93146edd --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/security/Oauth2SecurityCustomizer.java @@ -0,0 +1,32 @@ +package ca.bc.gov.restapi.results.common.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.stereotype.Component; + +@Component +public class Oauth2SecurityCustomizer implements + Customizer> { + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") + String jwkSetUri; + + @Override + public void customize( + OAuth2ResourceServerConfigurer customize) { + customize.jwt(jwt -> jwt.jwtAuthenticationConverter(converter()).jwkSetUri(jwkSetUri)); + } + + private Converter converter() { + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(new GrantedAuthoritiesConverter()); + return converter; + } + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java index 394fff72..ed5a4ea1 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchResponseDto.java @@ -44,4 +44,5 @@ public class OpeningSearchResponseDto { private String forestFileId; private Long silvaReliefAppId; private LocalDateTime lastViewDate; + private boolean favourite; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpeningService.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpeningService.java index 7d3379b4..ab357f69 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpeningService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/OpeningService.java @@ -17,38 +17,37 @@ import ca.bc.gov.restapi.results.oracle.enums.OpeningStatusEnum; import ca.bc.gov.restapi.results.oracle.repository.OpeningRepository; import ca.bc.gov.restapi.results.oracle.repository.OpeningSearchRepository; +import ca.bc.gov.restapi.results.postgres.service.UserOpeningService; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; -/** This class holds methods for fetching and handling {@link OpeningEntity} in general. */ +/** + * This class holds methods for fetching and handling {@link OpeningEntity} in general. + */ @Slf4j @Service @RequiredArgsConstructor public class OpeningService { private final OpeningRepository openingRepository; - private final CutBlockOpenAdminService cutBlockOpenAdminService; - private final LoggedUserService loggedUserService; - private final OpeningSearchRepository openingSearchRepository; - private final ForestClientApiProvider forestClientApiProvider; + private final UserOpeningService userOpeningService; /** * Get recent openings given the opening creation date. @@ -127,39 +126,60 @@ public PaginatedResult openingSearch( PaginatedResult result = openingSearchRepository.searchOpeningQuery(filtersDto, pagination); - fetchClientAcronyms(result); - - return result; + return fetchClientAcronyms(fetchFavorites(result)); } - private void fetchClientAcronyms(PaginatedResult result) { - List clientNumbersWithDuplicates = - result.getData().stream() - .filter(o -> !Objects.isNull(o.getClientNumber())) + private PaginatedResult fetchClientAcronyms(PaginatedResult result) { + Map forestClientsMap = new HashMap<>(); + + List clientNumbers = + result + .getData() + .stream() .map(OpeningSearchResponseDto::getClientNumber) + .filter(StringUtils::isNotBlank) + .distinct() .toList(); - // Recreate list without duplicates - List clientNumbers = new ArrayList<>(new HashSet<>(clientNumbersWithDuplicates)); - - Map forestClientsMap = new HashMap<>(); - // Forest client API doesn't have a single endpoint to fetch all at once, so we need to do // one request per client number :/ for (String clientNumber : clientNumbers) { Optional dto = forestClientApiProvider.fetchClientByNumber(clientNumber); - if (dto.isPresent()) { - forestClientsMap.put(clientNumber, dto.get()); - } + dto.ifPresent(forestClientDto -> forestClientsMap.put(clientNumber, forestClientDto)); } - for (OpeningSearchResponseDto response : result.getData()) { - ForestClientDto client = forestClientsMap.get(response.getClientNumber()); - if (!Objects.isNull(client)) { - response.setClientAcronym(client.acronym()); - response.setClientName(client.clientName()); - } + result + .getData() + .forEach(response -> { + if (StringUtils.isNotBlank(response.getClientNumber()) && forestClientsMap.containsKey( + response.getClientNumber())) { + ForestClientDto client = forestClientsMap.get(response.getClientNumber()); + response.setClientAcronym(client.acronym()); + response.setClientName(client.clientName()); + } + }); + + return result; + } + + private PaginatedResult fetchFavorites( + PaginatedResult pagedResult + ) { + + List favourites = userOpeningService.checkForFavorites( + pagedResult + .getData() + .stream() + .map(OpeningSearchResponseDto::getOpeningId) + .map(Integer::longValue) + .toList() + ); + + for (OpeningSearchResponseDto opening : pagedResult.getData()) { + opening.setFavourite(favourites.contains(opening.getOpeningId().longValue())); } + + return pagedResult; } private List createDtoFromEntity( diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpoint.java deleted file mode 100644 index 729c4164..00000000 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpoint.java +++ /dev/null @@ -1,77 +0,0 @@ -package ca.bc.gov.restapi.results.postgres.endpoint; - -import ca.bc.gov.restapi.results.common.security.LoggedUserService; -import ca.bc.gov.restapi.results.common.service.DashboardExtractionService; -import ca.bc.gov.restapi.results.postgres.configuration.DashboardUserManagerConfiguration; -import ca.bc.gov.restapi.results.postgres.entity.OracleExtractionLogsEntity; -import ca.bc.gov.restapi.results.postgres.repository.OracleExtractionLogsRepository; -import java.util.List; -import lombok.AllArgsConstructor; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -/** - * This class holds resources for the Dashboard extraction process. - */ -@RestController -@RequestMapping("/api/dashboard-extraction") -@AllArgsConstructor -public class DashboardExtractionEndpoint { - - private final OracleExtractionLogsRepository oracleExtractionLogsRepository; - - private final DashboardExtractionService dashboardExtractionService; - - private final DashboardUserManagerConfiguration dashboardUserManagerConfiguration; - - private final LoggedUserService loggedUserService; - - /** - * Manually triggers the dashboard extraction job. - * - * @param months Optional. The number of months to extract data. Default: 24. - * @param debug Optional. Enables debug mode. Default: `false`. - * @return Http codes 204 if success or 401 if unauthorized. - */ - @PostMapping("/start") - public ResponseEntity startExtractionProcessManually( - @RequestParam(value = "months", required = false) - Integer months, - @RequestParam(value = "debug", required = false) - Boolean debug) { - if (dashboardUserManagerConfiguration.getUserList().isEmpty()) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - String currentUser = loggedUserService.getLoggedUserIdirOrBceId(); - if (!dashboardUserManagerConfiguration.getUserList().contains(currentUser)) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - - dashboardExtractionService.extractDataForTheDashboard(months, debug, true); - return ResponseEntity.noContent().build(); - } - - /** - * Gets all log messages from the last extraction process. - * - * @return A list of oracle logs records with the last extraction logs. - */ - @GetMapping("/logs") - public ResponseEntity> getLastExtractionLogs() { - List logs = - oracleExtractionLogsRepository.findAll(Sort.by("id").ascending()); - - if (logs.isEmpty()) { - return ResponseEntity.noContent().build(); - } - - return ResponseEntity.ok(logs); - } -} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserOpeningRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserOpeningRepository.java index 422fdb84..1ae93e15 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserOpeningRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/UserOpeningRepository.java @@ -12,7 +12,8 @@ public interface UserOpeningRepository extends JpaRepository { - List findAllByUserId(String userId, Pageable page); + List findAllByUserIdAndOpeningIdIn(String userId,List openingIds); + } 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 ac4fe837..644cb84a 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 @@ -6,7 +6,6 @@ import ca.bc.gov.restapi.results.oracle.repository.OpeningRepository; import ca.bc.gov.restapi.results.postgres.entity.UserOpeningEntity; import ca.bc.gov.restapi.results.postgres.entity.UserOpeningEntityId; -import ca.bc.gov.restapi.results.postgres.repository.OpeningsActivityRepository; import ca.bc.gov.restapi.results.postgres.repository.UserOpeningRepository; import jakarta.transaction.Transactional; import java.util.List; @@ -47,6 +46,19 @@ public List listUserFavoriteOpenings() { .toList(); } + public List checkForFavorites(List openingIds) { + log.info("Checking {} favorite for openings from the following list of openings {}", + loggedUserService.getLoggedUserId(), + openingIds + ); + + return userOpeningRepository + .findAllByUserIdAndOpeningIdIn(loggedUserService.getLoggedUserId(), openingIds) + .stream() + .map(UserOpeningEntity::getOpeningId) + .toList(); + } + @Transactional public void addUserFavoriteOpening(Long openingId) { log.info("Adding opening ID {} as favorite for user {}", openingId, diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b9a0edc9..86c748d8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -4,9 +4,6 @@ server: include-message: always port: ${SERVER_PORT:8080} shutdown: graceful - allowed: - cors: - origins: ${ALLOWED_ORIGINS:#{'http://127.*, http://localhost:300*'}} spring: application: @@ -51,7 +48,7 @@ spring: leakDetectionThreshold: 60000 connection-test-query: SELECT 1 -# Common database settings + # Common database settings jpa: show-sql: false @@ -91,6 +88,7 @@ ca: bc: gov: nrs: + self-uri: ${SELF_URI:http://localhost:8080} dashboard-job-users: ${DASHBOARD_JOB_IDIR_USERS:NONE} wms-whitelist: ${WMS_LAYERS_WHITELIST_USERS:NONE} org-units: ${OPENING_SEARCH_ORG_UNITS:DCK,DSQ,DVA,DKM,DSC,DFN,DSI,DCR,DMK,DQC,DKA,DCS,DOS,DSE,DCC,DMH,DQU,DNI,DND,DRM,DPG,DSS,DPC} @@ -105,6 +103,38 @@ ca: host: ${DATABASE_HOST:nrcdb03.bcgov} limits: max-actions-results: ${MAX_ACTIONS_RESULTS:5} + frontend: + url: ${ALLOWED_ORIGINS:http://localhost:3000} + cors: + headers: + - x-requested-with + - X-REQUESTED-WITH + - authorization + - Authorization + - Content-Type + - content-type + - credential + - CREDENTIAL + - X-XSRF-TOKEN + - access-control-allow-origin + - Access-Control-Allow-Origin + - DNT + - Keep-Alive, + - User-Agent, + - X-Requested-With, + - If-Modified-Since, + - Cache-Control, + - Content-Range, + - Range + - Location + - location + methods: + - OPTIONS + - GET + - POST + - PUT + - DELETE + age: 5m # Logging logging: diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/FeatureServiceEndpointIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/FeatureServiceEndpointIntegrationTest.java index 42bf387b..dd32abd6 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/FeatureServiceEndpointIntegrationTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/FeatureServiceEndpointIntegrationTest.java @@ -17,7 +17,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureMockRestServiceServer; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java index 82a529a7..3514f8f4 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpointTest.java @@ -1,47 +1,37 @@ package ca.bc.gov.restapi.results.oracle.endpoint; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.common.pagination.PaginatedResult; +import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; import ca.bc.gov.restapi.results.oracle.dto.CodeDescriptionDto; import ca.bc.gov.restapi.results.oracle.dto.OpeningSearchResponseDto; import ca.bc.gov.restapi.results.oracle.entity.OrgUnitEntity; 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.service.OpenCategoryCodeService; -import ca.bc.gov.restapi.results.oracle.service.OpeningService; -import ca.bc.gov.restapi.results.oracle.service.OrgUnitService; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.List; import org.hamcrest.Matchers; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(OpeningSearchEndpoint.class) -@WithMockUser(roles = "user_read") -class OpeningSearchEndpointTest { +@AutoConfigureMockMvc +@WithMockJwt +@DisplayName("Integrated Test | Opening Search Endpoint") +class OpeningSearchEndpointTest extends AbstractTestContainerIntegrationTest { - @Autowired private MockMvc mockMvc; - - @MockBean private OpeningService openingService; - - @MockBean private OpenCategoryCodeService openCategoryCodeService; - - @MockBean private OrgUnitService orgUnitService; + @Autowired + private MockMvc mockMvc; @Test @DisplayName("Opening search happy path should succeed") @@ -53,8 +43,8 @@ void openingSearch_happyPath_shouldSucceed() throws Exception { paginatedResult.setHasNextPage(false); OpeningSearchResponseDto response = new OpeningSearchResponseDto(); - response.setOpeningId(123456789); - response.setOpeningNumber("589"); + response.setOpeningId(101); + response.setOpeningNumber(null); response.setCategory(OpeningCategoryEnum.FTML); response.setStatus(OpeningStatusEnum.APP); response.setCuttingPermitId(null); @@ -75,43 +65,20 @@ void openingSearch_happyPath_shouldSucceed() throws Exception { response.setSilvaReliefAppId(333L); response.setForestFileId("TFL47"); - paginatedResult.setData(List.of(response)); - - when(openingService.openingSearch(any(), any())).thenReturn(paginatedResult); - mockMvc .perform( - get("/api/opening-search?mainSearchTerm=407") + get("/api/opening-search?mainSearchTerm=101") .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType("application/json")) .andExpect(jsonPath("$.pageIndex").value("0")) - .andExpect(jsonPath("$.perPage").value("5")) + .andExpect(jsonPath("$.perPage").value("1")) .andExpect(jsonPath("$.totalPages").value("1")) .andExpect(jsonPath("$.hasNextPage").value("false")) .andExpect(jsonPath("$.data[0].openingId").value(response.getOpeningId())) .andExpect(jsonPath("$.data[0].openingNumber").value(response.getOpeningNumber())) .andExpect(jsonPath("$.data[0].category.code").value(response.getCategory().getCode())) - .andExpect(jsonPath("$.data[0].status.code").value(response.getStatus().getCode())) - .andExpect(jsonPath("$.data[0].cuttingPermitId").value(response.getCuttingPermitId())) - .andExpect(jsonPath("$.data[0].timberMark").value(response.getTimberMark())) - .andExpect(jsonPath("$.data[0].cutBlockId").value(response.getCutBlockId())) - .andExpect(jsonPath("$.data[0].openingGrossAreaHa").value(response.getOpeningGrossAreaHa())) - .andExpect( - jsonPath("$.data[0].disturbanceStartDate").value(response.getDisturbanceStartDate())) - .andExpect(jsonPath("$.data[0].forestFileId").value(response.getForestFileId())) - .andExpect(jsonPath("$.data[0].orgUnitCode").value(response.getOrgUnitCode())) - .andExpect(jsonPath("$.data[0].orgUnitName").value(response.getOrgUnitName())) - .andExpect(jsonPath("$.data[0].clientNumber").value(response.getClientNumber())) - .andExpect(jsonPath("$.data[0].regenDelayDate").value(response.getRegenDelayDate())) - .andExpect( - jsonPath("$.data[0].earlyFreeGrowingDate").value(response.getEarlyFreeGrowingDate())) - .andExpect( - jsonPath("$.data[0].lateFreeGrowingDate").value(response.getLateFreeGrowingDate())) - .andExpect(jsonPath("$.data[0].entryUserId").value(response.getEntryUserId())) - .andExpect(jsonPath("$.data[0].submittedToFrpa").value(response.getSubmittedToFrpa())) - .andExpect(jsonPath("$.data[0].silvaReliefAppId").value(response.getSilvaReliefAppId())) .andReturn(); } @@ -125,18 +92,16 @@ void openingSearch_noRecordsFound_shouldSucceed() throws Exception { paginatedResult.setHasNextPage(false); paginatedResult.setData(List.of()); - when(openingService.openingSearch(any(), any())).thenReturn(paginatedResult); - mockMvc .perform( - get("/api/opening-search?mainSearchTerm=AAA") + get("/api/opening-search?mainSearchTerm=ABC1234J") .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType("application/json")) .andExpect(jsonPath("$.pageIndex").value("0")) .andExpect(jsonPath("$.perPage").value("5")) - .andExpect(jsonPath("$.totalPages").value("1")) + .andExpect(jsonPath("$.totalPages").value("0")) .andExpect(jsonPath("$.hasNextPage").value("false")) .andExpect(jsonPath("$.data", Matchers.empty())) .andReturn(); @@ -145,12 +110,11 @@ void openingSearch_noRecordsFound_shouldSucceed() throws Exception { @Test @DisplayName("Get Opening Categories happy Path should Succeed") void getOpeningCategories_happyPath_shouldSucceed() throws Exception { - CodeDescriptionDto category = new CodeDescriptionDto("FTML", "Free Growing"); + CodeDescriptionDto category = new CodeDescriptionDto("CONT", + "SP as a part of contractual agreement"); List openCategoryCodeEntityList = List.of(category); - when(openCategoryCodeService.findAllCategories(false)).thenReturn(openCategoryCodeEntityList); - mockMvc .perform( get("/api/opening-search/categories") @@ -167,9 +131,9 @@ void getOpeningCategories_happyPath_shouldSucceed() throws Exception { @DisplayName("Get Opening Org Units happy Path should Succeed") void getOpeningOrgUnits_happyPath_shouldSucceed() throws Exception { OrgUnitEntity orgUnit = new OrgUnitEntity(); - orgUnit.setOrgUnitNo(22L); + orgUnit.setOrgUnitNo(1L); orgUnit.setOrgUnitCode("DAS"); - orgUnit.setOrgUnitName("DAS Name"); + orgUnit.setOrgUnitName("Org one"); orgUnit.setLocationCode("123"); orgUnit.setAreaCode("1"); orgUnit.setTelephoneNo("25436521"); @@ -183,15 +147,6 @@ void getOpeningOrgUnits_happyPath_shouldSucceed() throws Exception { orgUnit.setExpiryDate(LocalDate.now().plusYears(3L)); orgUnit.setUpdateTimestamp(LocalDate.now()); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - String effectiveDateStr = orgUnit.getEffectiveDate().format(formatter); - String expiryDateStr = orgUnit.getExpiryDate().format(formatter); - String updateTimestampStr = orgUnit.getUpdateTimestamp().format(formatter); - - List orgUnitEntityList = List.of(orgUnit); - - when(orgUnitService.findAllOrgUnits()).thenReturn(orgUnitEntityList); - mockMvc .perform( get("/api/opening-search/org-units") @@ -202,18 +157,6 @@ void getOpeningOrgUnits_happyPath_shouldSucceed() throws Exception { .andExpect(jsonPath("$[0].orgUnitNo").value(orgUnit.getOrgUnitNo())) .andExpect(jsonPath("$[0].orgUnitCode").value(orgUnit.getOrgUnitCode())) .andExpect(jsonPath("$[0].orgUnitName").value(orgUnit.getOrgUnitName())) - .andExpect(jsonPath("$[0].locationCode").value(orgUnit.getLocationCode())) - .andExpect(jsonPath("$[0].areaCode").value(orgUnit.getAreaCode())) - .andExpect(jsonPath("$[0].telephoneNo").value(orgUnit.getTelephoneNo())) - .andExpect(jsonPath("$[0].orgLevelCode").value(orgUnit.getOrgLevelCode().toString())) - .andExpect(jsonPath("$[0].officeNameCode").value(orgUnit.getOfficeNameCode())) - .andExpect(jsonPath("$[0].rollupRegionNo").value(orgUnit.getRollupRegionNo())) - .andExpect(jsonPath("$[0].rollupRegionCode").value(orgUnit.getRollupRegionCode())) - .andExpect(jsonPath("$[0].rollupDistNo").value(orgUnit.getRollupDistNo())) - .andExpect(jsonPath("$[0].rollupDistCode").value(orgUnit.getRollupDistCode())) - .andExpect(jsonPath("$[0].effectiveDate").value(effectiveDateStr)) - .andExpect(jsonPath("$[0].expiryDate").value(expiryDateStr)) - .andExpect(jsonPath("$[0].updateTimestamp").value(updateTimestampStr)) .andReturn(); } @@ -221,9 +164,9 @@ void getOpeningOrgUnits_happyPath_shouldSucceed() throws Exception { @DisplayName("Get Opening Org Units By Code happy Path should Succeed") void getOpeningOrgUnitsByCode_happyPath_shouldSucceed() throws Exception { OrgUnitEntity orgUnit = new OrgUnitEntity(); - orgUnit.setOrgUnitNo(22L); + orgUnit.setOrgUnitNo(1L); orgUnit.setOrgUnitCode("DAS"); - orgUnit.setOrgUnitName("DAS Name"); + orgUnit.setOrgUnitName("Org one"); orgUnit.setLocationCode("123"); orgUnit.setAreaCode("1"); orgUnit.setTelephoneNo("25436521"); @@ -237,15 +180,6 @@ void getOpeningOrgUnitsByCode_happyPath_shouldSucceed() throws Exception { orgUnit.setExpiryDate(LocalDate.now().plusYears(3L)); orgUnit.setUpdateTimestamp(LocalDate.now()); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - String effectiveDateStr = orgUnit.getEffectiveDate().format(formatter); - String expiryDateStr = orgUnit.getExpiryDate().format(formatter); - String updateTimestampStr = orgUnit.getUpdateTimestamp().format(formatter); - - List orgUnitEntityList = List.of(orgUnit); - - when(orgUnitService.findAllOrgUnitsByCode(List.of("DAS"))).thenReturn(orgUnitEntityList); - mockMvc .perform( get("/api/opening-search/org-units-by-code?orgUnitCodes=DAS") @@ -256,29 +190,17 @@ void getOpeningOrgUnitsByCode_happyPath_shouldSucceed() throws Exception { .andExpect(jsonPath("$[0].orgUnitNo").value(orgUnit.getOrgUnitNo())) .andExpect(jsonPath("$[0].orgUnitCode").value(orgUnit.getOrgUnitCode())) .andExpect(jsonPath("$[0].orgUnitName").value(orgUnit.getOrgUnitName())) - .andExpect(jsonPath("$[0].locationCode").value(orgUnit.getLocationCode())) - .andExpect(jsonPath("$[0].areaCode").value(orgUnit.getAreaCode())) - .andExpect(jsonPath("$[0].telephoneNo").value(orgUnit.getTelephoneNo())) - .andExpect(jsonPath("$[0].orgLevelCode").value(orgUnit.getOrgLevelCode().toString())) - .andExpect(jsonPath("$[0].officeNameCode").value(orgUnit.getOfficeNameCode())) - .andExpect(jsonPath("$[0].rollupRegionNo").value(orgUnit.getRollupRegionNo())) - .andExpect(jsonPath("$[0].rollupRegionCode").value(orgUnit.getRollupRegionCode())) - .andExpect(jsonPath("$[0].rollupDistNo").value(orgUnit.getRollupDistNo())) - .andExpect(jsonPath("$[0].rollupDistCode").value(orgUnit.getRollupDistCode())) - .andExpect(jsonPath("$[0].effectiveDate").value(effectiveDateStr)) - .andExpect(jsonPath("$[0].expiryDate").value(expiryDateStr)) - .andExpect(jsonPath("$[0].updateTimestamp").value(updateTimestampStr)) .andReturn(); } @Test @DisplayName("Get Opening Org Units By Code not Found should Succeed") void getOpeningOrgUnitsByCode_notFound_shouldSucceed() throws Exception { - when(orgUnitService.findAllOrgUnitsByCode(List.of("DAS"))).thenReturn(List.of()); + //when(orgUnitService.findAllOrgUnitsByCode(List.of("DAS"))).thenReturn(List.of()); mockMvc .perform( - get("/api/opening-search/org-units-by-code?orgUnitCodes=DAS") + get("/api/opening-search/org-units-by-code?orgUnitCodes=XYZ") .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpointTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpointTest.java deleted file mode 100644 index 38d64a88..00000000 --- a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardExtractionEndpointTest.java +++ /dev/null @@ -1,152 +0,0 @@ -package ca.bc.gov.restapi.results.postgres.endpoint; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -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.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.common.security.LoggedUserService; -import ca.bc.gov.restapi.results.common.service.DashboardExtractionService; -import ca.bc.gov.restapi.results.postgres.configuration.DashboardUserManagerConfiguration; -import ca.bc.gov.restapi.results.postgres.entity.OracleExtractionLogsEntity; -import ca.bc.gov.restapi.results.postgres.repository.OracleExtractionLogsRepository; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.data.domain.Sort; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MockMvc; - -@WebMvcTest(DashboardExtractionEndpoint.class) -@WithMockUser -class DashboardExtractionEndpointTest { - - @Autowired private MockMvc mockMvc; - - @MockBean private OracleExtractionLogsRepository oracleExtractionLogsRepository; - - @MockBean private DashboardExtractionService dashboardExtractionService; - - @MockBean private DashboardUserManagerConfiguration dashboardUserManagerConfiguration; - - @MockBean private LoggedUserService loggedUserService; - - @Test - @DisplayName("Start extraction process manually happy path should succeed") - void startExtractionProcessManually_happyPath_shouldSucceed() throws Exception { - Integer months = 24; - Boolean debug = Boolean.FALSE; - - when(dashboardUserManagerConfiguration.getUserList()).thenReturn(List.of("TEST")); - when(loggedUserService.getLoggedUserIdirOrBceId()).thenReturn("TEST"); - doNothing().when(dashboardExtractionService).extractDataForTheDashboard(months, debug, true); - - mockMvc - .perform( - post("/api/dashboard-extraction/start?months={months}&debug={debug}", months, debug) - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()) - .andReturn(); - } - - @Test - @DisplayName("Start extraction process manually user not authorized should fail") - void startExtractionProcessManually_userNotAuthorized_shouldFail() throws Exception { - Integer months = 24; - Boolean debug = Boolean.FALSE; - - when(dashboardUserManagerConfiguration.getUserList()).thenReturn(List.of("TEST")); - when(loggedUserService.getLoggedUserIdirOrBceId()).thenReturn("TEST"); - doNothing().when(dashboardExtractionService).extractDataForTheDashboard(months, debug, true); - - mockMvc - .perform( - post("/api/dashboard-extraction/start?months={months}&debug={debug}", months, debug) - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()) - .andReturn(); - } - - @Test - @DisplayName("Start extraction process manually empty users should fail") - void getLastExtractionLogs_emptyUsers_shouldFail() throws Exception { - Integer months = 24; - Boolean debug = Boolean.FALSE; - - when(dashboardUserManagerConfiguration.getUserList()).thenReturn(List.of()); - - mockMvc - .perform( - post("/api/dashboard-extraction/start?months={months}&debug={debug}", months, debug) - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isUnauthorized()) - .andReturn(); - } - - @Test - @DisplayName("Start extraction process manually user not authorized should fail") - void getLastExtractionLogs_userNotAuthorized_shouldFail() throws Exception { - Integer months = 24; - Boolean debug = Boolean.FALSE; - - when(dashboardUserManagerConfiguration.getUserList()).thenReturn(List.of("AA")); - when(loggedUserService.getLoggedUserIdirOrBceId()).thenReturn("BB"); - - mockMvc - .perform( - post("/api/dashboard-extraction/start?months={months}&debug={debug}", months, debug) - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isUnauthorized()) - .andReturn(); - } - - @Test - @DisplayName("Get last extraction logs happy path should succeed") - void getLastExtractionLogs_happyPath_shouldSucceed() throws Exception { - OracleExtractionLogsEntity extractionLogs = new OracleExtractionLogsEntity(); - extractionLogs.setId(1L); - extractionLogs.setLogMessage("Test message"); - extractionLogs.setManuallyTriggered(false); - when(oracleExtractionLogsRepository.findAll(any(Sort.class))) - .thenReturn(List.of(extractionLogs)); - - mockMvc - .perform( - get("/api/dashboard-extraction/logs") - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$[0].id").value("1")) - .andExpect(jsonPath("$[0].logMessage").value("Test message")) - .andExpect(jsonPath("$[0].manuallyTriggered").value("false")) - .andReturn(); - } - - @Test - @DisplayName("Get last extraction logs empty logs should succeed") - void getLastExtractionLogs_emptyLogs_shouldSucceed() throws Exception { - when(oracleExtractionLogsRepository.findAll(any(Sort.class))).thenReturn(List.of()); - - mockMvc - .perform( - get("/api/dashboard-extraction/logs") - .with(csrf().asHeader()) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()) - .andReturn(); - } -} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardMetricsEndpointTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardMetricsEndpointTest.java index c09eefcf..e5c8a315 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardMetricsEndpointTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/DashboardMetricsEndpointTest.java @@ -1,43 +1,35 @@ package ca.bc.gov.restapi.results.postgres.endpoint; -import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.postgres.dto.DashboardFiltersDto; +import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; import ca.bc.gov.restapi.results.postgres.dto.FreeGrowingMilestonesDto; -import ca.bc.gov.restapi.results.postgres.dto.OpeningsPerYearDto; -import ca.bc.gov.restapi.results.postgres.service.DashboardMetricsService; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; -@WebMvcTest(DashboardMetricsEndpoint.class) -@WithMockUser -class DashboardMetricsEndpointTest { +@DisplayName("Integrated Test | Dashboard Metrics Endpoint") +@AutoConfigureMockMvc +@WithMockJwt +class DashboardMetricsEndpointTest extends AbstractTestContainerIntegrationTest { - @Autowired private MockMvc mockMvc; - - @MockBean private DashboardMetricsService dashboardMetricsService; + @Autowired + private MockMvc mockMvc; @Test @DisplayName("Opening submission trends with no filters should succeed") void getOpeningsSubmissionTrends_noFilters_shouldSucceed() throws Exception { - DashboardFiltersDto filtersDto = new DashboardFiltersDto(null, null, null, null, null); - - OpeningsPerYearDto dto = new OpeningsPerYearDto(1, "Jan", 70); - when(dashboardMetricsService.getOpeningsSubmissionTrends(filtersDto)).thenReturn(List.of(dto)); mockMvc .perform( @@ -49,16 +41,13 @@ void getOpeningsSubmissionTrends_noFilters_shouldSucceed() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$[0].month").value("1")) .andExpect(jsonPath("$[0].monthName").value("Jan")) - .andExpect(jsonPath("$[0].amount").value("70")) + .andExpect(jsonPath("$[0].amount").value("1")) .andReturn(); } @Test @DisplayName("Opening submission trends with no data should succeed") void getOpeningsSubmissionTrends_orgUnitFilter_shouldSucceed() throws Exception { - DashboardFiltersDto filtersDto = new DashboardFiltersDto("DCR", null, null, null, null); - - when(dashboardMetricsService.getOpeningsSubmissionTrends(filtersDto)).thenReturn(List.of()); mockMvc .perform( @@ -66,19 +55,13 @@ void getOpeningsSubmissionTrends_orgUnitFilter_shouldSucceed() throws Exception .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()) + .andExpect(status().isOk()) .andReturn(); } @Test @DisplayName("Free growing milestones test with no filters should succeed") void getFreeGrowingMilestonesData_noFilters_shouldSucceed() throws Exception { - DashboardFiltersDto filtersDto = new DashboardFiltersDto(null, null, null, null, null); - - FreeGrowingMilestonesDto milestonesDto = - new FreeGrowingMilestonesDto(0, "0 - 5 months", 25, new BigDecimal("100")); - when(dashboardMetricsService.getFreeGrowingMilestoneChartData(filtersDto)) - .thenReturn(List.of(milestonesDto)); mockMvc .perform( @@ -90,8 +73,8 @@ void getFreeGrowingMilestonesData_noFilters_shouldSucceed() throws Exception { .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$[0].index").value("0")) .andExpect(jsonPath("$[0].label").value("0 - 5 months")) - .andExpect(jsonPath("$[0].amount").value("25")) - .andExpect(jsonPath("$[0].percentage").value(new BigDecimal("100"))) + .andExpect(jsonPath("$[0].amount").value("0")) + .andExpect(jsonPath("$[0].percentage").value("0.0")) .andReturn(); } @@ -104,10 +87,6 @@ void getFreeGrowingMilestonesData_clientNumberFilter_shouldSucceed() throws Exce dtoList.add(new FreeGrowingMilestonesDto(2, "12 - 17 months", 25, new BigDecimal("25"))); dtoList.add(new FreeGrowingMilestonesDto(3, "18 months", 25, new BigDecimal("25"))); - DashboardFiltersDto filtersDto = new DashboardFiltersDto(null, null, null, null, "00012797"); - - when(dashboardMetricsService.getFreeGrowingMilestoneChartData(filtersDto)).thenReturn(dtoList); - mockMvc .perform( get("/api/dashboard-metrics/free-growing-milestones?clientNumber=00012797") @@ -118,30 +97,13 @@ void getFreeGrowingMilestonesData_clientNumberFilter_shouldSucceed() throws Exce .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$[0].index").value("0")) .andExpect(jsonPath("$[0].label").value("0 - 5 months")) - .andExpect(jsonPath("$[0].amount").value("25")) - .andExpect(jsonPath("$[0].percentage").value(new BigDecimal("25"))) - .andExpect(jsonPath("$[1].index").value("1")) - .andExpect(jsonPath("$[1].label").value("6 - 11 months")) - .andExpect(jsonPath("$[1].amount").value("25")) - .andExpect(jsonPath("$[1].percentage").value(new BigDecimal("25"))) - .andExpect(jsonPath("$[2].index").value("2")) - .andExpect(jsonPath("$[2].label").value("12 - 17 months")) - .andExpect(jsonPath("$[2].amount").value("25")) - .andExpect(jsonPath("$[2].percentage").value(new BigDecimal("25"))) - .andExpect(jsonPath("$[3].index").value("3")) - .andExpect(jsonPath("$[3].label").value("18 months")) - .andExpect(jsonPath("$[3].amount").value("25")) - .andExpect(jsonPath("$[3].percentage").value(new BigDecimal("25"))) + .andExpect(jsonPath("$[0].amount").value("0")) .andReturn(); } @Test @DisplayName("Free growing milestones test with no content should succeed") void getFreeGrowingMilestonesData_noData_shouldSucceed() throws Exception { - DashboardFiltersDto filtersDto = new DashboardFiltersDto(null, null, null, null, "00012579"); - - when(dashboardMetricsService.getFreeGrowingMilestoneChartData(filtersDto)) - .thenReturn(List.of()); mockMvc .perform( @@ -149,7 +111,7 @@ void getFreeGrowingMilestonesData_noData_shouldSucceed() throws Exception { .with(csrf().asHeader()) .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isNoContent()) + .andExpect(status().isOk()) .andReturn(); } } 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 fe740f50..15f639f9 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 @@ -1,5 +1,6 @@ package ca.bc.gov.restapi.results.postgres.service; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; @@ -11,6 +12,7 @@ import ca.bc.gov.restapi.results.postgres.entity.UserOpeningEntity; import ca.bc.gov.restapi.results.postgres.repository.OpeningsActivityRepository; import ca.bc.gov.restapi.results.postgres.repository.UserOpeningRepository; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -82,4 +84,24 @@ void removeUserFavoriteOpening_notFound_shouldFail() { userOpeningService.removeUserFavoriteOpening(112233L); }); } + + @Test + @DisplayName("List user favourite openings happy path should succeed") + void listUserFavoriteOpenings_happyPath_shouldSucceed() { + when(loggedUserService.getLoggedUserId()).thenReturn(USER_ID); + when(userOpeningRepository.findAllByUserId(any(), any())).thenReturn(List.of(new UserOpeningEntity())); + userOpeningService.listUserFavoriteOpenings(); + } + + @Test + @DisplayName("Check for favorites happy path should succeed") + void checkForFavorites_happyPath_shouldSucceed() { + when(loggedUserService.getLoggedUserId()).thenReturn(USER_ID); + when(userOpeningRepository.findAllByUserIdAndOpeningIdIn(any(), any())).thenReturn(List.of(new UserOpeningEntity(USER_ID,112233L))); + assertThat(userOpeningService.checkForFavorites(List.of(112233L))) + .isNotNull() + .isNotEmpty() + .hasSize(1) + .contains(112233L); + } } diff --git a/frontend/src/__test__/components/OpeningsTab.test.tsx b/frontend/src/__test__/components/OpeningsTab.test.tsx index d96aa034..d09eb800 100644 --- a/frontend/src/__test__/components/OpeningsTab.test.tsx +++ b/frontend/src/__test__/components/OpeningsTab.test.tsx @@ -3,15 +3,10 @@ import React from 'react'; import { render, act, waitFor, screen } from '@testing-library/react'; import OpeningsTab from '../../components/OpeningsTab'; import { AuthProvider } from '../../contexts/AuthProvider'; -import { getWmsLayersWhitelistUsers } from '../../services/SecretsService'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import PaginationProvider from '../../contexts/PaginationProvider'; -vi.mock('../../services/SecretsService', () => ({ - getWmsLayersWhitelistUsers: vi.fn() -})); - vi.mock('../../services/OpeningService', async () => { const actual = await vi.importActual('../../services/OpeningService'); return { @@ -24,8 +19,7 @@ const queryClient = new QueryClient(); describe('Openings Tab test',() => { it('should render properly',async () =>{ - (getWmsLayersWhitelistUsers as vi.Mock).mockResolvedValue([{userName: 'TEST'}]); - + await act(async () => { render( @@ -41,8 +35,7 @@ describe('Openings Tab test',() => { }); it('should have Hide map when the showSpatial is true',async () =>{ - (getWmsLayersWhitelistUsers as vi.Mock).mockResolvedValue([{userName: 'TEST'}]); - + await act(async () => { render( @@ -64,9 +57,7 @@ describe('Openings Tab test',() => { .mockImplementationOnce(() => [true, vi.fn()]) // for openingPolygonNotFound .mockImplementationOnce(() => [{ userName: 'TEST' }, vi.fn()]) // for wmsUsersWhitelist .mockImplementationOnce(() => [[], vi.fn()]); // for headers - - (getWmsLayersWhitelistUsers as vi.Mock).mockResolvedValue([{ userName: 'TEST' }]); - + await act(async () => { render( diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx index 790329ab..46760a00 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import SearchScreenDataTable from '../../../../components/SilvicultureSearch/Openings/SearchScreenDataTable'; import { searchScreenColumns as columns } from '../../../../constants/tableConstants'; @@ -8,6 +8,16 @@ import { NotificationProvider } from '../../../../contexts/NotificationProvider' import { BrowserRouter } from 'react-router-dom'; import { OpeningsSearchProvider } from '../../../../contexts/search/OpeningsSearch'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { setOpeningFavorite, deleteOpeningFavorite } from '../../../../services/OpeningFavouriteService'; + +vi.mock('../../../../services/OpeningFavouriteService', async () => { + const actual = await vi.importActual('../../../../services/OpeningFavouriteService'); + return { + ...actual, + setOpeningFavorite: vi.fn((openingIds: number[]) => {}), + deleteOpeningFavorite: vi.fn((openingIds: number[]) => {}) + }; +}); const handleCheckboxChange = vi.fn(); const toggleSpatial = vi.fn(); @@ -18,7 +28,7 @@ const rows:any = [ { id: '114207', openingId: '114207', - fileId: 'TFL47', + fileId: 'TFL99', cuttingPermit: '12S', timberMark: '47/12S', cutBlock: '12-69', @@ -28,7 +38,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2022-10-27', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-27' + lastViewed: '2022-10-27', + favourite: false }, { id: '114206', @@ -43,7 +54,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2022-09-04', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-27' + lastViewed: '2022-10-27', + favourite: true }, { id: '114205', @@ -58,7 +70,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2022-09-04', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-27' + lastViewed: '2022-10-27', + favourite: false }, { id: '114204', @@ -73,7 +86,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2022-01-16', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-26' + lastViewed: '2022-10-26', + favourite: false }, { id: '114203', @@ -88,7 +102,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-12-08', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-26' + lastViewed: '2022-10-26', + favourite: false }, { id: '114202', @@ -103,7 +118,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-11-15', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-25' + lastViewed: '2022-10-25', + favourite: false }, { id: '114201', @@ -118,7 +134,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-11-15', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-25' + lastViewed: '2022-10-25', + favourite: false }, { id: '114200', @@ -133,7 +150,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-10-20', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-24' + lastViewed: '2022-10-24', + favourite: false }, { id: '114199', @@ -148,7 +166,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-10-20', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-24' + lastViewed: '2022-10-24', + favourite: false }, { id: '114198', @@ -163,7 +182,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-09-12', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-23' + lastViewed: '2022-10-23', + favourite: false }, { id: '114197', @@ -178,7 +198,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-09-12', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-23' + lastViewed: '2022-10-23', + favourite: false }, { id: '114196', @@ -193,7 +214,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-08-05', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-22' + lastViewed: '2022-10-22', + favourite: false }, { id: '114195', @@ -208,7 +230,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-08-05', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-22' + lastViewed: '2022-10-22', + favourite: false }, { id: '114194', @@ -223,7 +246,8 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-07-10', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-21' + lastViewed: '2022-10-21', + favourite: false }, { id: '114193', @@ -238,10 +262,12 @@ const rows:any = [ disturbanceStart: '-', createdAt: '2021-07-10', orgUnit: 'DCC - Cariboo chilcotin natural resources', - lastViewed: '2022-10-21' + lastViewed: '2022-10-21', + favourite: false } ]; + describe('Search Screen Data table test', () => { it('should render the Search Screen Data table', () => { @@ -306,8 +332,8 @@ describe('Search Screen Data table test', () => { expect(container.querySelector('.total-search-results')).toContainHTML('0'); }); - it('should render the checkbox for showSpatial being true', () => { - render( + it('should render the checkbox for showSpatial being true', async () => { + await act(async () => render( @@ -317,7 +343,7 @@ describe('Search Screen Data table test', () => { rows={rows} headers={columns} defaultColumns={columns} - showSpatial={false} + showSpatial={true} handleCheckboxChange={handleCheckboxChange} toggleSpatial={toggleSpatial} totalItems={0} @@ -328,7 +354,10 @@ describe('Search Screen Data table test', () => { - ); + )); + + expect(screen.getByTestId('toggle-spatial')).toContainHTML('Hide map'); + const checkbox = document.querySelector('.cds--checkbox-group'); expect(checkbox).toBeInTheDocument(); @@ -368,4 +397,60 @@ describe('Search Screen Data table test', () => { }); + it('should favorite and unfavorite an opening', async () => { + + (setOpeningFavorite as vi.Mock).mockResolvedValue({}); + (deleteOpeningFavorite as vi.Mock).mockResolvedValue({}); + + let container; + + await act(async () => + ({ container } = + render( + + + + + + + + + + + + ))); + + expect(container).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toBeInTheDocument(); + expect(container.querySelector('.total-search-results')).toContainHTML('Total Search Results'); + + await act(async () => expect(screen.getByTestId('row-114207')).toBeInTheDocument() ); + await act(async () => expect(screen.getByTestId('cell-actions-114206')).toBeInTheDocument() ); + + const overflowMenu = screen.getByTestId('action-ofl-114207'); + await act(async () => expect(overflowMenu).toBeInTheDocument() ); + await act(async () => fireEvent.click(overflowMenu)); + + const actionOverflow = screen.getByTestId(`action-fav-114207`); + await act(async () => expect(actionOverflow).toBeInTheDocument() ); + expect(actionOverflow).toContainHTML('Favourite opening'); + await act(async () => fireEvent.click(actionOverflow)); + + const overflowMenuAgain = screen.getByTestId('action-ofl-114207'); + await act(async () => expect(overflowMenuAgain).toBeInTheDocument() ); + await act(async () => fireEvent.click(overflowMenuAgain)); + + expect(screen.getByTestId(`action-fav-114207`)).toContainHTML('Unfavourite opening'); + + }); + }); \ 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 c890dba6..89286948 100644 --- a/frontend/src/__test__/screens/Opening.test.tsx +++ b/frontend/src/__test__/screens/Opening.test.tsx @@ -6,7 +6,6 @@ import PaginationContext from '../../contexts/PaginationContext'; import { NotificationProvider } from '../../contexts/NotificationProvider'; import { BrowserRouter } from 'react-router-dom'; import { RecentOpening } from '../../types/RecentOpening'; -import { getWmsLayersWhitelistUsers } from '../../services/SecretsService'; import { fetchFreeGrowingMilestones, fetchOpeningsPerYear, fetchRecentActions } from '../../services/OpeningService'; import { fetchOpeningFavourites } from '../../services/OpeningFavouriteService'; import { AuthProvider } from '../../contexts/AuthProvider'; @@ -25,10 +24,6 @@ vi.mock('../../services/OpeningFavouriteService', () => ({ fetchOpeningFavourites: vi.fn(), })); -vi.mock('../../services/SecretsService', () => ({ - getWmsLayersWhitelistUsers: vi.fn() -})); - vi.mock('../../services/OpeningService', async () => { const actual = await vi.importActual('../../services/OpeningService'); return { @@ -77,9 +72,7 @@ const queryClient = new QueryClient(); describe('Opening screen test cases', () => { beforeEach(() => { - vi.clearAllMocks(); - - (getWmsLayersWhitelistUsers as vi.Mock).mockResolvedValue([{userName: 'TEST'}]); + vi.clearAllMocks(); (fetchOpeningsPerYear as vi.Mock).mockResolvedValue([ { group: '2022', key: 'Openings', value: 10 }, { group: '2023', key: 'Openings', value: 15 }, diff --git a/frontend/src/__test__/services/OpeningFavoriteService.test.ts b/frontend/src/__test__/services/OpeningFavoriteService.test.ts index bb25235b..ab840c8d 100644 --- a/frontend/src/__test__/services/OpeningFavoriteService.test.ts +++ b/frontend/src/__test__/services/OpeningFavoriteService.test.ts @@ -23,7 +23,9 @@ describe('OpeningFavouriteService', () => { const result = await fetchOpeningFavourites(); expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites`, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual(mockData); }); @@ -35,7 +37,9 @@ describe('OpeningFavouriteService', () => { const result = await fetchOpeningFavourites(); expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites`, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual(mockData); }); @@ -53,7 +57,9 @@ describe('OpeningFavouriteService', () => { const result = await fetchOpeningFavourites(); expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites`, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual(mockData); }); @@ -65,7 +71,9 @@ describe('OpeningFavouriteService', () => { const result = await fetchOpeningFavourites(); expect(axios.get).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites`, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual(mockData); }); @@ -83,7 +91,9 @@ describe('OpeningFavouriteService', () => { await setOpeningFavorite(openingId); expect(axios.put).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites/${openingId}`, null, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); }); @@ -101,7 +111,9 @@ describe('OpeningFavouriteService', () => { await deleteOpeningFavorite(openingId); expect(axios.delete).toHaveBeenCalledWith(`${backendUrl}/api/openings/favourites/${openingId}`, { - headers: { Authorization: `Bearer ${authToken}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); }); diff --git a/frontend/src/__test__/services/OpeningService.test.ts b/frontend/src/__test__/services/OpeningService.test.ts index e3d050b2..582d9152 100644 --- a/frontend/src/__test__/services/OpeningService.test.ts +++ b/frontend/src/__test__/services/OpeningService.test.ts @@ -32,7 +32,9 @@ describe('OpeningService', () => { 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}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual([ { group: 'Openings', key: 'January', value: 10 }, @@ -59,7 +61,9 @@ describe('OpeningService', () => { 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}` } + headers: { Authorization: `Bearer ${authToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" } }); expect(result).toEqual([ { group: 'Milestone1', value: 10 }, diff --git a/frontend/src/__test__/services/queries/dashboard/dashboardQueries.test.tsx b/frontend/src/__test__/services/queries/dashboard/dashboardQueries.test.tsx index 82d05cca..64efe999 100644 --- a/frontend/src/__test__/services/queries/dashboard/dashboardQueries.test.tsx +++ b/frontend/src/__test__/services/queries/dashboard/dashboardQueries.test.tsx @@ -23,7 +23,9 @@ describe("postViewedOpening", () => { const result = await postViewedOpening(openingId); expect(axios.put).toHaveBeenCalledWith(`${backendUrl}/api/openings/recent/${openingId}`, null, { - headers: { Authorization: `Bearer testAuthToken` }, + headers: { Authorization: `Bearer testAuthToken`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" }, }); expect(result).toEqual(mockResponse.data); }); @@ -85,7 +87,9 @@ describe("usePostViewedOpening", () => { // Wait for axios call await waitFor(() => expect(axios.put).toHaveBeenCalledWith(`${backendUrl}/api/openings/recent/123`, null, { - headers: { Authorization: `Bearer testAuthToken` }, + headers: { Authorization: `Bearer testAuthToken`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" }, }) ); }); diff --git a/frontend/src/__test__/services/search/openings.test.tsx b/frontend/src/__test__/services/search/openings.test.tsx index 5dec0d22..71ed1836 100644 --- a/frontend/src/__test__/services/search/openings.test.tsx +++ b/frontend/src/__test__/services/search/openings.test.tsx @@ -90,6 +90,8 @@ describe("fetchOpenings", () => { expect.objectContaining({ headers: { Authorization: `Bearer ${expectedToken}`, + "Access-Control-Allow-Origin": "http://localhost:3000", + "Content-Type": "application/json" }, }) ); diff --git a/frontend/src/components/OpeningsMap/index.tsx b/frontend/src/components/OpeningsMap/index.tsx index 64d769b2..664fdc6d 100644 --- a/frontend/src/components/OpeningsMap/index.tsx +++ b/frontend/src/components/OpeningsMap/index.tsx @@ -40,7 +40,9 @@ const OpeningsMap: React.FC = ({ const getOpeningPolygonAndProps = async (selectedOpeningId: number | null): Promise => { const urlApi = `/api/feature-service/polygon-and-props/${selectedOpeningId}`; const response = await axios.get(backendUrl.concat(urlApi), { - headers: { Authorization: `Bearer ${authToken}` } + headers: { 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, + Authorization: `Bearer ${authToken}` } }); const { data } = response; diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index 6585356e..9b85458c 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -40,7 +40,7 @@ import { downloadXLSX, } from "../../../../utils/fileConversions"; import { useNavigate } from "react-router-dom"; -import { setOpeningFavorite } from "../../../../services/OpeningFavouriteService"; +import { setOpeningFavorite, deleteOpeningFavorite } from "../../../../services/OpeningFavouriteService"; import { usePostViewedOpening } from "../../../../services/queries/dashboard/dashboardQueries"; import { useNotification } from "../../../../contexts/NotificationProvider"; import TruncatedText from "../../../TruncatedText"; @@ -156,19 +156,30 @@ const SearchScreenDataTable: React.FC = ({ }; //Function to handle the favourite feature of the opening for a user - const handleFavouriteOpening = (openingId: string) => { - try { - setOpeningFavorite(parseInt(openingId)); - displayNotification({ - title: `Opening Id ${openingId} favourited`, - subTitle: "You can follow this opening ID on your dashboard", - type: "success", - buttonLabel: "Go to track openings", - onClose: () => { - navigate("/opening?tab=metrics&scrollTo=trackOpenings"); - }, - }); - } catch (error) { + const handleFavouriteOpening = async (openingId: string, favorite: boolean) => { + try{ + if(favorite){ + await deleteOpeningFavorite(parseInt(openingId)); + displayNotification({ + title: `Opening Id ${openingId} unfavourited`, + type: 'success', + dismissIn: 8000, + onClose: () => {} + }); + }else{ + await setOpeningFavorite(parseInt(openingId)); + displayNotification({ + title: `Opening Id ${openingId} favourited`, + subTitle: 'You can follow this opening ID on your dashboard', + type: "success", + buttonLabel: "Go to track openings", + onClose: () => { + navigate('/opening?tab=metrics&scrollTo=trackOpenings') + } + }); + } + + } catch (favoritesError) { displayNotification({ title: "Unable to process your request", subTitle: "Please try again in a few minutes", @@ -348,10 +359,14 @@ const SearchScreenDataTable: React.FC = ({ {rows && rows.map((row: any, i: number) => ( - + {headers.map((header) => header.selected ? ( (cellRefs.current[i] = el)} key={header.key} className={ @@ -366,39 +381,47 @@ const SearchScreenDataTable: React.FC = ({ {header.key === "statusDescription" ? ( ) : header.key === "actions" ? ( - - {/* Checkbox for selecting rows */} - {showSpatial && ( - -
- - handleRowSelectionChanged(row.openingId) - } - /> -
-
+ <> + {showSpatial && ( + + {/* Checkbox for selecting rows */} + + +
+ + handleRowSelectionChanged(row.openingId) + } + /> +
+
+
)} - + {!showSpatial &&( + - handleFavouriteOpening(row.openingId) + data-testid={`action-fav-${row.openingId}`} + itemText={row.favourite ? "Unfavourite opening" : "Favourite opening"} + onClick={() =>{ + handleFavouriteOpening(row.openingId,row.favourite) + row.favourite = !row.favourite; + } } /> = ({ /> -
+ )} + + + ) : header.header === "Category" ? ( (undefined); // 4. Create the AuthProvider component with explicit typing -export const AuthProvider: React.FC = ({ children }) => { - const [isLoggedIn, setIsLoggedIn] = useState(false); +export const AuthProvider: React.FC = ({ children }) => { const [user, setUser] = useState(undefined); const [userRoles, setUserRoles] = useState(undefined); const [isLoading, setIsLoading] = useState(true); const appEnv = env.VITE_ZONE ?? 'DEV'; + const refreshUserState = async () => { + setIsLoading(true); + try { + const idToken = await loadUserToken(); + if (idToken) { + setUser(parseToken(idToken)); + setUserRoles(extractGroups(idToken.payload)); + } else { + setUser(undefined); + setUserRoles(undefined); + } + } catch { + setUser(undefined); + setUserRoles(undefined); + } finally { + setIsLoading(false); + } + }; - useEffect(() => { - const checkUser = async () => { - try{ - const idToken = await loadUserToken(); - setIsLoggedIn(!!idToken); - setIsLoading(false); - if(idToken){ - setUser(parseToken(idToken)); - setUserRoles(extractGroups(idToken?.payload)); - } - }catch(error){ - setIsLoggedIn(false); - setUser(parseToken(undefined)); - setIsLoading(false); - } - }; - checkUser(); + useEffect(() => { + refreshUserState(); + const interval = setInterval(refreshUserState, 3 * 60 * 1000); + return () => clearInterval(interval); }, []); + + const login = async (provider: string) => { const envProvider = (provider.localeCompare('idir') === 0) ?`${(appEnv).toLocaleUpperCase()}-IDIR` @@ -65,18 +71,19 @@ export const AuthProvider: React.FC = ({ children }) => { const logout = async () => { await signOut(); - setIsLoggedIn(false); + setUser(undefined); + setUserRoles(undefined); window.location.href = '/'; // Optional redirect after logout }; const contextValue = useMemo(() => ({ user, userRoles, - isLoggedIn, + isLoggedIn: !!user, isLoading, login, logout - }), [user, userRoles, isLoggedIn, isLoading]); + }), [user, userRoles, isLoading]); return ( @@ -97,19 +104,16 @@ export const useGetAuth = (): AuthContextType => { const loadUserToken = async () : Promise => { if(env.NODE_ENV !== 'test'){ - const {idToken} = (await fetchAuthSession()).tokens ?? {}; - return Promise.resolve(idToken); + const { idToken } = (await fetchAuthSession()).tokens ?? {}; + return idToken; } else { // This is for test only const token = getUserTokenFromCookie(); if (token) { - const jwtBody = token - ? JSON.parse(atob(token.split(".")[1])) - : null; - return Promise.resolve({ payload: jwtBody }); - } else { - return Promise.reject(new Error("No token found")); - } + const jwtBody = JSON.parse(atob(token.split(".")[1])); + return { payload: jwtBody }; + } + throw new Error("No token found"); } }; @@ -117,9 +121,7 @@ const getUserTokenFromCookie = (): string|undefined => { const baseCookieName = `CognitoIdentityServiceProvider.${env.VITE_USER_POOLS_WEB_CLIENT_ID}`; const userId = encodeURIComponent(getCookie(`${baseCookieName}.LastAuthUser`)); if (userId) { - const idTokenCookieName = `${baseCookieName}.${userId}.idToken`; - const idToken = getCookie(idTokenCookieName); - return idToken; + return getCookie(`${baseCookieName}.${userId}.idToken`); } else { return undefined; } diff --git a/frontend/src/services/OpeningFavouriteService.ts b/frontend/src/services/OpeningFavouriteService.ts index ea10ed14..a64976e9 100644 --- a/frontend/src/services/OpeningFavouriteService.ts +++ b/frontend/src/services/OpeningFavouriteService.ts @@ -18,6 +18,8 @@ export const fetchOpeningFavourites = async (): Promise =>{ const response = await axios.get( `${backendUrl}/api/openings/favourites`, { headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, Authorization: `Bearer ${authToken}` } }); @@ -42,6 +44,8 @@ export const setOpeningFavorite = async (openingId: number): Promise => { const response = await axios.put( `${backendUrl}/api/openings/favourites/${openingId}`, null, { headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, Authorization: `Bearer ${authToken}` } }); @@ -63,6 +67,8 @@ export const deleteOpeningFavorite = async (openingId: number): Promise => const response = await axios.delete( `${backendUrl}/api/openings/favourites/${openingId}`, { headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, Authorization: `Bearer ${authToken}` } }); diff --git a/frontend/src/services/OpeningService.ts b/frontend/src/services/OpeningService.ts index 2a1bc56d..257a40ce 100644 --- a/frontend/src/services/OpeningService.ts +++ b/frontend/src/services/OpeningService.ts @@ -3,9 +3,7 @@ import { getAuthIdToken } from './AuthService'; import { env } from '../env'; import { RecentAction } from '../types/RecentAction'; import { OpeningPerYearChart } from '../types/OpeningPerYearChart'; -import { RecentOpening } from '../types/RecentOpening'; import { - RecentOpeningApi, IOpeningPerYear, IFreeGrowingProps, IFreeGrowingChartData @@ -35,7 +33,9 @@ export async function fetchOpeningsPerYear(props: IOpeningPerYear): Promise { try { const response = await axios.get(backendUrl.concat("/api/users/recent-actions"),{ headers: { - Authorization: `Bearer ${authToken}` + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, + Authorization: `Bearer ${authToken}` } }); diff --git a/frontend/src/services/SecretsService.ts b/frontend/src/services/SecretsService.ts deleted file mode 100644 index e530f2a1..00000000 --- a/frontend/src/services/SecretsService.ts +++ /dev/null @@ -1,40 +0,0 @@ -import axios from 'axios'; -import { getAuthIdToken } from './AuthService'; -import { env } from '../env'; - -const backendUrl = env.VITE_BACKEND_URL || ''; - -export interface WmsLayersWhitelistUser { - userName: string -} - -/** - * Get the list of users that can see and download WMS layers information. - * - * @returns {Promise} Array of objects found - */ -export async function getWmsLayersWhitelistUsers(): Promise { - const authToken = getAuthIdToken(); - try { - const response = await axios.get(backendUrl.concat("/api/secrets/wms-layers-whitelist"), { - headers: { - Authorization: `Bearer ${authToken}` - } - }); - - if (response.status >= 200 && response.status < 300) { - if (response.data) { - // Extracting row information from the fetched data - const rows: WmsLayersWhitelistUser[] = response.data.map((user: WmsLayersWhitelistUser) => ({ - userName: user.userName - })); - - return rows; - } - } - return []; - } catch (error) { - console.error('Error fetching wms whitelist users:', error); - throw error; - } -} diff --git a/frontend/src/services/TestService.ts b/frontend/src/services/TestService.ts deleted file mode 100644 index d432535f..00000000 --- a/frontend/src/services/TestService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from 'axios'; -import { ForestClientType } from '../types/ForestClientTypes/ForestClientType'; -import { env } from '../env'; -import { getAuthIdToken } from './AuthService'; - -const backendUrl = env.VITE_BACKEND_URL; - -export const getForestClientByNumberOrAcronym = async (numberOrAcronym: string): Promise => { - const url = `${backendUrl}/api/forest-clients/${numberOrAcronym}`; - const authToken = getAuthIdToken(); - - try { - const response = await axios.get(url, { - headers: { - Authorization: `Bearer ${authToken}` - } - }); - - return response.data as ForestClientType; - } catch (error) { - console.error(`Failed to fetch forest client with ID or Acronym ${numberOrAcronym}:`, error); - throw error; - } -}; diff --git a/frontend/src/services/queries/dashboard/dashboardQueries.ts b/frontend/src/services/queries/dashboard/dashboardQueries.ts index f4c3be7b..fde7b6ab 100644 --- a/frontend/src/services/queries/dashboard/dashboardQueries.ts +++ b/frontend/src/services/queries/dashboard/dashboardQueries.ts @@ -11,7 +11,9 @@ export const postViewedOpening = async (openingId: string): Promise => { try { const response = await axios.put(`${backendUrl}/api/openings/recent/${openingId}`, null, { headers: { - Authorization: `Bearer ${authToken}`, + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, + Authorization: `Bearer ${authToken}`, }, }); return response.data; diff --git a/frontend/src/services/search/openings.ts b/frontend/src/services/search/openings.ts index 0e87a155..0c2e4025 100644 --- a/frontend/src/services/search/openings.ts +++ b/frontend/src/services/search/openings.ts @@ -56,6 +56,12 @@ export interface OpeningItem { const backendUrl = env.VITE_BACKEND_URL; +const buildDefaultHeaders = (authToken: string|null) => ({ + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, + Authorization: `Bearer ${authToken}` +}); + export const fetchOpenings = async (filters: OpeningFilters): Promise => { // Get the date params based on dateType // Get the date params based on dateType @@ -96,9 +102,7 @@ export const fetchOpenings = async (filters: OpeningFilters): Promise => { // Make the API request with the Authorization header const response = await axios.get(`${backendUrl}/api/opening-search${queryString}`, { - headers: { - Authorization: `Bearer ${authToken}` - } + headers: buildDefaultHeaders(authToken) }); // Flatten the data part of the response @@ -127,9 +131,7 @@ export const fetchUserRecentOpenings = async (limit: number): Promise => { // Make the API request with the Authorization header const response = await axios.get(`${backendUrl}/api/openings/recent`, { - headers: { - Authorization: `Bearer ${authToken}` - } + headers: buildDefaultHeaders(authToken) }); // Flatten the data part of the response @@ -156,9 +158,7 @@ export const fetchCategories = async (): Promise => { // Make the API request with the Authorization header const response = await axios.get(backendUrl + "/api/opening-search/categories", { - headers: { - Authorization: `Bearer ${authToken}` - } + headers: buildDefaultHeaders(authToken) }); // Returning the api response data @@ -171,9 +171,7 @@ export const fetchOrgUnits = async (): Promise => { // Make the API request with the Authorization header const response = await axios.get(backendUrl + "/api/opening-search/org-units", { - headers: { - Authorization: `Bearer ${authToken}` - } + headers: buildDefaultHeaders(authToken) }); // Returning the api response data From 2d522d4d7a1e2fd3f8e8c63f1356e6fe3f7d7c09 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Thu, 21 Nov 2024 15:45:31 -0800 Subject: [PATCH 06/10] set min-width for checkbox labels --- .../Openings/SearchScreenDataTable/styles.scss | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss index 5ea10503..d5e7e566 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/styles.scss @@ -59,6 +59,9 @@ letter-spacing: 0.16px; font-weight: 400; } + .bx--checkbox-label { + min-width: 300px; + } } .download-column-content{ From 9d141da065071ed2c36ca63fdbd9889f738cc0ed Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Fri, 22 Nov 2024 13:13:39 -0800 Subject: [PATCH 07/10] added the checkMark functionality to show user column selection --- .../Openings/SearchScreenDataTable/index.tsx | 169 +++++++++++------- .../SearchScreenDataTable/styles.scss | 13 ++ 2 files changed, 116 insertions(+), 66 deletions(-) diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index c738981b..9840b930 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -40,13 +40,17 @@ import { downloadXLSX, } from "../../../../utils/fileConversions"; import { useNavigate } from "react-router-dom"; -import { setOpeningFavorite, deleteOpeningFavorite } from '../../../../services/OpeningFavouriteService'; +import { + setOpeningFavorite, + deleteOpeningFavorite, +} from "../../../../services/OpeningFavouriteService"; import { usePostViewedOpening } from "../../../../services/queries/dashboard/dashboardQueries"; import { useNotification } from "../../../../contexts/NotificationProvider"; import TruncatedText from "../../../TruncatedText"; import FriendlyDate from "../../../FriendlyDate"; import ComingSoonModal from "../../../ComingSoonModal"; import { Icon } from "@carbon/icons-react"; +import { set } from "date-fns"; interface ISearchScreenDataTable { rows: OpeningsSearch[]; @@ -85,6 +89,8 @@ const SearchScreenDataTable: React.FC = ({ const [openDownload, setOpenDownload] = useState(false); const [selectedRows, setSelectedRows] = useState([]); // State to store selected rows const [openingDetails, setOpeningDetails] = useState(""); + const [columnsSelected, setColumnsSelected] = + useState("select-default"); const { mutate: markAsViewedOpening, isError, @@ -156,29 +162,31 @@ const SearchScreenDataTable: React.FC = ({ }; //Function to handle the favourite feature of the opening for a user - const handleFavouriteOpening = async (openingId: string, favorite: boolean) => { - try{ - if(favorite){ + const handleFavouriteOpening = async ( + openingId: string, + favorite: boolean + ) => { + try { + if (favorite) { await deleteOpeningFavorite(parseInt(openingId)); displayNotification({ - title: `Opening Id ${openingId} unfavourited`, - type: 'success', + title: `Opening Id ${openingId} unfavourited`, + type: "success", dismissIn: 8000, - onClose: () => {} + onClose: () => {}, }); - }else{ + } else { await setOpeningFavorite(parseInt(openingId)); displayNotification({ title: `Opening Id ${openingId} favourited`, - subTitle: 'You can follow this opening ID on your dashboard', + subTitle: "You can follow this opening ID on your dashboard", type: "success", buttonLabel: "Go to track openings", onClose: () => { - navigate('/opening?tab=metrics&scrollTo=trackOpenings') - } + navigate("/opening?tab=metrics&scrollTo=trackOpenings"); + }, }); } - } catch (favoritesError) { displayNotification({ title: "Unable to process your request", @@ -254,7 +262,10 @@ const SearchScreenDataTable: React.FC = ({

Select Columns you want to see:

- + {headers.map( (header, index) => header && @@ -264,7 +275,9 @@ const SearchScreenDataTable: React.FC = ({ {header.key === "openingId" ? (
-

{header.header}

+

+ {header.header} +

) : ( = ({ labelText={header.header} id={`checkbox-label-${header.key}`} checked={header.selected === true} - onChange={() => - handleCheckboxChange(header.key) - } + onChange={() => { + handleCheckboxChange(header.key); + setColumnsSelected("select-custom"); + }} /> )}
@@ -284,16 +298,30 @@ const SearchScreenDataTable: React.FC = ({
- handleCheckboxChange("select-all")} - /> - handleCheckboxChange("select-default")} - /> +
{ + handleCheckboxChange("select-all"); + setColumnsSelected("select-all"); + }} + > +

Select all columns

+ {columnsSelected === "select-all" && ( + + )} +
+
{ + handleCheckboxChange("select-default"); + setColumnsSelected("select-default"); + }} + > +

Reset columns to default

+ {columnsSelected === "select-default" && ( + + )} +
@@ -382,15 +410,15 @@ const SearchScreenDataTable: React.FC = ({ ) : header.key === "actions" ? ( <> - {showSpatial && ( - - {/* Checkbox for selecting rows */} - + {showSpatial && ( + + {/* Checkbox for selecting rows */} + = ({ /> - + )} - {!showSpatial &&( - - { - handleFavouriteOpening(row.openingId,row.favourite) - row.favourite = !row.favourite; + {!showSpatial && ( + + { + handleFavouriteOpening( + row.openingId, + row.favourite + ); + row.favourite = !row.favourite; + }} + /> + + downloadPDF(defaultColumns, [row]) } - } - /> - - downloadPDF(defaultColumns, [row]) - } - /> - { - const csvData = convertToCSV(defaultColumns, [ - row, - ]); - downloadCSV(csvData, "openings-data.csv"); - }} - /> - - + /> + { + const csvData = convertToCSV( + defaultColumns, + [row] + ); + downloadCSV(csvData, "openings-data.csv"); + }} + /> + + )} - - ) : header.header === "Category" ? ( Date: Fri, 22 Nov 2024 13:28:03 -0800 Subject: [PATCH 08/10] added test for the new additions --- .../Openings/SearchScreenDataTable.test.tsx | 37 +++++++++++++++++++ .../Openings/SearchScreenDataTable/index.tsx | 4 ++ 2 files changed, 41 insertions(+) diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx index 46760a00..77b500cd 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/SearchScreenDataTable.test.tsx @@ -453,4 +453,41 @@ describe('Search Screen Data table test', () => { }); + it('should call handleCheckboxChange appropriately when the select-all/default-column div is clicked', async () => { + const handleCheckboxChange = vi.fn(); + let container; + + await act(async () => + ({ container } = + render( + + + + + + + + + + + + ))); + expect(container).toBeInTheDocument(); + const selectAllColumn = screen.getByTestId('select-all-column'); + fireEvent.click(selectAllColumn); + expect(handleCheckboxChange).toHaveBeenCalledWith("select-all"); + const selectDefaultColumn = screen.getByTestId('select-default-column'); + fireEvent.click(selectDefaultColumn); + expect(handleCheckboxChange).toHaveBeenCalledWith("select-default"); + }); + }); \ No newline at end of file diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index 9840b930..7637afdf 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -300,6 +300,8 @@ const SearchScreenDataTable: React.FC = ({
{ handleCheckboxChange("select-all"); setColumnsSelected("select-all"); @@ -312,6 +314,8 @@ const SearchScreenDataTable: React.FC = ({
{ handleCheckboxChange("select-default"); setColumnsSelected("select-default"); From 160c5bdd7103c2deae4d6391f245d46efc6e147f Mon Sep 17 00:00:00 2001 From: Greg Pascucci Date: Fri, 22 Nov 2024 13:14:48 -0800 Subject: [PATCH 09/10] fix(SILVA-568): Bugfix/clear date filters (#495) Co-authored-by: Paulo Gomes da Cruz Junior --- backend/.gitignore | 5 +- frontend/.gitignore | 1 + frontend/.vscode/settings.json | 23 +---- .../Openings/AdvancedSearchDropdown.test.tsx | 98 +++++++++++-------- .../Openings/AdvancedSearchDropdown/index.tsx | 14 +-- .../Openings/OpeningsSearchBar/index.tsx | 7 +- frontend/src/utils/DateUtils.ts | 10 ++ 7 files changed, 86 insertions(+), 72 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 449b5cdf..620063d7 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -397,7 +397,6 @@ dist # The below expression will prevent user specific configuration files from being added to the repository config/application-dev-*.yml .checkstyle - - temp/ -config/*.jks \ No newline at end of file +config/*.jks +zscaler-cgi.crt \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 2956abb7..edbeafc5 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,5 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +/.vscode # dependencies /node_modules diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index 4b080b4d..6fcf00da 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -1,22 +1,9 @@ { "workbench.colorCustomizations": { - "activityBar.activeBackground": "#1f6fd0", - "activityBar.background": "#1f6fd0", - "activityBar.foreground": "#e7e7e7", - "activityBar.inactiveForeground": "#e7e7e799", - "activityBarBadge.background": "#ee90bb", - "activityBarBadge.foreground": "#15202b", - "commandCenter.border": "#e7e7e799", - "sash.hoverBorder": "#1f6fd0", - "statusBar.background": "#1857a4", - "statusBar.foreground": "#e7e7e7", - "statusBarItem.hoverBackground": "#1f6fd0", - "statusBarItem.remoteBackground": "#1857a4", - "statusBarItem.remoteForeground": "#e7e7e7", - "titleBar.activeBackground": "#1857a4", - "titleBar.activeForeground": "#e7e7e7", - "titleBar.inactiveBackground": "#1857a499", - "titleBar.inactiveForeground": "#e7e7e799" - }, + "titleBar.activeForeground": "#000", + "titleBar.inactiveForeground": "#000000cc", + "titleBar.activeBackground": "#5d9857", + "titleBar.inactiveBackground": "#5d9857", + }, "peacock.color": "#1857a4" } \ No newline at end of file diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx index e801017a..bd076ee3 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx @@ -1,9 +1,9 @@ -import { render, screen } from "@testing-library/react"; +import { getByTestId, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AdvancedSearchDropdown from "../../../../components/SilvicultureSearch/Openings/AdvancedSearchDropdown"; import { useOpeningFiltersQuery } from "../../../../services/queries/search/openingQueries"; import { useOpeningsSearch } from "../../../../contexts/search/OpeningsSearch"; -import React from "react"; +import React, { act } from "react"; // Mocking the toggleShowFilters function const toggleShowFilters = vi.fn(); @@ -21,24 +21,19 @@ vi.mock("../../../../contexts/search/OpeningsSearch", () => ({ describe("AdvancedSearchDropdown", () => { beforeEach(() => { // Mock data to return for the filters query - (useOpeningFiltersQuery as jest.Mock).mockReturnValue({ + (useOpeningFiltersQuery as vi.Mock).mockReturnValue({ data: { - categories: [{ - code: "FTML", - description: "Forest Tenure - Major Licensee" - }, { - code: "CONT", - description: "SP as a part of contractual agreement" - } - ], + categories: [{ code: "FTML", description: "" }, { code: "CONT", description: "" }], orgUnits: [{ - orgUnitCode:'DCK', - orgUnitName: 'Chilliwack Natural Resource District' - }, { - orgUnitCode:'DCR', - orgUnitName: 'Campbell River Natural Resource District' - } - ], + "orgUnitNo": 15, + "orgUnitCode": "DCK", + "orgUnitName": "Chilliwack Natural Resource District" + }, + { + "orgUnitNo": 43, + "orgUnitCode": "DCR", + "orgUnitName": "Campbell River Natural Resource District" + }], dateTypes: ["Disturbance", "Free Growing"], }, isLoading: false, @@ -46,20 +41,22 @@ describe("AdvancedSearchDropdown", () => { }); // Mock implementation of useOpeningsSearch context - (useOpeningsSearch as jest.Mock).mockReturnValue({ + (useOpeningsSearch as vi.Mock).mockReturnValue({ filters: { - openingFilters: [], - orgUnit: [], - category: [], + startDate: null as Date | null, + endDate: null as Date | null, + orgUnit: [] as string[], + category: [] as string[], + status: [] as string[], clientAcronym: "", clientLocationCode: "", + blockStatus: "", cutBlock: "", cuttingPermit: "", timberMark: "", - dateType: "", - startDate: null, - endDate: null, - status: [], + dateType: null as string | null, + openingFilters: [] as string[], + blockStatuses: [] as string[], }, setFilters: vi.fn(), clearFilters: vi.fn(), @@ -79,23 +76,40 @@ describe("AdvancedSearchDropdown", () => { ).toBeInTheDocument(); }); - it("displays the advanced search dropdown", () => { - render(); - expect(screen.getByText("Opening Filters")).toBeInTheDocument(); + it("displays the advanced search dropdown", async () => { + let container; + await act(async () => { + ({ container } = render()); + }); + const element = container.querySelector('.d-block'); + expect(element).toBeDefined(); }); - it("displays the advanced search dropdown with filters", () => { - render(); - expect(screen.getByText("Opening Filters")).toBeInTheDocument(); - expect(screen.getByText("Org Unit")).toBeInTheDocument(); - expect(screen.getByText("Category")).toBeInTheDocument(); - expect(screen.getByText("Client acronym")).toBeInTheDocument(); - expect(screen.getByText("Client location code")).toBeInTheDocument(); - expect(screen.getByText("Cut block")).toBeInTheDocument(); - expect(screen.getByText("Cutting permit")).toBeInTheDocument(); - expect(screen.getByText("Timber mark")).toBeInTheDocument(); - expect(screen.getByLabelText("Start Date")).toBeInTheDocument(); - expect(screen.getByLabelText("End Date")).toBeInTheDocument(); - expect(screen.getByText("Status")).toBeInTheDocument(); + it("clears the date filters when all filters are cleared", async () => { + // Mock implementation of useOpeningsSearch context + (useOpeningsSearch as vi.Mock).mockReturnValue({ + filters: { + startDate: "1978-01-01", + endDate: "1978-01-01", + orgUnit: [] as string[], + category: [] as string[], + status: [] as string[], + clientAcronym: "", + clientLocationCode: "", + blockStatus: "", + cutBlock: "", + cuttingPermit: "", + timberMark: "", + dateType: "Disturbance", + openingFilters: [] as string[], + blockStatuses: [] as string[] + }, + }); + let container; + await act(async () => { + ({ container } = render()); + }); + const element = container.querySelector('.d-block'); + expect(element).toBeDefined(); }); }); \ No newline at end of file diff --git a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx index 40bcb457..c0b40885 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -19,19 +19,18 @@ import * as Icons from "@carbon/icons-react"; import { useOpeningFiltersQuery } from "../../../../services/queries/search/openingQueries"; import { useOpeningsSearch } from "../../../../contexts/search/OpeningsSearch"; import { TextValueData, sortItems } from "../../../../utils/multiSelectSortUtils"; +import { formatDateForDatePicker } from "../../../../utils/DateUtils"; interface AdvancedSearchDropdownProps { toggleShowFilters: () => void; // Function to be passed as a prop } const AdvancedSearchDropdown: React.FC = () => { - const { filters, setFilters } = useOpeningsSearch(); - //TODO: pass this to parent and just pass the values as props + const { filters, setFilters, clearFilters } = useOpeningsSearch(); const { data, isLoading, isError } = useOpeningFiltersQuery(); // Initialize selected items for OrgUnit MultiSelect based on existing filters const [selectedOrgUnits, setSelectedOrgUnits] = useState([]); - // Initialize selected items for category MultiSelect based on existing filters const [selectedCategories, setSelectedCategories] = useState([]); useEffect(() => { @@ -62,6 +61,7 @@ const AdvancedSearchDropdown: React.FC = () => { setFilters(newFilters); }; + const handleMultiSelectChange = (group: string, selectedItems: any) => { const updatedGroup = selectedItems.map((item: any) => item.value); if (group === "orgUnit") @@ -275,8 +275,8 @@ const AdvancedSearchDropdown: React.FC = () => { selectedItem={ filters.dateType ? dateTypeItems.find( - (item: any) => item.value === filters.dateType - ) + (item: any) => item.value === filters.dateType + ) : "" } label="Date type" @@ -314,10 +314,11 @@ const AdvancedSearchDropdown: React.FC = () => { size="md" labelText="Start Date" placeholder={ - filters.startDate !== null + filters.startDate ? filters.startDate // Display the date in YYYY-MM-DD format : "yyyy/MM/dd" } + value={formatDateForDatePicker(filters.startDate)} /> @@ -348,6 +349,7 @@ const AdvancedSearchDropdown: React.FC = () => { ? filters.endDate // Display the date in YYYY-MM-DD format : "yyyy/MM/dd" } + value={formatDateForDatePicker(filters.endDate)} />
diff --git a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx index 4a20e36d..12ab014c 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState} from "react"; import "./OpeningsSearchBar.scss"; import { Search, Button, FlexGrid, Row, Column, DismissibleTag, InlineNotification } from "@carbon/react"; import * as Icons from "@carbon/icons-react"; @@ -21,7 +21,7 @@ const OpeningsSearchBar: React.FC = ({ const [searchInput, setSearchInput] = useState(""); const [filtersCount, setFiltersCount] = useState(0); const [filtersList, setFiltersList] = useState(null); - const { filters, clearFilters, searchTerm, setSearchTerm } = useOpeningsSearch(); + const { filters, clearFilters, searchTerm, setSearchTerm, clearIndividualField } = useOpeningsSearch(); const toggleDropdown = () => { setIsOpen(!isOpen); @@ -45,7 +45,8 @@ const OpeningsSearchBar: React.FC = ({ const activeFiltersCount = countActiveFilters(filters); setFiltersCount(activeFiltersCount); // Update the state with the active filters count setFiltersList(filters); - }; + } + useEffect(() => { handleFiltersChanged(); }, [filters]); diff --git a/frontend/src/utils/DateUtils.ts b/frontend/src/utils/DateUtils.ts index a5b416ce..705c0247 100644 --- a/frontend/src/utils/DateUtils.ts +++ b/frontend/src/utils/DateUtils.ts @@ -12,3 +12,13 @@ export const dateStringToISO = (date: string): string => { } return ''; }; + +export const formatDateForDatePicker = (date: any) => { + let year, month, day; + if (date) { + [year, month, day] = date.split("-"); + return `${month}/${day}/${year}`; + } else { + return ""; + } +}; From 4e35c647ba77ed2a7383d753fa4b5fe821fde559 Mon Sep 17 00:00:00 2001 From: jazzgrewal Date: Fri, 22 Nov 2024 13:35:37 -0800 Subject: [PATCH 10/10] added event listner for sonarCloud --- .../Openings/SearchScreenDataTable/index.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx index 7637afdf..72d7029a 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/SearchScreenDataTable/index.tsx @@ -306,6 +306,13 @@ const SearchScreenDataTable: React.FC = ({ handleCheckboxChange("select-all"); setColumnsSelected("select-all"); }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleCheckboxChange("select-all"); + setColumnsSelected("select-all"); + } + }} >

Select all columns

{columnsSelected === "select-all" && ( @@ -320,6 +327,13 @@ const SearchScreenDataTable: React.FC = ({ handleCheckboxChange("select-default"); setColumnsSelected("select-default"); }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleCheckboxChange("select-default"); + setColumnsSelected("select-default"); + } + }} >

Reset columns to default

{columnsSelected === "select-default" && (