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 f0c7a27a..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,12 +1,16 @@ 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; 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 +36,18 @@ public ForestClientDto getForestClient(@PathVariable String clientNumber) { .getClientByNumber(clientNumber) .orElseThrow(ForestClientNotFoundException::new); } + + @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.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..b414ef05 --- /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; + } + } + return null; + } +} 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..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 @@ -1,11 +1,15 @@ 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; 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.HttpServerErrorException; import org.springframework.web.client.RestClient; /** @@ -44,10 +48,63 @@ 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 searchClients( + int page, + int size, + String value + ) { + log.info("Starting {} request to /clients/search/by?name={}&acronym={}&number={}", PROVIDER,value,value,value); + + 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(uriBuilder -> + uriBuilder + .path("/clients/{clientNumber}/locations") + .queryParam("page",0) + .queryParam("size",100) + .build(clientNumber) + ) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + } 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 c0f67bf2..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,7 +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; @@ -34,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"; @@ -46,4 +86,5 @@ private String checkClientNumber(String clientNumber) { return "00000000"; } } + } 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 ) 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/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 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") + ); + } + } 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) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d6d67316..9c5b93d3 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", @@ -45,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", @@ -5218,6 +5220,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 74802951..3a5618f4 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", @@ -62,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/__test__/components/AutocompleteClientLocation.test.tsx b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx new file mode 100644 index 00000000..b97ca5e5 --- /dev/null +++ b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx @@ -0,0 +1,122 @@ +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); + 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.getAllByRole("button", { name: /clear/i }); + fireEvent.click(clientClearButton[0]); + + 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); + 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 bd076ee3..87ee3778 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,8 @@ describe("AdvancedSearchDropdown", () => { openingFilters: [] as string[], blockStatuses: [] as string[] }, + setIndividualClearFieldFunctions: vi.fn(), + setFilters: 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 diff --git a/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx new file mode 100644 index 00000000..293cd934 --- /dev/null +++ b/frontend/src/__test__/contexts/AutocompleteProvider.test.tsx @@ -0,0 +1,311 @@ +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 () => { + const MockAutocompleteConsumer1 = () => { + const { options, loading, error, fetchOptions } = useAutocomplete(); + return ( +
+ + {loading &&
Loading...
} + {error &&
{error}
} +
{JSON.stringify(options)}
+
+ ); + }; + + await act(async () => render( + + + + )); + + 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/__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/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx new file mode 100644 index 00000000..e5df98e0 --- /dev/null +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -0,0 +1,134 @@ +import React, { useEffect, useState, useImperativeHandle, forwardRef } from "react"; +import { ComboBox } 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; +} + +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 + 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 +}; + +// 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.ForwardRefExoticComponent> = forwardRef( + ({ setValue }, ref) => + { + const { options, fetchOptions, updateOptions } = useAutocomplete(); + const [isActive, setIsActive] = useState(false); + const [location, setLocation] = useState(null); + const [client, setClient] = useState(null); + + const clearClient = () => { + updateOptions("locations", []); + updateOptions("clients", []); + setClient(null); + setValue(null); + setIsActive(false); + setLocation(null); + }; + + const handleClientChange = (autocompleteEvent: AutocompleteComboboxProps) => { + + const selectedItem = autocompleteEvent.selectedItem; + if (selectedItem) { + setIsActive(true); + setClient(selectedItem); + fetchOptions(selectedItem.id, "locations"); + }else{ + clearClient(); + } + }; + + useImperativeHandle(ref, () => ({ + reset: () => setLocation(null) + })); + + useEffect(() => { + setValue(location?.id ?? null); + }, [location]); + + return ( +
+ fetchOptions(value, "clients")} + onChange={handleClientChange} + itemToElement={(item: AutocompleteProps) => item.label} + helperText="Search by client name, number or acronym" + items={options["clients"] || []} + titleText="Client" /> + + setLocation(item.selectedItem)} + itemToElement={(item: AutocompleteProps) => item.label} + items={options["locations"] || [{ id: "", label: "No results found" }]} + titleText="Location code" /> +
+ ); +}); + +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 c0b40885..f0cd3c52 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/AdvancedSearchDropdown/index.tsx @@ -1,11 +1,9 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Checkbox, CheckboxGroup, Dropdown, TextInput, - FormLabel, - Tooltip, DatePicker, DatePickerInput, Loading, @@ -15,23 +13,25 @@ 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"; import { formatDateForDatePicker } from "../../../../utils/DateUtils"; +import { AutocompleteProvider } from "../../../../contexts/AutocompleteProvider"; +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 AdvancedSearchDropdown: React.FC = () => { + 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 +56,20 @@ 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); + setFilters({ ...filters, ...updatedFilters }); }; - const handleMultiSelectChange = (group: string, selectedItems: any) => { const updatedGroup = selectedItems.map((item: any) => item.value); if (group === "orgUnit") @@ -80,6 +88,7 @@ const AdvancedSearchDropdown: React.FC = () => { handleFilterChange({ [group]: updatedGroup }); }; + if (isLoading) { return ; } @@ -182,41 +191,12 @@ const AdvancedSearchDropdown: React.FC = () => { -
-
- Client acronym - - - - - handleFilterChange({ clientAcronym: e.target.value }) - } - /> -
- <> - - handleFilterChange({ clientLocationCode: e.target.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 new file mode 100644 index 00000000..3b8a9b7a --- /dev/null +++ b/frontend/src/contexts/AutocompleteProvider.tsx @@ -0,0 +1,90 @@ +import { createContext, useContext, ReactNode, useState, useRef, useEffect } from "react"; +import { InlineLoading } from "@carbon/react"; +import { debounce, DebouncedFunc } from "lodash"; + +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; + updateOptions: (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) => { + const [options, setOptions] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const debouncedFetchMap = useRef Promise>>>(new Map()); + + const updateOptions = (key: string, items: any[]) => { + setOptions((prev) => ({ ...prev, [key]: items })); + }; + + const createDebouncedFetch = (key: string) => { + return debounce(async (query: string) => { + try { + setLoading(true); + setOptions((prev) => ({ ...prev, [key]: searchingForItems })); + const fetchedOptions = await fetchOptions(query, key); + setOptions((prev) => ({ ...prev, [key]: fetchedOptions.length ? fetchedOptions : noDataFound })); + } catch (fetchError) { + setError("Error fetching options"); + } finally { + setLoading(false); + } + }, 450); + }; + + 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; + } + 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/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/OpeningClientLocationService.ts b/frontend/src/services/OpeningClientLocationService.ts new file mode 100644 index 00000000..101e5a11 --- /dev/null +++ b/frontend/src/services/OpeningClientLocationService.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; +import { getAuthIdToken } from './AuthService'; +import { API_ENDPOINTS, defaultHeaders } from './apiConfig'; + +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 index 4da6081b..ec9c22b5 100644 --- a/frontend/src/services/apiConfig.ts +++ b/frontend/src/services/apiConfig.ts @@ -11,7 +11,9 @@ const API_ENDPOINTS = { 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` + 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 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: diff --git a/frontend/src/setupTests.ts b/frontend/src/setupTests.ts index 1c5770ac..7d0e3bfa 100644 --- a/frontend/src/setupTests.ts +++ b/frontend/src/setupTests.ts @@ -64,3 +64,5 @@ Object.defineProperty(global.SVGElement.prototype, 'createSVGMatrix', { multiply: () => {} }) }); + +window.HTMLElement.prototype.scrollIntoView = function() {};