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 373452e8..2217b0f2 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 @@ -24,5 +24,6 @@ public class SilvaOracleConstants { public static final String TIMBER_MARK = "timberMark"; public static final String MAIN_SEARCH_TERM = "mainSearchTerm"; public static final String LOCATION_CODE = "clientLocationCode"; + public static final String CLIENT_NUMBER = "clientNumber"; public static final String NOVALUE = "NOVALUE"; } 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 48de7f15..09436f4a 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 @@ -37,6 +37,7 @@ public class OpeningSearchFiltersDto { // 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; + private final String clientNumber; @Setter private String requestUserId; @@ -62,6 +63,7 @@ public OpeningSearchFiltersDto( String cutBlockId, String timberMark, String clientLocationCode, + String clientNumber, String mainSearchTerm) { this.orgUnit = !CollectionUtils.isEmpty(orgUnit) ? orgUnit : List.of( SilvaOracleConstants.NOVALUE); @@ -97,6 +99,7 @@ public OpeningSearchFiltersDto( Objects.isNull(mainSearchTerm) ? null : mainSearchTerm.toUpperCase().trim(); this.clientLocationCode = Objects.isNull(clientLocationCode) ? null : clientLocationCode.trim(); + this.clientNumber = Objects.isNull(clientNumber) ? null : clientNumber.trim(); } /** @@ -130,6 +133,7 @@ public boolean hasValue(String prop) { 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); + case SilvaOracleConstants.CLIENT_NUMBER -> !Objects.isNull(this.clientNumber); 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 36c955ca..be528051 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 @@ -94,6 +94,8 @@ public PaginatedResult openingSearch( String cutBlockId, @RequestParam(value = "clientLocationCode", required = false) String clientLocationCode, + @RequestParam(value ="clientNumber", required = false) + String clientNumber, @RequestParam(value = "timberMark", required = false) String timberMark, @Valid PaginationParameters paginationParameters) { @@ -116,6 +118,7 @@ public PaginatedResult openingSearch( cutBlockId, timberMark, clientLocationCode, + clientNumber, 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 b6069729..f3290c42 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 @@ -211,6 +211,9 @@ LEFT JOIN THE.STOCKING_MILESTONE smfg ON (smfg.STOCKING_STANDARD_UNIT_ID = ssu.S ) AND ( NVL(:#{#filter.clientLocationCode},'NOVALUE') = 'NOVALUE' OR client_location = :#{#filter.clientLocationCode} + ) + AND ( + NVL(:#{#filter.clientNumber},'NOVALUE') = 'NOVALUE' OR client_number = :#{#filter.clientNumber} )""", countQuery = """ SELECT count(opening_id) as total @@ -308,6 +311,9 @@ LEFT JOIN THE.STOCKING_MILESTONE smfg ON (smfg.STOCKING_STANDARD_UNIT_ID = ssu.S AND ( NVL(:#{#filter.clientLocationCode},'NOVALUE') = 'NOVALUE' OR res.CLIENT_LOCN_CODE = :#{#filter.clientLocationCode} ) + AND ( + NVL(:#{#filter.clientNumber},'NOVALUE') = 'NOVALUE' OR res.CLIENT_NUMBER = :#{#filter.clientNumber} + ) GROUP BY o.OPENING_ID )""", nativeQuery = true 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 5571fb74..2314db3e 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 @@ -62,7 +62,9 @@ void getRecentOpenings_fetchNoUserPaginated_shouldSucceed() { void openingSearch_fileId_shouldSucceed() { PaginatedResult result = - openingService.openingSearch(new OpeningSearchFiltersDto( + openingService.openingSearch( + new OpeningSearchFiltersDto( + null, null, null, null, @@ -121,7 +123,8 @@ void openingSearch_fileId_shouldSucceed() { void openingSearch_orgUnit_shouldSucceed() { PaginatedResult result = - openingService.openingSearch(new OpeningSearchFiltersDto( + openingService.openingSearch( + new OpeningSearchFiltersDto( List.of("TWO"), null, null, @@ -139,7 +142,8 @@ void openingSearch_orgUnit_shouldSucceed() { null, null, null, - null + null, + null ), new PaginationParameters(0, 10) ); @@ -198,6 +202,7 @@ void openingSearch_noRecordsFound_shouldSucceed() { null, null, null, + null, "ABCD" ), new PaginationParameters(0, 10) @@ -236,6 +241,7 @@ void openingSearch_maxPageException_shouldFail() { null, null, null, + null, "FTML" ), new PaginationParameters(0, 2999) diff --git a/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx index b97ca5e5..2c848f1a 100644 --- a/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx +++ b/frontend/src/__test__/components/AutocompleteClientLocation.test.tsx @@ -38,18 +38,18 @@ describe("AutocompleteClientLocation", () => { }); it("renders both ComboBoxes and their titles", () => { - render(); + render(); expect(screen.getByText("Client")).toBeInTheDocument(); expect(screen.getByText("Location code")).toBeInTheDocument(); }); it("disables the Location ComboBox initially", () => { - render(); + render(); expect(screen.getByRole("combobox", { name: /location code/i })).toBeDisabled(); }); it("calls fetchOptions when typing in the Client ComboBox", async () => { - render(); + render(); const clientInput = screen.getByRole("combobox", { name: /client/i }); await userEvent.type(clientInput, "Client"); @@ -60,7 +60,7 @@ describe("AutocompleteClientLocation", () => { }); it("enables the Location ComboBox when a client is selected", async () => { - render(); + render(); const clientInput = screen.getByRole("combobox", { name: /client/i }); await userEvent.type(clientInput, "Client"); @@ -73,9 +73,10 @@ describe("AutocompleteClientLocation", () => { }); it("clears the location selection when the client is reset", async () => { - const mockSetValue = vi.fn(); + const mockSetLocationValue = vi.fn(); + const mockSetClientValue = vi.fn(); - render(); + render(); // Select a client const clientInput = screen.getByRole("combobox", { name: /client/i }); @@ -89,7 +90,7 @@ describe("AutocompleteClientLocation", () => { await userEvent.click(locationInput); const locationOption = screen.getByText(mockLocations[0].label); await userEvent.click(locationOption); - expect(mockSetValue).toHaveBeenCalledWith("A"); + expect(mockSetLocationValue).toHaveBeenCalledWith("A"); // Clear client selection const clientClearButton = screen.getAllByRole("button", { name: /clear/i }); @@ -97,13 +98,14 @@ describe("AutocompleteClientLocation", () => { expect(mockUpdateOptions).toHaveBeenCalledWith("locations", []); expect(mockUpdateOptions).toHaveBeenCalledWith("clients", []); - expect(mockSetValue).toHaveBeenCalledWith(null); + expect(mockSetLocationValue).toHaveBeenCalledWith(null); }); it("calls setValue when a location is selected", async () => { - const mockSetValue = vi.fn(); + const mockSetLocationValue = vi.fn(); + const mockSetClientValue = vi.fn(); - render(); + render(); // Select a client const clientInput = screen.getByRole("combobox", { name: /client/i }); @@ -117,6 +119,6 @@ describe("AutocompleteClientLocation", () => { await userEvent.click(locationInput); const locationOption = screen.getByText(mockLocations[0].label); await userEvent.click(locationOption); - expect(mockSetValue).toHaveBeenCalledWith("A"); + expect(mockSetLocationValue).toHaveBeenCalledWith("A"); }); }); diff --git a/frontend/src/__test__/contexts/OpeningsSearch.test.tsx b/frontend/src/__test__/contexts/OpeningsSearch.test.tsx index 810652a8..92473a05 100644 --- a/frontend/src/__test__/contexts/OpeningsSearch.test.tsx +++ b/frontend/src/__test__/contexts/OpeningsSearch.test.tsx @@ -30,7 +30,7 @@ describe('OpeningsSearchProvider', () => { it('should initialize with default values', () => { expect(screen.getByTestId('searchTerm').textContent).toBe(''); - expect(screen.getByTestId('startDate').textContent).toBe('null'); + expect(screen.getByTestId('startDate').textContent).toBe('undefined'); }); it('should update searchTerm', () => { @@ -43,7 +43,7 @@ describe('OpeningsSearchProvider', () => { expect(screen.getByTestId('startDate').textContent).not.toBe('null'); fireEvent.click(screen.getByTestId('clearFilters')); - expect(screen.getByTestId('startDate').textContent).toBe('null'); + expect(screen.getByTestId('startDate').textContent).toBe('undefined'); }); it('should clear individual field', () => { @@ -51,6 +51,6 @@ describe('OpeningsSearchProvider', () => { expect(screen.getByTestId('startDate').textContent).not.toBe('null'); fireEvent.click(screen.getByTestId('clearStartDate')); - expect(screen.getByTestId('startDate').textContent).toBe('null'); + expect(screen.getByTestId('startDate').textContent).toBe('undefined'); }); }); diff --git a/frontend/src/components/AutocompleteClientLocation/index.tsx b/frontend/src/components/AutocompleteClientLocation/index.tsx index 4c4f1a7f..205b338a 100644 --- a/frontend/src/components/AutocompleteClientLocation/index.tsx +++ b/frontend/src/components/AutocompleteClientLocation/index.tsx @@ -18,7 +18,8 @@ interface AutocompleteComboboxProps{ } interface AutocompleteComponentProps { - setValue: (value: string | null) => void; + setLocationValue: (value: string | null) => void; + setClientValue: (value: string | null) => void; } export interface AutocompleteComponentRefProps { @@ -67,7 +68,7 @@ export const fetchValues = async (query: string, key: string) => { }; const AutocompleteClientLocation: React.ForwardRefExoticComponent> = forwardRef( - ({ setValue }, ref) => + ({ setLocationValue, setClientValue }, ref) => { const { options, fetchOptions, updateOptions } = useAutocomplete(); const [isActive, setIsActive] = useState(false); @@ -80,16 +81,23 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent { + setClient(null); + setClient(null); + setLocation(null); + } + const selectClient = (selectedItem: AutocompleteProps) => { if (selectedItem) { setIsActive(true); setClient(selectedItem); fetchOptions(selectedItem.id, "locations"); + setClientValue(selectedItem.id); }else{ clearClient(); } @@ -127,12 +135,12 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent ({ - reset: () => setLocation(null) + reset: () => clearValues() })); // Why do we keep this then? Because of the onChange start to work, this will work as it was intended useEffect(() => { - setValue(location?.id ?? null); + setLocationValue(location?.id ?? null); }, [location]); return ( @@ -153,6 +161,7 @@ const AutocompleteClientLocation: React.ForwardRefExoticComponent setLocation(item.selectedItem)} itemToElement={(item: AutocompleteProps) => item.label} diff --git a/frontend/src/components/BarChartGrouped/index.tsx b/frontend/src/components/BarChartGrouped/index.tsx index 2eb6aa0f..886e3505 100644 --- a/frontend/src/components/BarChartGrouped/index.tsx +++ b/frontend/src/components/BarChartGrouped/index.tsx @@ -220,7 +220,6 @@ const BarChartGrouped = (): JSX.Element => { return ( -

{searchParameters}

void; // Function to be passed as a prop @@ -57,16 +58,16 @@ 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() + })); - // 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 handleFilterChange = (updatedFilters: Partial) => { setFilters({ ...filters, ...updatedFilters }); }; @@ -81,6 +82,7 @@ const AdvancedSearchDropdown: React.FC = () => { const handleCheckboxChange = (value: string, group: string) => { const selectedGroup = filters[group as keyof typeof filters] as string[]; + console.log(`selectedGroup: ${selectedGroup}`); const updatedGroup = selectedGroup.includes(value) ? selectedGroup.filter((item) => item !== value) : [...selectedGroup, value]; @@ -131,7 +133,7 @@ const AdvancedSearchDropdown: React.FC = () => { @@ -144,7 +146,7 @@ const AdvancedSearchDropdown: React.FC = () => { @@ -193,7 +195,8 @@ const AdvancedSearchDropdown: React.FC = () => { handleFilterChange({ clientLocationCode: value })} + setLocationValue={(value: string | null) => handleFilterChange({ clientLocationCode: value ?? undefined })} + setClientValue={(value: string | null) => handleFilterChange({ clientNumber: value ?? undefined })} ref={autoCompleteRef} /> @@ -348,49 +351,49 @@ const AdvancedSearchDropdown: React.FC = () => { handleCheckboxChange("AMG", "status")} /> handleCheckboxChange("AMD", "status")} /> handleCheckboxChange("APP", "status")} /> handleCheckboxChange("DFT", "status")} /> handleCheckboxChange("FG", "status")} /> handleCheckboxChange("RMD", "status")} /> handleCheckboxChange("RET", "status")} /> handleCheckboxChange("SUB", "status")} /> diff --git a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx index d2dbc063..d7c05079 100644 --- a/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx +++ b/frontend/src/components/SilvicultureSearch/Openings/OpeningsSearchBar/index.tsx @@ -19,7 +19,7 @@ const OpeningsSearchBar: React.FC = ({ const [isOpen, setIsOpen] = useState(false); const [showFilters, setShowFilters] = useState(false); const [filtersCount, setFiltersCount] = useState(0); - const [filtersList, setFiltersList] = useState(null); + const [filtersList, setFiltersList] = useState({}); const { filters, clearFilters, searchTerm, setSearchTerm } = useOpeningsSearch(); const toggleDropdown = () => { diff --git a/frontend/src/contexts/search/OpeningsSearch.tsx b/frontend/src/contexts/search/OpeningsSearch.tsx index b58f03c9..2a244d94 100644 --- a/frontend/src/contexts/search/OpeningsSearch.tsx +++ b/frontend/src/contexts/search/OpeningsSearch.tsx @@ -1,9 +1,10 @@ import React, { createContext, useState, useContext, ReactNode } from 'react'; +import { OpeningFilters,openingFiltersKeys } from '../../services/search/openings'; // Define the shape of the search filters context interface OpeningsSearchContextProps { - filters: any; //In the future, this should be a type that represents the filters - setFilters: React.Dispatch>; + filters: OpeningFilters; + setFilters: React.Dispatch>; searchTerm: string; setSearchTerm: React.Dispatch> clearFilters: () => void; @@ -16,40 +17,22 @@ const OpeningsSearchContext = createContext = ({ children }) => { - const defaultFilters = { - startDate: null as Date | null, - endDate: null as Date | null, - orgUnit: [] as string[], - category: [] as string[], - status: [] as string[], - clientAcronym: "", - clientLocationCode: "", - blockStatus: "", - cutBlock: "", - cuttingPermit: "", - timberMark: "", - dateType: null as string | null, - openingFilters: [] as string[], - blockStatuses: [] as string[], - }; - - const [filters, setFilters] = useState(defaultFilters); - const [searchTerm, setSearchTerm] = useState(""); + const [filters, setFilters] = useState({status:[], openingFilters:[]}); + 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]: undefined })); individualClearFieldFunctions[key] && individualClearFieldFunctions[key](); }; const clearFilters = () => { - setFilters(defaultFilters); - - Object.keys(defaultFilters).forEach((key) => { + setFilters({status:[], openingFilters:[]}); + openingFiltersKeys.forEach((key) => { individualClearFieldFunctions[key] && individualClearFieldFunctions[key](); }); diff --git a/frontend/src/services/search/openings.ts b/frontend/src/services/search/openings.ts index 1c04478f..1a4f4496 100644 --- a/frontend/src/services/search/openings.ts +++ b/frontend/src/services/search/openings.ts @@ -25,8 +25,31 @@ export interface OpeningFilters { page?: number; perPage?: number; clientLocationCode?: string; + clientNumber?: string; } +export const openingFiltersKeys = [ + "searchInput", + "startDate", + "endDate", + "orgUnit", + "category", + "clientAcronym", + "blockStatus", + "dateType", + "cutBlock", + "cuttingPermit", + "grossArea", + "timberMark", + "status", + "openingFilters", + "blockStatuses", + "page", + "perPage", + "clientLocationCode", + "clientNumber" +] as const; + export interface OpeningItem { openingId: number; openingNumber: string; @@ -95,6 +118,7 @@ export const fetchOpenings = async (filters: OpeningFilters): Promise => { cuttingPermitId:filters.cuttingPermit, cutBlockId: filters.cutBlock, clientLocationCode: filters.clientLocationCode, + clientNumber: filters.clientNumber, timberMark:filters.timberMark, page: filters.page && filters.page - 1, // Adjust page index (-1) perPage: filters.perPage diff --git a/frontend/src/utils/searchUtils.ts b/frontend/src/utils/searchUtils.ts index 10b0f16a..63e4f8d6 100644 --- a/frontend/src/utils/searchUtils.ts +++ b/frontend/src/utils/searchUtils.ts @@ -7,7 +7,7 @@ export const countActiveFilters = (filters: any): number => { // Check if the value is an array (e.g., for checkboxes) if (Array.isArray(value)) { count += value.filter((item) => item !== "").length; // Count non-empty values in the array - } else if (value !== null && value !== "" && value !== "Option 1") { + } else if (value && value !== "Option 1") { // Increment the count for non-default, non-null, and non-empty values count += 1; }