From c28747a83cfcb80bfc0ee912dda43cc202c22e39 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Fri, 22 Nov 2024 13:54:44 -0800 Subject: [PATCH 01/20] feat(SILVA-539): added search by acronym, name or number on FC api --- .../common/endpoint/ForestClientEndpoint.java | 11 +++++++++++ .../provider/ForestClientApiProvider.java | 17 +++++++++++++++++ .../common/service/ForestClientService.java | 6 ++++++ 3 files changed, 34 insertions(+) diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java index f0c7a27a..fb7c89ac 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java @@ -3,10 +3,12 @@ import ca.bc.gov.restapi.results.common.dto.ForestClientDto; import ca.bc.gov.restapi.results.common.exception.ForestClientNotFoundException; import ca.bc.gov.restapi.results.common.service.ForestClientService; +import java.util.List; import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** @@ -32,4 +34,13 @@ public ForestClientDto getForestClient(@PathVariable String clientNumber) { .getClientByNumber(clientNumber) .orElseThrow(ForestClientNotFoundException::new); } + + + @GetMapping("/search") + public List searchByNameAcronymNumber( + @RequestParam(name = "value") + String value + ) { + return forestClientService.searchByNameAcronymNumber(value); + } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java index 4b2bfa9d..e5b3c61a 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java @@ -1,9 +1,11 @@ package ca.bc.gov.restapi.results.common.provider; import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import java.util.List; import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; @@ -50,4 +52,19 @@ public Optional fetchClientByNumber(String number) { return Optional.empty(); } + + public List searchByNameAcronymNumber(String value) { + + try{ + return + restClient + .get() + .uri("/clients/search/by?name={value}&acronym={value}&number={value}", value,value,value) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } catch (HttpClientErrorException httpExc) { + log.error("Finished {} request - Response code error: {}", PROVIDER, httpExc.getStatusCode()); + return List.of(); + } + } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java index c0f67bf2..86c8cfc6 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java @@ -2,6 +2,7 @@ import ca.bc.gov.restapi.results.common.dto.ForestClientDto; import ca.bc.gov.restapi.results.common.provider.ForestClientApiProvider; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -46,4 +47,9 @@ private String checkClientNumber(String clientNumber) { return "00000000"; } } + + public List searchByNameAcronymNumber(String value) { + log.info("Received search value {}", value); + return forestClientApiProvider.searchByNameAcronymNumber(value); + } } From bb714460383d6ab0f2afe769b5a355aa8205d4f9 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 25 Nov 2024 13:16:51 -0800 Subject: [PATCH 02/20] feat(SILVA-539): added backend code to do client search --- .../ForestClientAutocompleteResultDto.java | 12 ++ .../results/common/dto/ForestClientDto.java | 16 +++ .../common/dto/ForestClientLocationDto.java | 31 +++++ .../restapi/results/common/dto/IdNameDto.java | 11 ++ .../common/endpoint/ForestClientEndpoint.java | 19 ++- .../results/common/enums/YesNoEnum.java | 30 +++++ .../provider/ForestClientApiProvider.java | 48 ++++++- .../common/service/ForestClientService.java | 43 ++++++- .../entity/UserRecentOpeningEntity.java | 2 + .../ForestClientEndpointIntegrationTest.java | 120 ++++++++++++++++-- ...orestClientApiProviderIntegrationTest.java | 89 +++++++++++++ .../service/ForestClientServiceTest.java | 117 +++++++++++++++++ 12 files changed, 512 insertions(+), 26 deletions(-) create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientAutocompleteResultDto.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientLocationDto.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/dto/IdNameDto.java create mode 100644 backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientAutocompleteResultDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientAutocompleteResultDto.java new file mode 100644 index 00000000..e1449799 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientAutocompleteResultDto.java @@ -0,0 +1,12 @@ +package ca.bc.gov.restapi.results.common.dto; + +import lombok.With; + +@With +public record ForestClientAutocompleteResultDto( + String id, + String name, + String acronym +) { + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientDto.java index 74531628..10dcd8ee 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientDto.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientDto.java @@ -2,8 +2,12 @@ import ca.bc.gov.restapi.results.common.enums.ForestClientStatusEnum; import ca.bc.gov.restapi.results.common.enums.ForestClientTypeEnum; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.Builder; import lombok.With; +import org.springframework.data.annotation.Transient; /** * This record represents a Forest Client object. @@ -20,4 +24,16 @@ public record ForestClientDto( String acronym ) { + @Transient + public String name() { + if (Objects.equals(this.clientTypeCode, "I")) { + return Stream.of(this.legalFirstName, this.legalMiddleName, this.clientName) + .filter(Objects::nonNull) + .map(String::trim) + .collect(Collectors.joining(" ")); + } else { + return this.clientName; + } + } + } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientLocationDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientLocationDto.java new file mode 100644 index 00000000..7bbac1d2 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/ForestClientLocationDto.java @@ -0,0 +1,31 @@ +package ca.bc.gov.restapi.results.common.dto; + +import ca.bc.gov.restapi.results.common.enums.YesNoEnum; +import java.time.LocalDate; +import lombok.With; + +@With +public record ForestClientLocationDto( + String clientNumber, + String locationCode, + String locationName, + String companyCode, + String address1, + String address2, + String address3, + String city, + String province, + String postalCode, + String country, + String businessPhone, + String homePhone, + String cellPhone, + String faxNumber, + String email, + YesNoEnum expired, + YesNoEnum trusted, + LocalDate returnedMailDate, + String comment +) { + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/IdNameDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/IdNameDto.java new file mode 100644 index 00000000..c87158cb --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/IdNameDto.java @@ -0,0 +1,11 @@ +package ca.bc.gov.restapi.results.common.dto; + +import lombok.With; + +@With +public record IdNameDto( + String id, + String name +) { + +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java index fb7c89ac..9e203810 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpoint.java @@ -1,6 +1,8 @@ package ca.bc.gov.restapi.results.common.endpoint; +import ca.bc.gov.restapi.results.common.dto.ForestClientAutocompleteResultDto; import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import ca.bc.gov.restapi.results.common.dto.IdNameDto; import ca.bc.gov.restapi.results.common.exception.ForestClientNotFoundException; import ca.bc.gov.restapi.results.common.service.ForestClientService; import java.util.List; @@ -35,12 +37,17 @@ public ForestClientDto getForestClient(@PathVariable String clientNumber) { .orElseThrow(ForestClientNotFoundException::new); } - - @GetMapping("/search") - public List searchByNameAcronymNumber( - @RequestParam(name = "value") - String value + @GetMapping("/byNameAcronymNumber") + public List searchForestClients( + @RequestParam(value = "page",required = false,defaultValue = "0") Integer page, + @RequestParam(value = "size",required = false,defaultValue = "10") Integer size, + @RequestParam(value = "value") String value ) { - return forestClientService.searchByNameAcronymNumber(value); + return forestClientService.searchClients(page,size,value); + } + + @GetMapping("/{clientNumber}/locations") + public List getForestClientLocations(@PathVariable String clientNumber) { + return forestClientService.getClientLocations(clientNumber); } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java new file mode 100644 index 00000000..9914a251 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java @@ -0,0 +1,30 @@ +package ca.bc.gov.restapi.results.common.enums; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +public enum YesNoEnum { + YES("Y"), + NO("N"); + + private final String value; + + YesNoEnum(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return this.value; + } + + @JsonCreator + public static YesNoEnum fromValue(String value) { + for (YesNoEnum c : values()) { + if (c.value().equalsIgnoreCase(value)) { + return c; + } + } + throw new IllegalArgumentException(value); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java index e5b3c61a..154e45d9 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java @@ -1,6 +1,7 @@ package ca.bc.gov.restapi.results.common.provider; import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import ca.bc.gov.restapi.results.common.dto.ForestClientLocationDto; import java.util.List; import java.util.Optional; import lombok.extern.slf4j.Slf4j; @@ -8,6 +9,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.stereotype.Component; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestClient; /** @@ -46,25 +48,57 @@ public Optional fetchClientByNumber(String number) { .retrieve() .body(ForestClientDto.class) ); - } catch (HttpClientErrorException httpExc) { + } catch (HttpClientErrorException | HttpServerErrorException httpExc) { log.error("Finished {} request - Response code error: {}", PROVIDER, httpExc.getStatusCode()); } return Optional.empty(); } - public List searchByNameAcronymNumber(String value) { + public List searchClients( + int page, + int size, + String value + ) { + log.info("Starting {} request to /clients/search/by?name={}&acronym={}&number={}", PROVIDER,value,value,value); - try{ + try { + return + restClient + .get() + .uri(uriBuilder -> + uriBuilder + .path("/clients/search/by") + .queryParam("page", page) + .queryParam("size", size) + .queryParam("name", value) + .queryParam("acronym", value) + .queryParam("number", value) + .build() + ) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } catch (HttpClientErrorException | HttpServerErrorException httpExc) { + log.error("{} requested on search by - Response code error: {}", PROVIDER, httpExc.getStatusCode()); + } + + return List.of(); + } + + public List fetchLocationsByClientNumber(String clientNumber) { + log.info("Starting {} request to /clients/{}/locations", PROVIDER, clientNumber); + + try { return restClient .get() - .uri("/clients/search/by?name={value}&acronym={value}&number={value}", value,value,value) + .uri("/clients/{clientNumber}/locations", clientNumber) .retrieve() .body(new ParameterizedTypeReference<>() {}); - } catch (HttpClientErrorException httpExc) { - log.error("Finished {} request - Response code error: {}", PROVIDER, httpExc.getStatusCode()); - return List.of(); + } catch (HttpClientErrorException | HttpServerErrorException httpExc) { + log.error("Client location {} request - Response code error: {}", PROVIDER, httpExc.getStatusCode()); } + + return List.of(); } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java index 86c8cfc6..c74a2aef 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ForestClientService.java @@ -1,8 +1,11 @@ package ca.bc.gov.restapi.results.common.service; +import ca.bc.gov.restapi.results.common.dto.ForestClientAutocompleteResultDto; import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import ca.bc.gov.restapi.results.common.dto.IdNameDto; import ca.bc.gov.restapi.results.common.provider.ForestClientApiProvider; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +38,42 @@ public Optional getClientByNumber(String clientNumber) { return forestClientApiProvider.fetchClientByNumber(fixedNumber); } + public List searchClients( + int page, + int size, + String value + ) { + log.info("Searching forest client by {} as name, acronym or number with page {} and size {}", + value, page, size); + return forestClientApiProvider + .searchClients(page, size, value) + .stream() + .map(client -> new ForestClientAutocompleteResultDto( + client.clientNumber(), + client.name(), + client.acronym() + ) + ) + .toList(); + } + + public List getClientLocations(String clientNumber) { + String fixedNumber = checkClientNumber(clientNumber); + + log.info("Fetching locations for client number {}", fixedNumber); + + return + forestClientApiProvider + .fetchLocationsByClientNumber(fixedNumber) + .stream() + .map(location -> new IdNameDto( + location.locationCode(), + Objects.toString(location.locationName(), "No name provided") + )) + .toList(); + } + + private String checkClientNumber(String clientNumber) { if (StringUtils.isEmpty(clientNumber)) { return "00000000"; @@ -48,8 +87,4 @@ private String checkClientNumber(String clientNumber) { } } - public List searchByNameAcronymNumber(String value) { - log.info("Received search value {}", value); - return forestClientApiProvider.searchByNameAcronymNumber(value); - } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java index cc335c09..b07b491b 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/entity/UserRecentOpeningEntity.java @@ -10,6 +10,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.With; @@ -19,6 +20,7 @@ @With @Builder @Entity +@EqualsAndHashCode(exclude = {"id","lastViewed"}) @Table(schema = "silva", name = "user_recent_openings") public class UserRecentOpeningEntity { diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpointIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpointIntegrationTest.java index e0dc428b..4dece620 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpointIntegrationTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/ForestClientEndpointIntegrationTest.java @@ -1,31 +1,27 @@ package ca.bc.gov.restapi.results.common.endpoint; -import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.serviceUnavailable; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -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.dto.ForestClientDto; -import ca.bc.gov.restapi.results.common.enums.ForestClientStatusEnum; -import ca.bc.gov.restapi.results.common.enums.ForestClientTypeEnum; -import ca.bc.gov.restapi.results.common.service.ForestClientService; import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; import ca.bc.gov.restapi.results.extensions.WiremockLogNotifier; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -105,4 +101,110 @@ void getForestClient_notFound_shouldSucceed() throws Exception { .andExpect(status().isNotFound()) .andReturn(); } + + + @ParameterizedTest + @DisplayName("Search clients by name, acronym, or number succeeded") + @MethodSource("searchByNameAcronymNumberOk") + void fetchClientByName_happyPath_shouldSucceed( + String value + ) throws Exception { + + clientApiStub.stubFor( + WireMock.get(urlPathEqualTo("/clients/search/by")) + .willReturn(okJson(""" + [ + { + "clientNumber": "00012797", + "clientName": "MINISTRY OF FORESTS", + "legalFirstName": null, + "legalMiddleName": null, + "clientStatusCode": "ACT", + "clientTypeCode": "F", + "acronym": "MOF" + } + ] + """) + ) + ); + + mockMvc + .perform( + get("/api/forest-clients/byNameAcronymNumber?value={value}", value) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$[0].id").value("00012797")) + .andExpect(jsonPath("$[0].name").value("MINISTRY OF FORESTS")) + .andExpect(jsonPath("$[0].acronym").value("MOF")) + .andReturn(); + + } + + @Test + @DisplayName("Search clients by name, acronym, or number not available should succeed") + void fetchClientByName_unavailable_shouldSucceed() throws Exception { + + clientApiStub.stubFor( + WireMock.get(urlPathEqualTo("/clients/search/by")) + .willReturn(serviceUnavailable()) + ); + + mockMvc + .perform( + get("/api/forest-clients/byNameAcronymNumber?value={value}", "COMPANY") + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andReturn(); + + } + + @Test + @DisplayName("Fetch client locations happy path should succeed") + void fetchClientLocations_happyPath_shouldSucceed() throws Exception { + + clientApiStub.stubFor( + WireMock.get(urlPathEqualTo("/clients/00012797/locations")) + .willReturn(okJson(""" + [ + { + "locationCode": "00", + "locationName": "Location 1" + }, + { + "locationCode": "01", + "locationName": "Location 2" + } + ] + """) + ) + ); + + mockMvc + .perform( + get("/api/forest-clients/{clientNumber}/locations", "12797") + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType("application/json")) + .andExpect(jsonPath("$[0].id").value("00")) + .andExpect(jsonPath("$[0].name").value("Location 1")) + .andExpect(jsonPath("$[1].id").value("01")) + .andExpect(jsonPath("$[1].name").value("Location 2")) + .andReturn(); + + } + + + private static Stream searchByNameAcronymNumberOk() { + return Stream.of( + Arguments.of( "INDIA"), + Arguments.of( "SAMPLIBC"), + Arguments.of( "00000001"), + Arguments.of( "1") + ); + } } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProviderIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProviderIntegrationTest.java index 0ea0bee2..bf6ea8c1 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProviderIntegrationTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProviderIntegrationTest.java @@ -3,6 +3,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.notFound; import static com.github.tomakehurst.wiremock.client.WireMock.okJson; +import static com.github.tomakehurst.wiremock.client.WireMock.serviceUnavailable; import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; @@ -12,10 +13,14 @@ import ca.bc.gov.restapi.results.extensions.WiremockLogNotifier; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.web.client.RestClient; @DisplayName("Integrated Test | Forest Client API Provider") @@ -87,4 +92,88 @@ void fetchClientByNumber_clientNotFound_shouldSucceed() { Assertions.assertTrue(clientDto.isEmpty()); } + + @ParameterizedTest + @DisplayName("Search clients by name, acronym, or number succeeded") + @MethodSource("searchByNameAcronymNumberOk") + void fetchClientByName_happyPath_shouldSucceed( + int resultSize, + String value + ){ + + clientApiStub.stubFor( + get(urlPathEqualTo("/clients/search/by")) + .willReturn(okJson(""" + [ + { + "clientNumber": "00012797", + "clientName": "MINISTRY OF FORESTS", + "legalFirstName": null, + "legalMiddleName": null, + "clientStatusCode": "ACT", + "clientTypeCode": "F", + "acronym": "MOF" + } + ] + """) + ) + ); + + var clients = forestClientApiProvider.searchClients(1, 10, value); + + Assertions.assertEquals(resultSize, clients.size()); + + } + + @Test + @DisplayName("Search clients by name, acronym, or number not available should succeed") + void fetchClientByName_unavailable_shouldSucceed(){ + + clientApiStub.stubFor( + get(urlPathEqualTo("/clients/search/by")) + .willReturn(serviceUnavailable()) + ); + + var clients = forestClientApiProvider.searchClients(1, 10, "COMPANY"); + + Assertions.assertEquals(0, clients.size()); + + } + + @Test + @DisplayName("Fetch client locations happy path should succeed") + void fetchClientLocations_happyPath_shouldSucceed(){ + + clientApiStub.stubFor( + get(urlPathEqualTo("/clients/00012797/locations")) + .willReturn(okJson(""" + [ + { + "locationCode": "00", + "locationName": "Location 1" + }, + { + "locationCode": "01", + "locationName": "Location 2" + } + ] + """) + ) + ); + + var locations = forestClientApiProvider.fetchLocationsByClientNumber("00012797"); + + Assertions.assertEquals(2, locations.size()); + + } + + + private static Stream searchByNameAcronymNumberOk() { + return Stream.of( + Arguments.of(1, "INDIA"), + Arguments.of(1, "SAMPLIBC"), + Arguments.of(1, "00000001"), + Arguments.of(1, "1") + ); + } } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/service/ForestClientServiceTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/service/ForestClientServiceTest.java index 801a6a31..6016dfa3 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/common/service/ForestClientServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/service/ForestClientServiceTest.java @@ -3,16 +3,21 @@ import static org.mockito.Mockito.when; import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import ca.bc.gov.restapi.results.common.dto.ForestClientLocationDto; import ca.bc.gov.restapi.results.common.enums.ForestClientStatusEnum; import ca.bc.gov.restapi.results.common.enums.ForestClientTypeEnum; import ca.bc.gov.restapi.results.common.provider.ForestClientApiProvider; +import java.util.List; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.NullAndEmptySource; import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; @@ -126,4 +131,116 @@ void shouldGetEmptyWhenNoClientNumber(String clientNumber) { Assertions.assertTrue(clientDtoOptional.isEmpty()); } + + @ParameterizedTest + @DisplayName("Search clients by name, acronym, or number succeeded") + @MethodSource("searchByNameAcronymNumberOk") + void fetchClientByName_happyPath_shouldSucceed( + int resultSize, + String value + ){ + + when(forestClientApiProvider.searchClients(1, 10, value)) + .thenReturn( + List.of( + new ForestClientDto( + "00012797", + "MINISTRY OF FORESTS", + null, + null, + ForestClientStatusEnum.ACTIVE, + ForestClientTypeEnum.MINISTRY_OF_FORESTS_AND_RANGE, + "MOF" + ) + ) + ); + + var clients = forestClientService.searchClients(1, 10, value); + + Assertions.assertEquals(resultSize, clients.size()); + + } + + @Test + @DisplayName("Search clients by name, acronym, or number not available should succeed") + void fetchClientByName_unavailable_shouldSucceed(){ + + when(forestClientApiProvider.searchClients(1, 10, "COMPANY")) + .thenReturn(List.of()); + + var clients = forestClientService.searchClients(1, 10, "COMPANY"); + + Assertions.assertEquals(0, clients.size()); + + } + + @Test + @DisplayName("Fetch client locations happy path should succeed") + void fetchClientLocations_happyPath_shouldSucceed(){ + + when(forestClientApiProvider.fetchLocationsByClientNumber("00012797")) + .thenReturn( + List.of( + new ForestClientLocationDto( + null, + "00", + "Location 1", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ), + new ForestClientLocationDto( + null, + "01", + "Location 2", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ) + ) + ); + + var locations = forestClientService.getClientLocations("00012797"); + + Assertions.assertEquals(2, locations.size()); + + } + + private static Stream searchByNameAcronymNumberOk() { + return Stream.of( + Arguments.of(1, "INDIA"), + Arguments.of(1, "SAMPLIBC"), + Arguments.of(1, "00000001"), + Arguments.of(1, "1") + ); + } + } From 0e4956ecc7cdc0a8e46e74ceaebf69cb705d84b4 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 25 Nov 2024 13:11:06 -0800 Subject: [PATCH 03/20] feat(SILVA-539): added frontend code to do client search --- frontend/package-lock.json | 1 + frontend/package.json | 1 + .../AutocompleteClientLocation/index.tsx | 108 ++++++++++++++++++ .../Openings/AdvancedSearchDropdown/index.tsx | 48 ++------ .../src/contexts/AutocompleteProvider.tsx | 84 ++++++++++++++ .../services/OpeningClientLocationService.ts | 26 +++++ frontend/src/services/apiConfig.ts | 28 +++++ 7 files changed, 257 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/AutocompleteClientLocation/index.tsx create mode 100644 frontend/src/contexts/AutocompleteProvider.tsx create mode 100644 frontend/src/services/OpeningClientLocationService.ts create mode 100644 frontend/src/services/apiConfig.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f24d800b..45cf77d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.3", "leaflet": "^1.9.4", + "lodash": "^4.17.21", "lottie-react": "^2.4.0", "qs": "^6.13.0", "react": "^18.2.0", diff --git a/frontend/package.json b/frontend/package.json index f1023b8c..1e58fbe9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.3", "leaflet": "^1.9.4", + "lodash": "^4.17.21", "lottie-react": "^2.4.0", "qs": "^6.13.0", "react": "^18.2.0", diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx new file mode 100644 index 00000000..a1d55a3b --- /dev/null +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -0,0 +1,108 @@ +import React, { useState, useEffect } from "react"; +import { ComboBox, InlineLoading } from "@carbon/react"; +import { useAutocomplete } from "../../contexts/AutocompleteProvider"; +import { fetchClientsByNameAcronymNumber, fetchClientLocations, ForestClientAutocomplete, ForestClientLocation } from "../../services/OpeningClientLocationService"; + + +interface AutocompleteProps { + id: string, + label: string, +} + +interface AutocompleteComboboxProps{ + selectedItem: AutocompleteProps +} + +interface AutocompleteComponentProps { + setValue: (value: string | null) => void; +} + +// Defines when the fetch should be skipped for a specific key +export const skipConditions = { + // Skip when the query value matches the selected text by the user on the dropdown + clients: (query: string) => query.match(/^[a-zA-Z\s]*,\s[a-zA-Z\s]*,*/) ? true : false, + // Never skips for locations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locations: (query: string) => false +}; + +// Fetch options for the Autocomplete component +export const fetchValues = async (query: string, key: string) => { + + // If there is no query, return an empty array + if(!key || !query) return []; + + // For clients, it will do the autocomplete search based on the name, acronym, or number + if (key === "clients") { + const response = await fetchClientsByNameAcronymNumber(query); + const apiresponse = response; + return apiresponse.map((item: ForestClientAutocomplete) => ({ + id: item.id, + label: `${item.name}, ${item.id}, ${item.acronym? item.acronym : ''}` + })); + } + + // For locations, it will just load the value based on the selected client id + if (key === "locations") { + const response = await fetchClientLocations(query); + const apiresponse = response; + return apiresponse.map((item: ForestClientLocation) => ({ + id: item.id, + label: `${item.id} - ${item.name}` + })); + } + + return []; +}; + +const AutocompleteClientLocation: React.FC = ({ setValue }) => { + const { options, fetchOptions, setOptions } = useAutocomplete(); + const [isActive, setIsActive] = useState(false); + const [location, setLocation] = useState(null); + + const handleClientChange = async (e: AutocompleteComboboxProps) => { + + const selectedItem = e.selectedItem; + if (selectedItem) { + setIsActive(true); + await fetchOptions(selectedItem.id, "locations"); + }else{ + setOptions("locations", []); + setLocation(null); + setIsActive(false); + } + }; + + const handleLocationSelection = (e: AutocompleteComboboxProps) => { + setValue(e?.selectedItem?.id as string || null) + setLocation(e?.selectedItem || null); + }; + + return ( +
+ fetchOptions(e, "clients")} + onChange={handleClientChange} + helperText="Search by client name, number or acronym" + items={options["clients"] || []} + titleText="Client" + typeahead /> + + +
+ ); +} + +export default AutocompleteClientLocation; \ 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..45dc923c 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -4,8 +4,6 @@ import { CheckboxGroup, Dropdown, TextInput, - FormLabel, - Tooltip, DatePicker, DatePickerInput, Loading, @@ -19,6 +17,9 @@ 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"; +import { AutocompleteProvider } from "../../../../contexts/AutocompleteProvider"; +import AutocompleteClientLocation, { skipConditions, fetchValues} from "../../../AutocompleteClientLocation"; interface AdvancedSearchDropdownProps { toggleShowFilters: () => void; // Function to be passed as a prop @@ -26,7 +27,6 @@ interface AdvancedSearchDropdownProps { const AdvancedSearchDropdown: React.FC = () => { const { filters, setFilters } = useOpeningsSearch(); - //TODO: pass this to parent and just pass the values as props const { data, isLoading, isError } = useOpeningFiltersQuery(); // Initialize selected items for OrgUnit MultiSelect based on existing filters @@ -182,41 +182,9 @@ const AdvancedSearchDropdown: React.FC = () => { -
-
- Client acronym - - - - - handleFilterChange({ clientAcronym: e.target.value }) - } - /> -
- <> - - handleFilterChange({ clientLocationCode: e.target.value }) - } - /> - -
+ + handleFilterChange({ clientLocationCode: value })} /> +
@@ -314,10 +282,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 +317,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/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx new file mode 100644 index 00000000..5d9bc308 --- /dev/null +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -0,0 +1,84 @@ +import { createContext, useContext, ReactNode, useState, useRef, useEffect } from "react"; +import debounce from "lodash.debounce"; + +// TODO: test this + +interface AutocompleteProviderProps { + fetchOptions: (query: string, key: string) => Promise; + skipConditions?: Record boolean>; + children: ReactNode; +} + +interface AutocompleteContextType { + options: Record; + loading: boolean; + error: string | null; + fetchOptions: (query: string, key: string) => void; + setOptions: (key: string, items: any[]) => void; +} + +const AutocompleteContext = createContext(undefined); + +export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: AutocompleteProviderProps) => { + const [options, setOptionsState] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const debouncedFetchMap = useRef void>>(new Map()); + + const setOptions = (key: string, items: any[]) => { + setOptionsState((prev) => ({ ...prev, [key]: items })); + }; + + const createDebouncedFetch = (key: string) => { + return debounce(async (query: string) => { + try { + // If no key or query, return + if(!key || !query) return; + // If skipConditions are present and the condition is met, return, this avoids overwriting the existing + // data when a condition to skip is met + if (skipConditions && skipConditions[key] && skipConditions[key](query)) { + return; + } + const fetchedOptions = await fetchOptions(query, key); + setOptions(key, fetchedOptions); + } catch (fetchError) { + setError("Error fetching options"); + } finally { + console.log('Disabling loading'); + setLoading(false); + } + }, 450); + }; + + const fetchAndSetOptions = (query: string, key: string) => { + setLoading(true); + if (!debouncedFetchMap.current.has(key)) { + debouncedFetchMap.current.set(key, createDebouncedFetch(key)); + } + const debouncedFetch = debouncedFetchMap.current.get(key); + debouncedFetch!(query); // Call the specific debounced function for this key + }; + + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + debouncedFetchMap.current.forEach((debounced) => debounced.cancel()); + debouncedFetchMap.current.clear(); + }; + }, []); + + return ( + + {children} + + ); +}; + +export const useAutocomplete = (): AutocompleteContextType => { + const context = useContext(AutocompleteContext); + if (!context) { + throw new Error("useAutocomplete must be used within an AutocompleteProvider"); + } + return context; +}; diff --git a/frontend/src/services/OpeningClientLocationService.ts b/frontend/src/services/OpeningClientLocationService.ts new file mode 100644 index 00000000..37c11f86 --- /dev/null +++ b/frontend/src/services/OpeningClientLocationService.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; +import { getAuthIdToken } from './AuthService'; +import { API_ENDPOINTS, defaultHeaders } from './apiConfig'; + +// TODO: move interfaces to types maybe? +// TODO: test this +export interface ForestClientAutocomplete { + id: string; + name: string; + acronym: string; +} + +export interface ForestClientLocation { + id: string, + name: string, +} + +export const fetchClientsByNameAcronymNumber = async (query: string): Promise => { + const response = await axios.get(API_ENDPOINTS.clientsByNameAcronymNumber(query), defaultHeaders(getAuthIdToken())); + return response.data; +} + +export const fetchClientLocations = async (clientId: string): Promise => { + const response = await axios.get(API_ENDPOINTS.clientLocations(clientId), defaultHeaders(getAuthIdToken())); + return response.data; +} \ No newline at end of file diff --git a/frontend/src/services/apiConfig.ts b/frontend/src/services/apiConfig.ts new file mode 100644 index 00000000..ec9c22b5 --- /dev/null +++ b/frontend/src/services/apiConfig.ts @@ -0,0 +1,28 @@ +// Centralized API configuration file +import { env } from '../env'; + +// Define the API base URL from the environment variables +const API_BASE_URL = env.VITE_BACKEND_URL; + +// Define the API endpoints, making it easier to refactor in the future when needed +const API_ENDPOINTS = { + openingFavourites: () => `${API_BASE_URL}/api/openings/favourites`, + openingFavouriteWithId: (openingId: number) => `${API_BASE_URL}/api/openings/favourites/${openingId}`, + openingSearch: (filters: string) => `${API_BASE_URL}/api/opening-search${filters}`, + recentOpenings: () => `${API_BASE_URL}/api/openings/recent`, + categories: () => `${API_BASE_URL}/api/opening-search/categories`, + orgUnits: () => `${API_BASE_URL}/api/opening-search/org-units`, + clientsByNameAcronymNumber: (query: string) => `${API_BASE_URL}/api/forest-clients/byNameAcronymNumber?value=${query}`, + clientLocations: (clientId: string) => `${API_BASE_URL}/api/forest-clients/${clientId}/locations` +}; + +// Define the default headers for the API requests, including ones used by CORS +const defaultHeaders = (authToken: string|null) => ({ + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': window.location.origin, + Authorization: `Bearer ${authToken}` + } +}); + +export { API_BASE_URL, API_ENDPOINTS, defaultHeaders }; \ No newline at end of file From 0849cc52074637175df938d4607a3a46a1fa4999 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 25 Nov 2024 13:54:38 -0800 Subject: [PATCH 04/20] fix(SILVA-539): fixing lodash issues --- frontend/package-lock.json | 18 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/contexts/AutocompleteProvider.tsx | 4 ++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 45cf77d6..27fb5088 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,6 +46,7 @@ "@types/jest": "^29.5.12", "@types/jspdf": "^2.0.0", "@types/leaflet": "^1.9.12", + "@types/lodash.debounce": "^4.0.9", "@types/qs": "^6.9.16", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -5222,6 +5223,23 @@ "@types/geojson": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e58fbe9..ac94fe6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,6 +63,7 @@ "@types/jest": "^29.5.12", "@types/jspdf": "^2.0.0", "@types/leaflet": "^1.9.12", + "@types/lodash.debounce": "^4.0.9", "@types/qs": "^6.9.16", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index 5d9bc308..b6364cc2 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, ReactNode, useState, useRef, useEffect } from "react"; -import debounce from "lodash.debounce"; +import { debounce, DebouncedFunc } from "lodash"; // TODO: test this @@ -23,7 +23,7 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: const [options, setOptionsState] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const debouncedFetchMap = useRef void>>(new Map()); + const debouncedFetchMap = useRef Promise>>>(new Map()); const setOptions = (key: string, items: any[]) => { setOptionsState((prev) => ({ ...prev, [key]: items })); From b29c6c2905c7c0e3e90dfacd351133be1069f696 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 07:58:18 -0800 Subject: [PATCH 05/20] chore(SILVA-539): increasing the size of locations page This is to account for locations with more than 10 entries. Limit is usually 50 per client but we never know --- .../results/common/provider/ForestClientApiProvider.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java index 154e45d9..81a9d54d 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/provider/ForestClientApiProvider.java @@ -92,7 +92,13 @@ public List fetchLocationsByClientNumber(String clientN return restClient .get() - .uri("/clients/{clientNumber}/locations", clientNumber) + .uri(uriBuilder -> + uriBuilder + .path("/clients/{clientNumber}/locations") + .queryParam("page",0) + .queryParam("size",100) + .build(clientNumber) + ) .retrieve() .body(new ParameterizedTypeReference<>() {}); } catch (HttpClientErrorException | HttpServerErrorException httpExc) { From feef21f05ce5d0023938c45ab0ed5d865511907d Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 07:59:03 -0800 Subject: [PATCH 06/20] feat(SILVA-539): adding search with location code as param --- .../bc/gov/restapi/results/oracle/SilvaOracleConstants.java | 2 +- .../restapi/results/oracle/dto/OpeningSearchFiltersDto.java | 5 +++++ .../results/oracle/endpoint/OpeningSearchEndpoint.java | 3 +++ .../results/oracle/repository/OpeningRepository.java | 6 ++++++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java index dc6abd60..4b5c01c9 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleConstants.java @@ -9,7 +9,6 @@ public class SilvaOracleConstants { public static final String ORG_UNIT = "orgUnit"; public static final String CATEGORY = "category"; public static final String STATUS_LIST = "statusList"; - public static final String OPENING_IDS = "openingIds"; public static final String MY_OPENINGS = "myOpenings"; public static final String SUBMITTED_TO_FRPA = "submittedToFrpa"; public static final String DISTURBANCE_DATE_START = "disturbanceDateStart"; @@ -24,4 +23,5 @@ public class SilvaOracleConstants { public static final String CUT_BLOCK_ID = "cutBlockId"; public static final String TIMBER_MARK = "timberMark"; public static final String MAIN_SEARCH_TERM = "mainSearchTerm"; + public static final String LOCATION_CODE = "clientLocationCode"; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java index e3d20618..da25502c 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/dto/OpeningSearchFiltersDto.java @@ -32,6 +32,7 @@ public class OpeningSearchFiltersDto { private final String timberMark; // Main input, it can be one of Opening ID, Opening Number, Timber Mark ID, or File ID private final String mainSearchTerm; + private final String clientLocationCode; @Setter private String requestUserId; @@ -54,6 +55,7 @@ public OpeningSearchFiltersDto( String cuttingPermitId, String cutBlockId, String timberMark, + String clientLocationCode, String mainSearchTerm) { this.orgUnit = !Objects.isNull(orgUnit) ? orgUnit : null; this.category = !Objects.isNull(category) ? category : null; @@ -84,6 +86,8 @@ public OpeningSearchFiltersDto( this.timberMark = Objects.isNull(timberMark) ? null : timberMark.toUpperCase().trim(); this.mainSearchTerm = Objects.isNull(mainSearchTerm) ? null : mainSearchTerm.toUpperCase().trim(); + this.clientLocationCode = + Objects.isNull(clientLocationCode) ? null : clientLocationCode.trim(); } /** @@ -113,6 +117,7 @@ public boolean hasValue(String prop) { case SilvaOracleConstants.CUT_BLOCK_ID -> !Objects.isNull(this.cutBlockId); case SilvaOracleConstants.TIMBER_MARK -> !Objects.isNull(this.timberMark); case SilvaOracleConstants.MAIN_SEARCH_TERM -> !Objects.isNull(this.mainSearchTerm); + case SilvaOracleConstants.LOCATION_CODE -> !Objects.isNull(this.clientLocationCode); default -> { log.warn("Prop not found {}", prop); yield false; diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java index f7d283e0..6c73bd1c 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/endpoint/OpeningSearchEndpoint.java @@ -92,6 +92,8 @@ public PaginatedResult openingSearch( String cuttingPermitId, @RequestParam(value = "cutBlockId", required = false) String cutBlockId, + @RequestParam(value = "clientLocationCode", required = false) + String clientLocationCode, @RequestParam(value = "timberMark", required = false) String timberMark, @Valid PaginationParameters paginationParameters) { @@ -113,6 +115,7 @@ public PaginatedResult openingSearch( cuttingPermitId, cutBlockId, timberMark, + clientLocationCode, mainSearchTerm); return openingService.openingSearch(filtersDto, paginationParameters); } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningRepository.java index 0b2be95b..2c658b2e 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/OpeningRepository.java @@ -206,6 +206,9 @@ LEFT JOIN THE.STOCKING_MILESTONE smfg ON (smfg.STOCKING_STANDARD_UNIT_ID = ssu.S ) AND ( NVL(:#{#filter.timberMark},'NOVALUE') = 'NOVALUE' OR TIMBER_MARK = :#{#filter.timberMark} + ) + AND ( + NVL(:#{#filter.clientLocationCode},'NOVALUE') = 'NOVALUE' OR client_location = :#{#filter.clientLocationCode} )""", countQuery = """ SELECT count(o.OPENING_ID) as total @@ -276,6 +279,9 @@ LEFT JOIN THE.STOCKING_MILESTONE smfg ON (smfg.STOCKING_STANDARD_UNIT_ID = ssu.S ) AND ( NVL(:#{#filter.timberMark},'NOVALUE') = 'NOVALUE' OR cboa.TIMBER_MARK = :#{#filter.timberMark} + ) + AND ( + NVL(:#{#filter.clientLocationCode},'NOVALUE') = 'NOVALUE' OR res.CLIENT_LOCN_CODE = :#{#filter.clientLocationCode} )""", nativeQuery = true ) From 197f7059616ff14634f354ce311aeaf42ef681ff Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 08:47:03 -0800 Subject: [PATCH 07/20] chore: fixing test --- .../restapi/results/oracle/service/OpeningServiceTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpeningServiceTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpeningServiceTest.java index ccad11f2..5571fb74 100644 --- a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpeningServiceTest.java +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/OpeningServiceTest.java @@ -79,6 +79,7 @@ void openingSearch_fileId_shouldSucceed() { null, null, null, + null, "103" ), new PaginationParameters(0, 10) @@ -137,7 +138,8 @@ void openingSearch_orgUnit_shouldSucceed() { null, null, null, - null + null, + null ), new PaginationParameters(0, 10) ); @@ -195,6 +197,7 @@ void openingSearch_noRecordsFound_shouldSucceed() { null, null, null, + null, "ABCD" ), new PaginationParameters(0, 10) @@ -232,6 +235,7 @@ void openingSearch_maxPageException_shouldFail() { null, null, null, + null, "FTML" ), new PaginationParameters(0, 2999) From b3180402c7f8f85f317e7a606871e4626a323aac Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 11:51:47 -0800 Subject: [PATCH 08/20] feat(SILVA-539): adding changes to frontend --- .../AutocompleteClientLocation/index.tsx | 60 +++++++++++++++---- .../Openings/AdvancedSearchDropdown/index.tsx | 24 ++++++-- .../Openings/OpeningsSearchBar/index.tsx | 3 +- .../src/contexts/AutocompleteProvider.tsx | 1 - .../src/contexts/search/OpeningsSearch.tsx | 18 ++++-- frontend/src/services/search/openings.ts | 2 + 6 files changed, 84 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index a1d55a3b..f1b8dfce 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -1,7 +1,12 @@ -import React, { useState, useEffect } from "react"; -import { ComboBox, InlineLoading } from "@carbon/react"; +import React, { useState, useImperativeHandle, forwardRef } from "react"; +import { ComboBox } from "@carbon/react"; import { useAutocomplete } from "../../contexts/AutocompleteProvider"; -import { fetchClientsByNameAcronymNumber, fetchClientLocations, ForestClientAutocomplete, ForestClientLocation } from "../../services/OpeningClientLocationService"; +import { + fetchClientsByNameAcronymNumber, + fetchClientLocations, + ForestClientAutocomplete, + ForestClientLocation +} from "../../services/OpeningClientLocationService"; interface AutocompleteProps { @@ -17,6 +22,10 @@ interface AutocompleteComponentProps { setValue: (value: string | null) => void; } +export interface AutocompleteComponentRefProps { + reset: () => void; +} + // Defines when the fetch should be skipped for a specific key export const skipConditions = { // Skip when the query value matches the selected text by the user on the dropdown @@ -55,28 +64,52 @@ export const fetchValues = async (query: string, key: string) => { return []; }; -const AutocompleteClientLocation: React.FC = ({ setValue }) => { +const AutocompleteClientLocation: React.ForwardRefExoticComponent> = forwardRef( + ({ setValue }, ref) => { const { options, fetchOptions, setOptions } = useAutocomplete(); const [isActive, setIsActive] = useState(false); const [location, setLocation] = useState(null); + const [client, setClient] = useState(null); + + const clearLocation = () => { + setLocation(null); + setClient(null); + setValue(null); + }; - const handleClientChange = async (e: AutocompleteComboboxProps) => { + const clearClient = () => { + setOptions("locations", []); + setOptions("clients", []); + setClient(null); + setValue(null); + setIsActive(false); + clearLocation(); + }; - const selectedItem = e.selectedItem; + const handleClientChange = async (autocompleteEvent: AutocompleteComboboxProps) => { + + const selectedItem = autocompleteEvent.selectedItem; if (selectedItem) { setIsActive(true); + setClient(selectedItem); await fetchOptions(selectedItem.id, "locations"); }else{ - setOptions("locations", []); - setLocation(null); - setIsActive(false); + clearClient(); } }; const handleLocationSelection = (e: AutocompleteComboboxProps) => { - setValue(e?.selectedItem?.id as string || null) - setLocation(e?.selectedItem || null); + const selectedItem = e.selectedItem; + if(selectedItem){ + setValue(selectedItem.id); + setLocation(selectedItem); + }else{ + clearLocation(); + } }; + useImperativeHandle(ref, () => ({ + reset: clearLocation + })); return (
@@ -84,6 +117,7 @@ const AutocompleteClientLocation: React.FC = ({ setV id="client-name" className="flex-fill" allowCustomValue + selectedItem={client} onInputChange={(e: string) => fetchOptions(e, "clients")} onChange={handleClientChange} helperText="Search by client name, number or acronym" @@ -103,6 +137,8 @@ const AutocompleteClientLocation: React.FC = ({ setV typeahead />
); -} +}); + +AutocompleteClientLocation.displayName = "AutocompleteClientLocation"; export default AutocompleteClientLocation; \ 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 90193e92..865b85f5 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Checkbox, CheckboxGroup, @@ -19,19 +19,20 @@ import { useOpeningsSearch } from "../../../../contexts/search/OpeningsSearch"; import { TextValueData, sortItems } from "../../../../utils/multiSelectSortUtils"; import { formatDateForDatePicker } from "../../../../utils/DateUtils"; import { AutocompleteProvider } from "../../../../contexts/AutocompleteProvider"; -import AutocompleteClientLocation, { skipConditions, fetchValues} from "../../../AutocompleteClientLocation"; +import AutocompleteClientLocation, { skipConditions, fetchValues, AutocompleteComponentRefProps} from "../../../AutocompleteClientLocation"; interface AdvancedSearchDropdownProps { toggleShowFilters: () => void; // Function to be passed as a prop } const AdvancedSearchDropdown: React.FC = () => { - const { filters, setFilters, clearFilters } = useOpeningsSearch(); + const { filters, setFilters, setIndividualClearFieldFunctions } = useOpeningsSearch(); const { data, isLoading, isError } = useOpeningFiltersQuery(); // Initialize selected items for OrgUnit MultiSelect based on existing filters const [selectedOrgUnits, setSelectedOrgUnits] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]); + const autoCompleteRef = useRef(null); useEffect(() => { // Split filters.orgUnit into array and format as needed for selectedItems @@ -56,12 +57,21 @@ const AdvancedSearchDropdown: React.FC = () => { } }, [filters.orgUnit, filters.category]); + useEffect(() => { + + // In here, we're defining the function that will be called when the user clicks on the "Clear" button + // The idea is to keep the autocomplete component clear of any ties to the opening search context + setIndividualClearFieldFunctions((previousIndividualFilters) => ({ + ...previousIndividualFilters, + clientLocationCode: () => autoCompleteRef.current?.reset() + })); + },[]); + const handleFilterChange = (updatedFilters: Partial) => { const newFilters = { ...filters, ...updatedFilters }; setFilters(newFilters); }; - const handleMultiSelectChange = (group: string, selectedItems: any) => { const updatedGroup = selectedItems.map((item: any) => item.value); if (group === "orgUnit") @@ -80,6 +90,7 @@ const AdvancedSearchDropdown: React.FC = () => { handleFilterChange({ [group]: updatedGroup }); }; + if (isLoading) { return ; } @@ -183,7 +194,10 @@ const AdvancedSearchDropdown: React.FC = () => { - handleFilterChange({ clientLocationCode: value })} /> + handleFilterChange({ clientLocationCode: value })} + ref={autoCompleteRef} + /> diff --git a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx index c3be5dc2..d2dbc063 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx @@ -18,10 +18,9 @@ const OpeningsSearchBar: React.FC = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [showFilters, setShowFilters] = useState(false); - const [searchInput, setSearchInput] = useState(""); const [filtersCount, setFiltersCount] = useState(0); const [filtersList, setFiltersList] = useState(null); - const { filters, clearFilters, searchTerm, setSearchTerm, clearIndividualField } = useOpeningsSearch(); + const { filters, clearFilters, searchTerm, setSearchTerm } = useOpeningsSearch(); const toggleDropdown = () => { setIsOpen(!isOpen); diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index b6364cc2..7138a94a 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -44,7 +44,6 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: } catch (fetchError) { setError("Error fetching options"); } finally { - console.log('Disabling loading'); setLoading(false); } }, 450); diff --git a/frontend/src/contexts/search/OpeningsSearch.tsx b/frontend/src/contexts/search/OpeningsSearch.tsx index b198c30d..b58f03c9 100644 --- a/frontend/src/contexts/search/OpeningsSearch.tsx +++ b/frontend/src/contexts/search/OpeningsSearch.tsx @@ -2,12 +2,13 @@ import React, { createContext, useState, useContext, ReactNode } from 'react'; // Define the shape of the search filters context interface OpeningsSearchContextProps { - filters: any; + filters: any; //In the future, this should be a type that represents the filters setFilters: React.Dispatch>; searchTerm: string; setSearchTerm: React.Dispatch> clearFilters: () => void; clearIndividualField: (key:string) => void; + setIndividualClearFieldFunctions: React.Dispatch void>>>; } // Create the context with a default value of undefined @@ -34,19 +35,28 @@ export const OpeningsSearchProvider: React.FC<{ children: ReactNode }> = ({ chil const [filters, setFilters] = useState(defaultFilters); const [searchTerm, setSearchTerm] = useState(""); + const [individualClearFieldFunctions, setIndividualClearFieldFunctions] = useState void>>({}); // Function to clear individual filter field by key const clearIndividualField = (key: string) => { setFilters((prevFilters) => ({ ...prevFilters, - [key]: defaultFilters[key as keyof typeof defaultFilters], + [key]: defaultFilters[key as keyof typeof defaultFilters] })); + individualClearFieldFunctions[key] && individualClearFieldFunctions[key](); }; - const clearFilters = () => setFilters(defaultFilters); + const clearFilters = () => { + setFilters(defaultFilters); + + Object.keys(defaultFilters).forEach((key) => { + individualClearFieldFunctions[key] && individualClearFieldFunctions[key](); + }); + + }; return ( - + {children} ); diff --git a/frontend/src/services/search/openings.ts b/frontend/src/services/search/openings.ts index 89862894..c1e08f7c 100644 --- a/frontend/src/services/search/openings.ts +++ b/frontend/src/services/search/openings.ts @@ -23,6 +23,7 @@ export interface OpeningFilters { blockStatuses?: string[]; page?: number; perPage?: number; + clientLocationCode?: string; } export interface OpeningItem { @@ -68,6 +69,7 @@ export const fetchOpenings = async (filters: OpeningFilters): Promise => { cutBlockId: filters.cutBlock, cuttingPermitId:filters.cuttingPermit, timbermark:filters.timberMark, + clientLocationCode: filters.clientLocationCode, myOpenings: filters.openingFilters?.includes("Openings created by me") || undefined, submittedToFrpa: From e2b9a70a4c146a7412d1f5d0f327e1880787eb53 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 13:01:15 -0800 Subject: [PATCH 09/20] test: fixing FE tests --- .../Openings/AdvancedSearchDropdown.test.tsx | 12 +++++++--- .../Openings/OpeningSearchBar.test.tsx | 22 +++++++++++-------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx index bd076ee3..6ff500fc 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx @@ -14,9 +14,13 @@ vi.mock("../../../../services/queries/search/openingQueries", () => ({ })); // Mocking useOpeningsSearch to return the necessary functions and state -vi.mock("../../../../contexts/search/OpeningsSearch", () => ({ - useOpeningsSearch: vi.fn(), -})); +vi.mock("../../../../contexts/search/OpeningsSearch", async () => { + const actual = await vi.importActual("../../../../contexts/search/OpeningsSearch"); + return { + ...actual, + useOpeningsSearch: vi.fn(), + } +}); describe("AdvancedSearchDropdown", () => { beforeEach(() => { @@ -60,6 +64,7 @@ describe("AdvancedSearchDropdown", () => { }, setFilters: vi.fn(), clearFilters: vi.fn(), + setIndividualClearFieldFunctions: vi.fn(), }); }); @@ -104,6 +109,7 @@ describe("AdvancedSearchDropdown", () => { openingFilters: [] as string[], blockStatuses: [] as string[] }, + setIndividualClearFieldFunctions: vi.fn(), }); let container; await act(async () => { diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx index a48dd4e8..a4b3c7ff 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/OpeningSearchBar.test.tsx @@ -10,14 +10,19 @@ import { OpeningsSearchProvider, useOpeningsSearch } from "../../../../contexts/ import userEvent from "@testing-library/user-event"; // Mock the useOpeningsSearch context to avoid rendering errors -vi.mock("../../../../contexts/search/OpeningsSearch", () => ({ - useOpeningsSearch: vi.fn().mockReturnValue({ - filters: [], - clearFilters: vi.fn(), - searchTerm: "", - setSearchTerm: vi.fn(), - }), -})); +vi.mock("../../../../contexts/search/OpeningsSearch", async () => { + const actual = await vi.importActual("../../../../contexts/search/OpeningsSearch"); + return { + ...actual, + useOpeningsSearch: vi.fn().mockReturnValue({ + filters: [], + clearFilters: vi.fn(), + searchTerm: "", + setSearchTerm: vi.fn(), + setIndividualClearFieldFunctions: vi.fn(), + }), + } +}); describe("OpeningsSearchBar", () => { // Create a new QueryClient instance for each test @@ -106,7 +111,6 @@ describe("OpeningsSearchBar", () => { vi.spyOn(React, 'useState') .mockImplementationOnce(() => [false, vi.fn()]) // Mocking isOpen state as false .mockImplementationOnce(() => [false, vi.fn()]) // Mocking showFilters state as false - .mockImplementationOnce(() => ["", vi.fn()]) // Mocking searchInput state .mockImplementationOnce(() => [2, vi.fn()]) // Mocking filtersCount state .mockImplementationOnce(() => [null, vi.fn()]); // Mocking filtersList state From 499d12047a6652fe6afa225ecead79cee64dbd06 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 14:52:50 -0800 Subject: [PATCH 10/20] chore: adding loading --- .../AutocompleteClientLocation/index.tsx | 19 +++++++------- .../src/contexts/AutocompleteProvider.tsx | 25 +++++++++++++++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index f1b8dfce..a689f3d3 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -8,7 +8,6 @@ import { ForestClientLocation } from "../../services/OpeningClientLocationService"; - interface AutocompleteProps { id: string, label: string, @@ -65,7 +64,8 @@ export const fetchValues = async (query: string, key: string) => { }; const AutocompleteClientLocation: React.ForwardRefExoticComponent> = forwardRef( - ({ setValue }, ref) => { + ({ setValue }, ref) => + { const { options, fetchOptions, setOptions } = useAutocomplete(); const [isActive, setIsActive] = useState(false); const [location, setLocation] = useState(null); @@ -107,6 +107,7 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent ({ reset: clearLocation })); @@ -116,23 +117,23 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent fetchOptions(e, "clients")} + onInputChange={(value: string) => fetchOptions(value, "clients")} onChange={handleClientChange} + itemToElement={(item: AutocompleteProps) => item.label} helperText="Search by client name, number or acronym" items={options["clients"] || []} - titleText="Client" - typeahead /> + titleText="Client" /> item.label} + items={options["locations"] || [{ id: "", label: "No results found" }]} titleText="Location code" typeahead /> diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index 7138a94a..145bed1f 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -1,8 +1,7 @@ import { createContext, useContext, ReactNode, useState, useRef, useEffect } from "react"; +import { InlineLoading } from "@carbon/react"; import { debounce, DebouncedFunc } from "lodash"; -// TODO: test this - interface AutocompleteProviderProps { fetchOptions: (query: string, key: string) => Promise; skipConditions?: Record boolean>; @@ -17,6 +16,13 @@ interface AutocompleteContextType { setOptions: (key: string, items: any[]) => void; } +const searchingForItems = [{ + id: "", + label: () +}]; + +const noDataFound = [{ id: "", label: "No results found" }]; + const AutocompleteContext = createContext(undefined); export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: AutocompleteProviderProps) => { @@ -39,8 +45,9 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: if (skipConditions && skipConditions[key] && skipConditions[key](query)) { return; } + setOptions(key, searchingForItems); const fetchedOptions = await fetchOptions(query, key); - setOptions(key, fetchedOptions); + setOptions(key, fetchedOptions.length ? fetchedOptions : noDataFound); } catch (fetchError) { setError("Error fetching options"); } finally { @@ -49,8 +56,16 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: }, 450); }; - const fetchAndSetOptions = (query: string, key: string) => { - setLoading(true); + const fetchAndSetOptions = async (query: string, key: string) => { + if(!key || !query) return; + if (skipConditions && skipConditions[key] && skipConditions[key](query)) { + return; + } + /*setLoading(true); + setOptions(key, searchingForItems); + const fetchedOptions = await fetchOptions(query, key); + setOptions(key, fetchedOptions.length ? fetchedOptions : noDataFound); + setLoading(false);*/ if (!debouncedFetchMap.current.has(key)) { debouncedFetchMap.current.set(key, createDebouncedFetch(key)); } From 4ae3f2230af667f8b69e0245690f10c0f99a5e1a Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 15:21:43 -0800 Subject: [PATCH 11/20] test(SILVA-539): adding test to autocomplete --- .../contexts/AutocompleteProvider.test.tsx | 200 ++++++++++++++++++ .../src/contexts/AutocompleteProvider.tsx | 8 +- 2 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 frontend/src/__test__/contexts/AutocompleteProvider.test.tsx diff --git a/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx new file mode 100644 index 00000000..4574e215 --- /dev/null +++ b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { render, screen, waitFor, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; +import { AutocompleteProvider, useAutocomplete } from "../../contexts/AutocompleteProvider"; + +// Mock lodash debounce to run instantly in tests +vi.mock("lodash", () => ({ + debounce: (fn: any) => { + fn.cancel = vi.fn(); + return fn; + }, +})); + +// Mock component to consume the context +const MockAutocompleteConsumer = () => { + const { options, loading, error, fetchOptions } = useAutocomplete(); + return ( +
+ + {loading &&
Loading...
} + {error &&
{error}
} +
{JSON.stringify(options)}
+
+ ); +}; + +describe("AutocompleteProvider", () => { + let fetchOptionsMock: vi.Mock; + + beforeEach(() => { + fetchOptionsMock = vi.fn((query: string, key: string) => []); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("renders children correctly", () => { + render( + +
Child Component
+
+ ); + expect(screen.getByText("Child Component")).toBeInTheDocument(); + }); + + it("calls fetchOptions and updates state correctly", async () => { + fetchOptionsMock.mockResolvedValueOnce(["option1", "option2"]); + + await act(async () =>render( + + + + )); + + await act(async () => userEvent.click(screen.getByText("Fetch Options"))); + + expect(fetchOptionsMock).toHaveBeenCalledWith("test-query", "key1"); + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["option1", "option2"] }) + ); + }); + }); + + it("handles skip conditions", async () => { + const skipConditions = { key1: (query: string) => query === "skip" }; + fetchOptionsMock.mockResolvedValueOnce(["option1", "option2"]); + + await act(async () =>render( + + + + )); + + await act(async () =>userEvent.click(screen.getByText("Fetch Options"))); + + // Should fetch for "test-query" + expect(fetchOptionsMock).toHaveBeenCalledWith("test-query", "key1"); + + userEvent.click(screen.getByText("Fetch Options")); // Attempt to fetch for "skip" + fetchOptionsMock.mockClear(); + + await waitFor(() => { + expect(fetchOptionsMock).not.toHaveBeenCalled(); // Skip condition met + }); + }); + + it("handles errors during fetching", async () => { + fetchOptionsMock.mockRejectedValueOnce(new Error("Fetch error")); + + render( + + + + ); + + userEvent.click(screen.getByText("Fetch Options")); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Error fetching options"); + }); + }); + + it("sets options correctly", () => { + render( + + + + ); + + userEvent.click(screen.getByText("Fetch Options")); + + waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["option1", "option2"] }) + ); + }); + }); + + it("handles multiple keys correctly", async () => { + fetchOptionsMock.mockResolvedValueOnce(["option1", "option2"]); + fetchOptionsMock.mockResolvedValueOnce(["option3", "option4"]); + + await act(async () =>render( + + + + )); + + await act(async () =>userEvent.click(screen.getByText("Fetch Options"))); + expect(fetchOptionsMock).toHaveBeenCalledWith("test-query", "key1"); + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["option1", "option2"] }) + ); + }); + + await act(async () =>userEvent.click(screen.getByText("Fetch Options"))); + expect(fetchOptionsMock).toHaveBeenCalledWith("test-query", "key1"); + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["option3", "option4"] }) + ); + }); + }); + + it("updates loading state correctly", async () => { + fetchOptionsMock.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve(["option1", "option2"]), 2500)) + ); + + await act(async () =>render( + + + + )); + expect(screen.queryByTestId("loading")).not.toBeInTheDocument(); + await act(async () =>userEvent.click(screen.getByText("Fetch Options"))); + expect(screen.getByTestId("loading")).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByTestId("loading")).not.toBeInTheDocument(), { timeout: 3000 }); + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it("updates error state correctly", async () => { + fetchOptionsMock.mockRejectedValueOnce(new Error("Fetch error")); + + render( + + + + ); + + userEvent.click(screen.getByText("Fetch Options")); + + await waitFor(() => { + expect(screen.getByTestId("error")).toHaveTextContent("Error fetching options"); + }); + }); + + it("does not fetch options if key or query is missing", async () => { + render( + + + + ); + + userEvent.click(screen.getByText("Fetch Options")); + + await waitFor(() => { + expect(fetchOptionsMock).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index 145bed1f..593ee5f0 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -45,6 +45,7 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: if (skipConditions && skipConditions[key] && skipConditions[key](query)) { return; } + setLoading(true); setOptions(key, searchingForItems); const fetchedOptions = await fetchOptions(query, key); setOptions(key, fetchedOptions.length ? fetchedOptions : noDataFound); @@ -56,16 +57,11 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: }, 450); }; - const fetchAndSetOptions = async (query: string, key: string) => { + const fetchAndSetOptions = (query: string, key: string) => { if(!key || !query) return; if (skipConditions && skipConditions[key] && skipConditions[key](query)) { return; } - /*setLoading(true); - setOptions(key, searchingForItems); - const fetchedOptions = await fetchOptions(query, key); - setOptions(key, fetchedOptions.length ? fetchedOptions : noDataFound); - setLoading(false);*/ if (!debouncedFetchMap.current.has(key)) { debouncedFetchMap.current.set(key, createDebouncedFetch(key)); } From dc96131f384a9ba3b6171bef8a39048be7c9f9b4 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 15:35:02 -0800 Subject: [PATCH 12/20] chore: sonar fixes --- .../AutocompleteClientLocation/index.tsx | 6 +++--- frontend/src/contexts/AutocompleteProvider.tsx | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index a689f3d3..e0004eb4 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -66,7 +66,7 @@ export const fetchValues = async (query: string, key: string) => { const AutocompleteClientLocation: React.ForwardRefExoticComponent> = forwardRef( ({ setValue }, ref) => { - const { options, fetchOptions, setOptions } = useAutocomplete(); + const { options, fetchOptions, updateOptions } = useAutocomplete(); const [isActive, setIsActive] = useState(false); const [location, setLocation] = useState(null); const [client, setClient] = useState(null); @@ -78,8 +78,8 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent { - setOptions("locations", []); - setOptions("clients", []); + updateOptions("locations", []); + updateOptions("clients", []); setClient(null); setValue(null); setIsActive(false); diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index 593ee5f0..81da0d54 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -13,7 +13,7 @@ interface AutocompleteContextType { loading: boolean; error: string | null; fetchOptions: (query: string, key: string) => void; - setOptions: (key: string, items: any[]) => void; + updateOptions: (key: string, items: any[]) => void; } const searchingForItems = [{ @@ -26,13 +26,13 @@ const noDataFound = [{ id: "", label: "No results found" }]; const AutocompleteContext = createContext(undefined); export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: AutocompleteProviderProps) => { - const [options, setOptionsState] = useState>({}); + const [options, setOptions] = useState>({}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const debouncedFetchMap = useRef Promise>>>(new Map()); - const setOptions = (key: string, items: any[]) => { - setOptionsState((prev) => ({ ...prev, [key]: items })); + const updateOptions = (key: string, items: any[]) => { + setOptions((prev) => ({ ...prev, [key]: items })); }; const createDebouncedFetch = (key: string) => { @@ -42,13 +42,13 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: if(!key || !query) return; // If skipConditions are present and the condition is met, return, this avoids overwriting the existing // data when a condition to skip is met - if (skipConditions && skipConditions[key] && skipConditions[key](query)) { + if (skipConditions?.[key]?.(query)) { return; } setLoading(true); - setOptions(key, searchingForItems); + setOptions((prev) => ({ ...prev, [key]: searchingForItems })); const fetchedOptions = await fetchOptions(query, key); - setOptions(key, fetchedOptions.length ? fetchedOptions : noDataFound); + setOptions((prev) => ({ ...prev, [key]: fetchedOptions.length ? fetchedOptions : noDataFound })); } catch (fetchError) { setError("Error fetching options"); } finally { @@ -59,7 +59,7 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: const fetchAndSetOptions = (query: string, key: string) => { if(!key || !query) return; - if (skipConditions && skipConditions[key] && skipConditions[key](query)) { + if (skipConditions?.[key]?.(query)) { return; } if (!debouncedFetchMap.current.has(key)) { @@ -79,7 +79,7 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: }, []); return ( - + {children} ); From 37d5c465b2825c42eba4b8e8d7c0540148c214ed Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 15:54:13 -0800 Subject: [PATCH 13/20] chore: fixing sonar issues --- .../contexts/AutocompleteProvider.test.tsx | 119 +++++++++++++++++- .../src/contexts/AutocompleteProvider.tsx | 12 +- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx index 4574e215..293cd934 100644 --- a/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx +++ b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx @@ -185,16 +185,127 @@ describe("AutocompleteProvider", () => { }); it("does not fetch options if key or query is missing", async () => { - render( + const MockAutocompleteConsumer1 = () => { + const { options, loading, error, fetchOptions } = useAutocomplete(); + return ( +
+ + {loading &&
Loading...
} + {error &&
{error}
} +
{JSON.stringify(options)}
+
+ ); + }; + + await act(async () => render( - + - ); + )); - userEvent.click(screen.getByText("Fetch Options")); + await act(async () => userEvent.click(screen.getByText("Fetch Options"))); await waitFor(() => { expect(fetchOptionsMock).not.toHaveBeenCalled(); }); }); + + it("does not fetch options if condition to skip matches", async () => { + const MockAutocompleteConsumer2 = () => { + const { options, loading, error, fetchOptions } = useAutocomplete(); + return ( +
+ + {loading &&
Loading...
} + {error &&
{error}
} +
{JSON.stringify(options)}
+
+ ); + }; + + const conditions = { + key1: (query: string) => query === "query" + } + + await act(async () => render( + + + + )); + + await act(async () => userEvent.click(screen.getByText("Fetch Options"))); + + await waitFor(() => { + expect(fetchOptionsMock).not.toHaveBeenCalled(); + }); + }); + + it("should fail when mounting without context", () => { + expect(() => render()).toThrow( + "useAutocomplete must be used within an AutocompleteProvider" + ); + }) + + it("handles no results with proper message", async () => { + fetchOptionsMock.mockResolvedValueOnce([]); + + await act(async () =>render( + + + + )); + + await act(async () => userEvent.click(screen.getByText("Fetch Options"))); + + expect(fetchOptionsMock).toHaveBeenCalledWith("test-query", "key1"); + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: [{"id":"","label":"No results found"}] }) + ); + }); + }); + + it("handles set options", async () => { + fetchOptionsMock.mockResolvedValueOnce(["option1", "option2"]); + + const MockAutocompleteConsumer3 = () => { + const { options, loading, error, fetchOptions, updateOptions } = useAutocomplete(); + return ( +
+ + + {loading &&
Loading...
} + {error &&
{error}
} +
{JSON.stringify(options)}
+
+ ); + }; + + await act(async () => render( + + + + )); + + await act(async () => userEvent.click(screen.getByText("Fetch Options"))); + + expect(fetchOptionsMock).toHaveBeenCalledWith("query", "key1"); + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["option1", "option2"] }) + ); + }); + + await act(async () => userEvent.click(screen.getByText("Set Options"))); + + + await waitFor(() => { + expect(screen.getByTestId("options")).toHaveTextContent( + JSON.stringify({ key1: ["notalot"] }) + ); + }); + + }); }); \ No newline at end of file diff --git a/frontend/src/contexts/AutocompleteProvider.tsx b/frontend/src/contexts/AutocompleteProvider.tsx index 81da0d54..3b8a9b7a 100644 --- a/frontend/src/contexts/AutocompleteProvider.tsx +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -37,14 +37,7 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: const createDebouncedFetch = (key: string) => { return debounce(async (query: string) => { - try { - // If no key or query, return - if(!key || !query) return; - // If skipConditions are present and the condition is met, return, this avoids overwriting the existing - // data when a condition to skip is met - if (skipConditions?.[key]?.(query)) { - return; - } + try { setLoading(true); setOptions((prev) => ({ ...prev, [key]: searchingForItems })); const fetchedOptions = await fetchOptions(query, key); @@ -58,7 +51,10 @@ export const AutocompleteProvider = ({ fetchOptions, skipConditions, children }: }; const fetchAndSetOptions = (query: string, key: string) => { + // If no key or query, return if(!key || !query) return; + // If skipConditions are present and the condition is met, return, this avoids overwriting the existing + // data when a condition to skip is met if (skipConditions?.[key]?.(query)) { return; } From cd828df685df98659a8018ad4212b5b5589f24b8 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Thu, 28 Nov 2024 15:56:56 -0800 Subject: [PATCH 14/20] test(SILVA-539): adding tests --- .../OpeningClientLocationService.test.ts | 59 +++++++++++++++++++ .../services/OpeningClientLocationService.ts | 2 - 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__test__/services/OpeningClientLocationService.test.ts diff --git a/frontend/src/__test__/services/OpeningClientLocationService.test.ts b/frontend/src/__test__/services/OpeningClientLocationService.test.ts new file mode 100644 index 00000000..0de3f05a --- /dev/null +++ b/frontend/src/__test__/services/OpeningClientLocationService.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import axios from 'axios'; +import { getAuthIdToken } from '../../services/AuthService'; +import { fetchClientsByNameAcronymNumber, fetchClientLocations } from '../../services/OpeningClientLocationService'; +import { API_ENDPOINTS, defaultHeaders } from '../../services/apiConfig'; + +vi.mock('axios'); +vi.mock('../../services/AuthService'); +vi.mock('../../services/apiConfig'); + +describe('OpeningClientLocationService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('fetchClientsByNameAcronymNumber', () => { + it('should fetch clients by name, acronym, or number', async () => { + const mockData = [{ id: '1', name: 'Client A', acronym: 'CA' }]; + axios.get.mockResolvedValueOnce({ data: mockData }); + getAuthIdToken.mockReturnValue('mock-token'); + API_ENDPOINTS.clientsByNameAcronymNumber.mockReturnValue('/mock-endpoint'); + + const result = await fetchClientsByNameAcronymNumber('Client A'); + + expect(axios.get).toHaveBeenCalledWith('/mock-endpoint', defaultHeaders('mock-token')); + expect(result).toEqual(mockData); + }); + + it('should handle errors', async () => { + axios.get.mockRejectedValueOnce(new Error('Network Error')); + getAuthIdToken.mockReturnValue('mock-token'); + API_ENDPOINTS.clientsByNameAcronymNumber.mockReturnValue('/mock-endpoint'); + + await expect(fetchClientsByNameAcronymNumber('Client A')).rejects.toThrow('Network Error'); + }); + }); + + describe('fetchClientLocations', () => { + it('should fetch client locations by client ID', async () => { + const mockData = [{ id: '1', name: 'Location A' }]; + axios.get.mockResolvedValueOnce({ data: mockData }); + getAuthIdToken.mockReturnValue('mock-token'); + API_ENDPOINTS.clientLocations.mockReturnValue('/mock-endpoint'); + + const result = await fetchClientLocations('1'); + + expect(axios.get).toHaveBeenCalledWith('/mock-endpoint', defaultHeaders('mock-token')); + expect(result).toEqual(mockData); + }); + + it('should handle errors', async () => { + axios.get.mockRejectedValueOnce(new Error('Network Error')); + getAuthIdToken.mockReturnValue('mock-token'); + API_ENDPOINTS.clientLocations.mockReturnValue('/mock-endpoint'); + + await expect(fetchClientLocations('1')).rejects.toThrow('Network Error'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/services/OpeningClientLocationService.ts b/frontend/src/services/OpeningClientLocationService.ts index 37c11f86..101e5a11 100644 --- a/frontend/src/services/OpeningClientLocationService.ts +++ b/frontend/src/services/OpeningClientLocationService.ts @@ -2,8 +2,6 @@ import axios from 'axios'; import { getAuthIdToken } from './AuthService'; import { API_ENDPOINTS, defaultHeaders } from './apiConfig'; -// TODO: move interfaces to types maybe? -// TODO: test this export interface ForestClientAutocomplete { id: string; name: string; From d98ecdc843ce898bd7ff6d36f72502df185a10a0 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 03:59:56 -0800 Subject: [PATCH 15/20] chore: sonar fix --- .../AutocompleteClientLocation.test.tsx | 116 ++++++++++++++++++ .../AutocompleteClientLocation/index.tsx | 9 +- .../Openings/AdvancedSearchDropdown/index.tsx | 1 - 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__test__/components/AutocompleteClientLocation.test.tsx diff --git a/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx new file mode 100644 index 00000000..ff6229f7 --- /dev/null +++ b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import AutocompleteClientLocation from "../../components/AutocompleteClientLocation"; +import { useAutocomplete } from "../../contexts/AutocompleteProvider"; + +// Mock the `useAutocomplete` hook +vi.mock("../../contexts/AutocompleteProvider", () => ({ + useAutocomplete: vi.fn(), +})); + +// Mock API data +const mockClients = [ + { id: "1", label: "Client One, 1, C1" }, + { id: "2", label: "Client Two, 2, C2" }, +]; + +const mockLocations = [ + { id: "A", label: "A - Location A" }, + { id: "B", label: "B - Location B" }, +]; + +describe("AutocompleteClientLocation", () => { + const mockFetchOptions = vi.fn(); + const mockUpdateOptions = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useAutocomplete as any).mockReturnValue({ + options: { + clients: mockClients, + locations: mockLocations, + }, + fetchOptions: mockFetchOptions, + updateOptions: mockUpdateOptions, + }); + }); + + it("renders both ComboBoxes and their titles", () => { + render(); + expect(screen.getByText("Client")).toBeInTheDocument(); + expect(screen.getByText("Location code")).toBeInTheDocument(); + }); + + it("disables the Location ComboBox initially", () => { + render(); + expect(screen.getByRole("combobox", { name: /location code/i })).toBeDisabled(); + }); + + it("calls fetchOptions when typing in the Client ComboBox", async () => { + render(); + + const clientInput = screen.getByRole("combobox", { name: /client/i }); + await userEvent.type(clientInput, "Client"); + + await waitFor(() => { + expect(mockFetchOptions).toHaveBeenCalledWith("Client", "clients"); + }); + }); + + it("enables the Location ComboBox when a client is selected", async () => { + render(); + + const clientInput = screen.getByRole("combobox", { name: /client/i }); + await userEvent.type(clientInput, "Client"); + + const clientOption = screen.getByText(mockClients[0].label); + await userEvent.click(clientOption); + + expect(screen.getByRole("combobox", { name: /location code/i })).not.toBeDisabled(); + expect(mockFetchOptions).toHaveBeenCalledWith("1", "locations"); + }); + + it("clears the location selection when the client is reset", async () => { + const mockSetValue = vi.fn(); + render(); + + // Select a client + const clientInput = screen.getByRole("combobox", { name: /client/i }); + await userEvent.type(clientInput, "Client"); + const clientOption = screen.getByText(mockClients[0].label); + await userEvent.click(clientOption); + + // Select a location + const locationInput = screen.getByRole("combobox", { name: /location code/i }); + const locationOption = screen.getByText(mockLocations[0].label); + await userEvent.click(locationOption); + + // Clear client selection + const clientClearButton = screen.getByRole("button", { name: /clear/i }); + fireEvent.click(clientClearButton); + + expect(mockUpdateOptions).toHaveBeenCalledWith("locations", []); + expect(mockUpdateOptions).toHaveBeenCalledWith("clients", []); + expect(mockSetValue).toHaveBeenCalledWith(null); + }); + + it("calls setValue when a location is selected", async () => { + const mockSetValue = vi.fn(); + render(); + + // Select a client + const clientInput = screen.getByRole("combobox", { name: /client/i }); + await userEvent.type(clientInput, "Client"); + const clientOption = screen.getByText(mockClients[0].label); + await userEvent.click(clientOption); + + // Select a location + const locationInput = screen.getByRole("combobox", { name: /location code/i }); + const locationOption = screen.getByText(mockLocations[0].label); + await userEvent.click(locationOption); + + expect(mockSetValue).toHaveBeenCalledWith("A"); + }); +}); diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index e0004eb4..3424dee8 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -28,7 +28,10 @@ export interface AutocompleteComponentRefProps { // Defines when the fetch should be skipped for a specific key export const skipConditions = { // Skip when the query value matches the selected text by the user on the dropdown - clients: (query: string) => query.match(/^[a-zA-Z\s]*,\s[a-zA-Z\s]*,*/) ? true : false, + clients: (query: string) => { + const regex = /^[a-zA-Z\s]*,\s[a-zA-Z\s]*,*/; + return regex.exec(query) !== null; + }, // Never skips for locations // eslint-disable-next-line @typescript-eslint/no-unused-vars locations: (query: string) => false @@ -86,13 +89,13 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent { + const handleClientChange = (autocompleteEvent: AutocompleteComboboxProps) => { const selectedItem = autocompleteEvent.selectedItem; if (selectedItem) { setIsActive(true); setClient(selectedItem); - await fetchOptions(selectedItem.id, "locations"); + fetchOptions(selectedItem.id, "locations"); }else{ clearClient(); } diff --git a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx index 865b85f5..7537e351 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -13,7 +13,6 @@ import { FilterableMultiSelect } from "@carbon/react"; import "./AdvancedSearchDropdown.scss"; -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"; From 6fe45d7b6039c20a1d57eab1a2f9d3148d38ef0f Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 04:37:01 -0800 Subject: [PATCH 16/20] chore: clearning up components --- .../AutocompleteClientLocation/index.tsx | 32 ++++++------------- .../Openings/AdvancedSearchDropdown/index.tsx | 3 +- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index 3424dee8..863d5343 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useImperativeHandle, forwardRef } from "react"; +import React, { useEffect, useState, useImperativeHandle, forwardRef } from "react"; import { ComboBox } from "@carbon/react"; import { useAutocomplete } from "../../contexts/AutocompleteProvider"; import { @@ -74,19 +74,13 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent(null); const [client, setClient] = useState(null); - const clearLocation = () => { - setLocation(null); - setClient(null); - setValue(null); - }; - const clearClient = () => { updateOptions("locations", []); updateOptions("clients", []); setClient(null); setValue(null); setIsActive(false); - clearLocation(); + setLocation(null); }; const handleClientChange = (autocompleteEvent: AutocompleteComboboxProps) => { @@ -101,20 +95,14 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent { - const selectedItem = e.selectedItem; - if(selectedItem){ - setValue(selectedItem.id); - setLocation(selectedItem); - }else{ - clearLocation(); - } - }; - useImperativeHandle(ref, () => ({ - reset: clearLocation + reset: () => setLocation(null) })); + useEffect(() => { + setValue(location?.id || null); + }, [location]); + return (
setLocation(item.selectedItem)} itemToElement={(item: AutocompleteProps) => item.label} items={options["locations"] || [{ id: "", label: "No results found" }]} - titleText="Location code" - typeahead /> + titleText="Location code" />
); }); diff --git a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx index 7537e351..f0cd3c52 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -67,8 +67,7 @@ const AdvancedSearchDropdown: React.FC = () => { },[]); const handleFilterChange = (updatedFilters: Partial) => { - const newFilters = { ...filters, ...updatedFilters }; - setFilters(newFilters); + setFilters({ ...filters, ...updatedFilters }); }; const handleMultiSelectChange = (group: string, selectedItems: any) => { From f6ba29b1e0529a73808fb956ba8cbe5ad1a2d522 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 04:56:55 -0800 Subject: [PATCH 17/20] chore: fixing tests --- .../components/AutocompleteClientLocation.test.tsx | 12 +++++++++--- .../Openings/AdvancedSearchDropdown.test.tsx | 1 + .../components/AutocompleteClientLocation/index.tsx | 1 + frontend/src/setupTests.ts | 6 ++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx index ff6229f7..b97ca5e5 100644 --- a/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx +++ b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx @@ -74,6 +74,7 @@ describe("AutocompleteClientLocation", () => { it("clears the location selection when the client is reset", async () => { const mockSetValue = vi.fn(); + render(); // Select a client @@ -81,15 +82,18 @@ describe("AutocompleteClientLocation", () => { await userEvent.type(clientInput, "Client"); const clientOption = screen.getByText(mockClients[0].label); await userEvent.click(clientOption); + expect(mockFetchOptions).toHaveBeenCalledWith("1", "locations"); // Select a location const locationInput = screen.getByRole("combobox", { name: /location code/i }); + await userEvent.click(locationInput); const locationOption = screen.getByText(mockLocations[0].label); await userEvent.click(locationOption); + expect(mockSetValue).toHaveBeenCalledWith("A"); // Clear client selection - const clientClearButton = screen.getByRole("button", { name: /clear/i }); - fireEvent.click(clientClearButton); + const clientClearButton = screen.getAllByRole("button", { name: /clear/i }); + fireEvent.click(clientClearButton[0]); expect(mockUpdateOptions).toHaveBeenCalledWith("locations", []); expect(mockUpdateOptions).toHaveBeenCalledWith("clients", []); @@ -98,6 +102,7 @@ describe("AutocompleteClientLocation", () => { it("calls setValue when a location is selected", async () => { const mockSetValue = vi.fn(); + render(); // Select a client @@ -105,12 +110,13 @@ describe("AutocompleteClientLocation", () => { await userEvent.type(clientInput, "Client"); const clientOption = screen.getByText(mockClients[0].label); await userEvent.click(clientOption); + expect(mockFetchOptions).toHaveBeenCalledWith("1", "locations"); // Select a location const locationInput = screen.getByRole("combobox", { name: /location code/i }); + await userEvent.click(locationInput); const locationOption = screen.getByText(mockLocations[0].label); await userEvent.click(locationOption); - expect(mockSetValue).toHaveBeenCalledWith("A"); }); }); diff --git a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx index 6ff500fc..87ee3778 100644 --- a/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx +++ b/frontend/src/__test__/components/SilvicultureSearch/Openings/AdvancedSearchDropdown.test.tsx @@ -110,6 +110,7 @@ describe("AdvancedSearchDropdown", () => { blockStatuses: [] as string[] }, setIndividualClearFieldFunctions: vi.fn(), + setFilters: vi.fn(), }); let container; await act(async () => { diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index 863d5343..5ca02738 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -7,6 +7,7 @@ import { ForestClientAutocomplete, ForestClientLocation } from "../../services/OpeningClientLocationService"; +import { update } from "lodash"; interface AutocompleteProps { id: string, diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 1c5770ac..9ca0d25a 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -64,3 +64,9 @@ Object.defineProperty(global.SVGElement.prototype, 'createSVGMatrix', { multiply: () => {} }) }); + +window.HTMLElement.prototype.scrollIntoView = function() {}; + +window.SVGElement.prototype.baseVal = { + consolidate: vi.fn(() => {}) +}; \ No newline at end of file From d43784dd3bb4c1e20af6c9c5a5bbacf5e2286401 Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 04:59:40 -0800 Subject: [PATCH 18/20] chore: fixing build --- frontend/src/setupTests.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 9ca0d25a..7d0e3bfa 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -66,7 +66,3 @@ Object.defineProperty(global.SVGElement.prototype, 'createSVGMatrix', { }); window.HTMLElement.prototype.scrollIntoView = function() {}; - -window.SVGElement.prototype.baseVal = { - consolidate: vi.fn(() => {}) -}; \ No newline at end of file From 7a89341802c4ae0f48bcc7648466bb2cefa720bb Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 05:01:08 -0800 Subject: [PATCH 19/20] chore: sonar fixes --- frontend/src/components/AutocompleteClientLocation/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index 5ca02738..e5df98e0 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -7,7 +7,6 @@ import { ForestClientAutocomplete, ForestClientLocation } from "../../services/OpeningClientLocationService"; -import { update } from "lodash"; interface AutocompleteProps { id: string, @@ -101,7 +100,7 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent { - setValue(location?.id || null); + setValue(location?.id ?? null); }, [location]); return ( From fa71b6176d68aeb8b9f84b752a81dd5ef33480ed Mon Sep 17 00:00:00 2001 From: Paulo Gomes da Cruz Junior Date: Mon, 2 Dec 2024 05:11:51 -0800 Subject: [PATCH 20/20] chore: fixing test --- .../results/common/enums/YesNoEnum.java | 2 +- .../results/common/enums/YesNoEnumTest.java | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/ca/bc/gov/restapi/results/common/enums/YesNoEnumTest.java diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java index 9914a251..b414ef05 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/enums/YesNoEnum.java @@ -25,6 +25,6 @@ public static YesNoEnum fromValue(String value) { return c; } } - throw new IllegalArgumentException(value); + return null; } } diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/enums/YesNoEnumTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/enums/YesNoEnumTest.java new file mode 100644 index 00000000..e1278b02 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/enums/YesNoEnumTest.java @@ -0,0 +1,35 @@ +package ca.bc.gov.restapi.results.common.enums; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +@DisplayName("Unit Test | YesNoEnum") +class YesNoEnumTest { + + @DisplayName("Test enum conversion") + @MethodSource("enumConversion") + @ParameterizedTest(name = "Test enum conversion for code {0} and expected {1}") + void testEnumConversion(String code, YesNoEnum expected) { + YesNoEnum actual = YesNoEnum.fromValue(code); + assertEquals(expected, actual); + if (expected != null) { + assertEquals(expected.value(), actual.value()); + } + } + + + private static Stream enumConversion() { + return Stream.of( + Arguments.of("Y", YesNoEnum.YES), + Arguments.of("N", YesNoEnum.NO), + Arguments.of(null, null), + Arguments.of("J", null) + ); + } + +} \ No newline at end of file