diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d3bf33195..4b0e3e945e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5583,17 +5583,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "18.2.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", - "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@types/react": "*" - } - }, "node_modules/@types/react-reconciler": { "version": "0.28.9", "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", @@ -27163,17 +27152,6 @@ "csstype": "^3.0.2" } }, - "@types/react-dom": { - "version": "18.2.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", - "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@types/react": "*" - } - }, "@types/react-reconciler": { "version": "0.28.9", "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", diff --git a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterFormStore.test.js b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterFormStore.test.js index 515be95466..3060d6aaea 100644 --- a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterFormStore.test.js +++ b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterFormStore.test.js @@ -2,9 +2,33 @@ import EncounterFormStore from "../../../pages/SearchPages/stores/EncounterFormS describe("EncounterFormStore", () => { let store; + let mockStorage = {}; + + beforeAll(() => { + jest + .spyOn(Storage.prototype, "getItem") + .mockImplementation((key) => mockStorage[key] || null); + jest + .spyOn(Storage.prototype, "setItem") + .mockImplementation((key, value) => { + mockStorage[key] = value ? value.toString() : ""; + }); + jest.spyOn(Storage.prototype, "removeItem").mockImplementation((key) => { + delete mockStorage[key]; + }); + jest.spyOn(Storage.prototype, "clear").mockImplementation(() => { + mockStorage = {}; + }); + }); beforeEach(() => { store = new EncounterFormStore(); + mockStorage = {}; + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); }); test("initializes with empty formFilters", () => { @@ -72,5 +96,92 @@ describe("EncounterFormStore", () => { store.resetFilters(); expect(store.formFilters).toEqual([]); + expect(store.appliedFilters).toEqual([]); + expect(window.sessionStorage.removeItem).toHaveBeenCalledWith("formData"); + }); + + test("applyFilters deep copies formFilters to appliedFilters and saves to storage", () => { + const mockFilter = { + filterId: "f1", + clause: "AND", + query: "q1", + filterKey: "k1", + path: "", + }; + store.addFilter( + mockFilter.filterId, + mockFilter.clause, + mockFilter.query, + mockFilter.filterKey, + ); + + store.applyFilters(); + + expect(store.appliedFilters).toHaveLength(1); + expect(store.appliedFilters[0]).toEqual(mockFilter); + + expect(store.appliedFilters).not.toBe(store.formFilters); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + store.FILTER_STORAGE_KEY, + JSON.stringify([mockFilter]), + ); + }); + + test("getFiltersFromStorage loads valid JSON into formFilters and appliedFilters", () => { + const mockFilter = { + filterId: "f1", + clause: "AND", + query: "q1", + filterKey: "k1", + path: "", + }; + const savedData = JSON.stringify([mockFilter]); + window.sessionStorage.setItem("formData", savedData); + + store.getFiltersFromStorage(); + + expect(window.sessionStorage.getItem).toHaveBeenCalledTimes(1); + + expect(store.formFilters).toHaveLength(1); + expect(store.appliedFilters).toHaveLength(1); + }); + + test("getFiltersFromStorage ignores strings/non-arrays and clears storage", () => { + window.sessionStorage.setItem("formData", JSON.stringify("abc")); + store.getFiltersFromStorage(); + + expect(store.formFilters).toEqual([]); + expect(window.sessionStorage.removeItem).toHaveBeenCalledWith("formData"); + }); + + test("setFiltersInSessionStorage clears the storage data when called with no data", () => { + const mockFilter = { + filterId: "f1", + clause: "AND", + query: "q1", + filterKey: "k1", + path: "", + }; + window.sessionStorage.setItem("formData", JSON.stringify(mockFilter)); + store.setFiltersInSessionStorage(); + + expect(store.formFilters).toEqual([]); + expect(window.sessionStorage.removeItem).toHaveBeenCalledWith("formData"); + }); + + test("getFiltersFromStorage handles malformed JSON gracefully and clears storage", () => { + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + window.sessionStorage.setItem("formData", "{ bad_json ]"); + + store.getFiltersFromStorage(); + + expect(store.formFilters).toEqual([]); + expect(consoleSpy).toHaveBeenCalled(); + expect(window.sessionStorage.removeItem).toHaveBeenCalledWith("formData"); + + consoleSpy.mockRestore(); }); }); diff --git a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterSearch.test.js b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterSearch.test.js index de7ebb9244..e1446f7f98 100644 --- a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterSearch.test.js +++ b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/EncounterSearch.test.js @@ -8,7 +8,7 @@ import { } from "@testing-library/react"; import EncounterSearch from "../../../pages/SearchPages/EncounterSearch"; import { MemoryRouter } from "react-router-dom"; -import * as useFilterEncountersHook from "../../../models/encounters/useFilterEncounters"; +import useFilterEncounters, * as useFilterEncountersHook from "../../../models/encounters/useFilterEncounters"; import * as useFilterEncountersWithMediaAssetsHook from "../../../models/encounters/useFilterEncountersWithMediaAssets"; import * as useEncounterSearchSchemasHook from "../../../models/encounters/useEncounterSearchSchemas"; import * as getAllSearchParams from "../../../pages/SearchPages/getAllSearchParamsAndParse"; @@ -18,6 +18,7 @@ import axios from "axios"; jest.mock("../../../pages/SearchPages/stores/EncounterFormStore", () => ({ globalEncounterFormStore: { formFilters: [], + appliedFilters: [], mediaAssetsSearchQuery: [], pageSize: 20, start: 0, @@ -32,6 +33,7 @@ jest.mock("../../../pages/SearchPages/stores/EncounterFormStore", () => ({ setGalleryLoading: jest.fn(), setLoadingAll: jest.fn(), setGalleryExhausted: jest.fn(), + getFiltersFromStorage: jest.fn(), }, })); @@ -120,6 +122,7 @@ describe("EncounterSearch", () => { jest.clearAllMocks(); globalEncounterFormStore.formFilters = []; + globalEncounterFormStore.appliedFilters = []; globalEncounterFormStore.mediaAssetsSearchQuery = []; globalEncounterFormStore.pageSize = 20; globalEncounterFormStore.start = 0; @@ -677,4 +680,42 @@ describe("EncounterSearch", () => { expect(globalEncounterFormStore.setAssetOffset).toHaveBeenCalled(); }); }); + + it("calls getFiltersFromStorage on mount if no queryID is present", () => { + const params = { + from: 0, + size: 20, + sort: "date", + sortOrder: "desc", + }; + const mockFilter = [ + { + filterId: "f1", + clause: "AND", + query: "q1", + filterKey: "k1", + path: "", + }, + ]; + + globalEncounterFormStore.appliedFilters = mockFilter; + + renderWithProviders("/search"); + + expect( + globalEncounterFormStore.getFiltersFromStorage, + ).toHaveBeenCalledTimes(1); + expect(useFilterEncounters).toHaveBeenCalledWith({ + queries: mockFilter, + params: params, + }); + }); + + it("does NOT call getFiltersFromStorage if a queryID is present in the URL", () => { + renderWithProviders("/search?searchQueryId=abc123"); + + expect( + globalEncounterFormStore.getFiltersFromStorage, + ).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/FilterPanel.jsx b/frontend/src/components/FilterPanel.jsx index 5da661a54f..2920021074 100644 --- a/frontend/src/components/FilterPanel.jsx +++ b/frontend/src/components/FilterPanel.jsx @@ -15,7 +15,6 @@ export default function FilterPanel({ style = {}, handleSearch = () => {}, refetch = () => {}, - setTempFormFilters = () => {}, store, }) { const { data } = useSiteSettings(); @@ -143,16 +142,12 @@ export default function FilterPanel({ backgroundColor={theme.primaryColors.primary700} borderColor={theme.primaryColors.primary700} onClick={() => { - setTempFormFilters([...store.formFilters]); refetch().then(({ data }) => { console.log("Refetched data:", data); }); store.resetGallery(); setSearchParams(new URLSearchParams()); - sessionStorage.setItem( - "formData", - JSON.stringify(store.formFilters), - ); + store.applyFilters(); setFilterPanel(false); handleSearch(); store.setActiveStep(0); diff --git a/frontend/src/components/filterFields/SideBar.jsx b/frontend/src/components/filterFields/SideBar.jsx index d09c157edb..1b13a29ddb 100644 --- a/frontend/src/components/filterFields/SideBar.jsx +++ b/frontend/src/components/filterFields/SideBar.jsx @@ -9,7 +9,7 @@ import { useSearchParams } from "react-router-dom"; import { observer } from "mobx-react-lite"; const Sidebar = observer( - ({ setFilterPanel, searchQueryId, queryID, tempFormFilters = [], store }) => { + ({ setFilterPanel, searchQueryId, queryID = false, store }) => { const theme = React.useContext(ThemeContext); const [show, setShow] = useState(false); const sidebarWidth = 400; @@ -18,14 +18,17 @@ const Sidebar = observer( const handleShow = () => setShow(true); const [, setSearchParams] = useSearchParams(); - const num = () => (queryID ? 1 : tempFormFilters.length); + const num = () => (queryID ? 1 : store.appliedFilters.length); const handleCopy = () => { + // When viewing a result loaded from a shared query ID, copy that ID; + // otherwise copy the ID freshly generated for the current filter search. + const idToCopy = queryID || searchQueryId; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard - .writeText(searchQueryId) + .writeText(idToCopy) .then(() => { - alert(`Query ID: ${searchQueryId} copied to clipboard!`); + alert(`Query ID: ${idToCopy} copied to clipboard!`); }) .catch((err) => { console.error("Failed to copy text: ", err); @@ -111,7 +114,7 @@ const Sidebar = observer( ) : (
- {tempFormFilters.map((filter, index) => ( + {store.appliedFilters.map((filter, index) => ( {filter} ))}
@@ -135,21 +138,25 @@ const Sidebar = observer( > - { - setFilterPanel(true); - handleClose(); - }} - backgroundColor={theme.primaryColors.primary700} - borderColor={theme.primaryColors.primary700} - color="white" - noArrow={true} - > - - + + {!queryID && ( + { + setFilterPanel(true); + handleClose(); + }} + backgroundColor={theme.primaryColors.primary700} + borderColor={theme.primaryColors.primary700} + color="white" + noArrow={true} + > + + + )} + { const [encounterSortOrder, setEncounterSortOrder] = useState("desc"); const [searchIdSortName, setSearchIdSortName] = useState("date"); const [searchIdSortOrder, setSearchIdSortOrder] = useState("desc"); - const [tempFormFilters, setTempFormFilters] = useState([]); const [exportModalOpen, setExportModalOpen] = useState(false); useEffect(() => { - helperFunction( - searchParams, - store, - setFilterPanel, - setTempFormFilters, - encounterData, - ); + helperFunction(searchParams, store, setFilterPanel, encounterData); }, [searchParams]); + useEffect(() => { + if (!queryID) { + store.getFiltersFromStorage(); + } + }, [queryID, store]); + useEffect(() => { if (!queryID) { setEncounterSortName(sortname); @@ -70,7 +69,7 @@ const EncounterSearch = observer(() => { loading, refetch, } = useFilterEncounters({ - queries: store.formFilters, + queries: store.appliedFilters, params: { sort: encounterSortName, sortOrder: encounterSortOrder, @@ -260,6 +259,10 @@ const EncounterSearch = observer(() => { }) .catch((error) => { console.error("Error fetching search data:", error); + alert(`Query ID: ${queryID} could not be found!`); + setQueryID(false); + setSearchParams(new URLSearchParams()); + setFilterPanel(true); }); } }, [ @@ -309,7 +312,6 @@ const EncounterSearch = observer(() => { handleSearch={handleSearch} setQueryID={setQueryID} refetch={refetch} - setTempFormFilters={setTempFormFilters} store={store} /> { {}, -) => { +const helperFunction = async (searchParams, store, setFilterPanel) => { const params = Object.fromEntries(searchParams.entries()) || {}; if (Object.keys(params).length === 0) { return; } + let didAddFilter = false; + for (const [key, _] of Object.entries(params)) { if (key === "username") { store.addFilter( @@ -21,6 +18,7 @@ const helperFunction = async ( }, "Assigned User", ); + didAddFilter = true; } if (key === "state") { store.addFilter( @@ -33,9 +31,10 @@ const helperFunction = async ( }, "Encounter State", ); + didAddFilter = true; } if (key === "searchQueryId") { - store.formFilters = JSON.parse(sessionStorage.getItem("formData")) || []; + store.getFiltersFromStorage(); } if (key === "individualIDExact") { store.addFilter( @@ -48,10 +47,14 @@ const helperFunction = async ( }, "Individual ID", ); + didAddFilter = true; } } - setTempFormFilters([...store.formFilters]); - setFilterPanel(false); + if (didAddFilter) { + store.applyFilters(); + setFilterPanel(false); + } + return; }; diff --git a/frontend/src/pages/SearchPages/stores/EncounterFormStore.js b/frontend/src/pages/SearchPages/stores/EncounterFormStore.js index e5b08ed995..26b4ad2efe 100644 --- a/frontend/src/pages/SearchPages/stores/EncounterFormStore.js +++ b/frontend/src/pages/SearchPages/stores/EncounterFormStore.js @@ -6,6 +6,7 @@ import { action } from "mobx"; class EncounterFormStore { _formFilters; + _appliedFilters; _activeStep = 0; _siteSettingsData = null; @@ -33,8 +34,11 @@ class EncounterFormStore { imageModalStore; + FILTER_STORAGE_KEY = "formData"; + constructor() { this.formFilters = []; + this.appliedFilters = []; this.imageModalStore = new ImageModalStore(this); makeAutoObservable( @@ -67,6 +71,13 @@ class EncounterFormStore { this._formFilters = newFilters; } + get appliedFilters() { + return this._appliedFilters; + } + set appliedFilters(filters) { + this._appliedFilters = filters; + } + get activeStep() { return this._activeStep; } @@ -166,7 +177,7 @@ class EncounterFormStore { filterKey: "Number Media Assets", path: "", }; - const base = this.formFilters || []; + const base = this.appliedFilters || []; const has = base.some((f) => f.filterId === "numberMediaAssets"); if (!has) { return [...base, filterOnMediaAssets]; @@ -238,6 +249,10 @@ class EncounterFormStore { } } + setFormFilters(filters) { + this._formFilters = filters; + } + removeFilter(filterId) { this.formFilters = this.formFilters.filter((f) => f.filterId !== filterId); } @@ -248,8 +263,44 @@ class EncounterFormStore { ); } + setFiltersInSessionStorage() { + if (this.appliedFilters.length > 0) { + sessionStorage.setItem( + this.FILTER_STORAGE_KEY, + JSON.stringify(this.appliedFilters), + ); + } else { + sessionStorage.removeItem(this.FILTER_STORAGE_KEY); + } + } + + applyFilters() { + this.appliedFilters = toJS(this.formFilters); + this.setFiltersInSessionStorage(); + } + + getFiltersFromStorage() { + const savedJson = sessionStorage.getItem(this.FILTER_STORAGE_KEY); + if (savedJson) { + try { + const parsed = JSON.parse(savedJson); + if (Array.isArray(parsed) && parsed.length > 0) { + this.formFilters = parsed; + this.appliedFilters = parsed; + } else { + sessionStorage.removeItem(this.FILTER_STORAGE_KEY); + } + } catch (e) { + console.error("Failed to load filters:", e); + sessionStorage.removeItem(this.FILTER_STORAGE_KEY); + } + } + } + resetFilters() { this.formFilters = []; + this.appliedFilters = []; + sessionStorage.removeItem(this.FILTER_STORAGE_KEY); } async addEncountersToProject() {