diff --git a/react-app/src/components/Layout/index.tsx b/react-app/src/components/Layout/index.tsx index c192eea81..2795fe4d1 100644 --- a/react-app/src/components/Layout/index.tsx +++ b/react-app/src/components/Layout/index.tsx @@ -13,6 +13,7 @@ import { ScrollToTop, SimplePageContainer, UserPrompt, Banner } from "@/componen import { isFaqPage, isProd } from "@/utils"; import MMDLAlertBanner from "@/components/Banner/MMDLSpaBanner"; import { UserRoles } from "shared-types"; +import { removeItemLocalStorage } from "@/hooks/useLocalStorage"; /** * Custom hook that generates a list of navigation links based on the user's status and whether the current page is the FAQ page. * @@ -81,6 +82,7 @@ const UserDropdownMenu = () => { }; const handleLogout = async () => { + removeItemLocalStorage(); await Auth.signOut(); }; diff --git a/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.tsx b/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.tsx index ce3a06e64..8811a35b6 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Chipbar/index.tsx @@ -102,12 +102,13 @@ export const FilterChips: FC = () => { }); }; - const handleChipClick = () => + const handleChipClick = () => { url.onSet((s) => ({ ...s, filters: [], pagination: { ...s.pagination, number: 0 }, })); + }; return (
- +
+ +
{ return (value: opensearch.FilterValue) => { setFilters((state) => { const updateState = { ...state, [field]: { ...state[field], value } }; + // find all filter values to update const updateFilters = Object.values(updateState).filter((FIL) => { if (FIL.type === "terms") { const value = FIL.value as string[]; @@ -92,6 +93,7 @@ export const useFilterDrawer = () => { return true; }); + // this changes the tanstack query; which is used to query the data url.onSet((state) => ({ ...state, filters: updateFilters, @@ -107,24 +109,26 @@ export const useFilterDrawer = () => { setAccordionValues(updateAccordion); }; - const onFilterReset = () => + const onFilterReset = () => { url.onSet((s) => ({ ...s, filters: [], pagination: { ...s.pagination, number: 0 }, })); + }; const filtersApplied = checkMultiFilter(url.state.filters, 1); - // update initial filter state + accordion default open items + // update filter display based on url query useEffect(() => { if (!drawer.drawerOpen) return; const updateAccordions = [...accordionValues] as any[]; - - setFilters((s) => { - return Object.entries(s).reduce((STATE, [KEY, VAL]) => { + setFilters((currentFilters) => { + // Set the new filters state based on the current filter data + return Object.entries(currentFilters).reduce((STATE, [KEY, VAL]) => { const updateFilter = url.state.filters.find((FIL) => FIL.field === KEY); + // Determine the new value for the filter based on the URL state const value = (() => { if (updateFilter) { updateAccordions.push(KEY); @@ -135,12 +139,15 @@ export const useFilterDrawer = () => { return { gte: undefined, lte: undefined } as opensearch.RangeValue; })(); + // Update the state with the new value for this filter STATE[KEY] = { ...VAL, value }; + return STATE; }, {} as any); }); setAccordionValues(updateAccordions); - }, [url.state.filters, drawer.drawerOpen]); + // accordionValues is intensionally left out of this dendency array because it could cause looping + }, [url.state.filters, drawer.drawerOpen, setFilters]); const aggs = useMemo(() => { return Object.entries(_aggs || {}).reduce( @@ -157,7 +164,7 @@ export const useFilterDrawer = () => { }, {} as Record, ); - }, [_aggs]); + }, [_aggs, labelMap]); return { aggs, diff --git a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx index 265e9e279..1f864af80 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.test.tsx @@ -279,6 +279,7 @@ describe("OsFilterDrawer", () => { const chip = screen.queryByLabelText("CHIP SPA"); expect(chip).toBeInTheDocument(); + expect(chip.getAttribute("data-state")).toEqual("unchecked"); const med = screen.queryByLabelText("Medicaid SPA"); diff --git a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.tsx b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.tsx index 5fc026e2e..89bba2df4 100644 --- a/react-app/src/components/Opensearch/main/Filtering/Drawer/index.tsx +++ b/react-app/src/components/Opensearch/main/Filtering/Drawer/index.tsx @@ -17,10 +17,16 @@ import * as F from "./Filterable"; import { useFilterDrawer } from "./hooks"; export const OsFilterDrawer = () => { - const hook = useFilterDrawer(); + // how filterDrawerHook looks + // filterDrawerHook: {accordionValues: {}, aggs: filter opts, drawer: drawer state, fitlers: name of filters & status's, filtersApplied: boolean, + // onFilterChange, onFilterReset} + const filterDrawerHook = useFilterDrawer(); return ( - + - {Object.values(hook.filters).map((PK) => ( - - {PK.label} + {Object.values(filterDrawerHook.filters).map((filter) => ( + + {filter.label} - {PK.component === "multiSelect" && ( + {filter.component === "multiSelect" && ( )} - {PK.component === "multiCheck" && ( + {filter.component === "multiCheck" && ( )} - {PK.component === "dateRange" && ( + {filter.component === "dateRange" && ( )} - {PK.component === "boolean" && ( + {filter.component === "boolean" && ( )} diff --git a/react-app/src/components/Opensearch/main/index.test.tsx b/react-app/src/components/Opensearch/main/index.test.tsx index 1fb6e2ad5..ffb4b2d90 100644 --- a/react-app/src/components/Opensearch/main/index.test.tsx +++ b/react-app/src/components/Opensearch/main/index.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, beforeEach } from "vitest"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { opensearch } from "shared-types"; @@ -14,6 +14,7 @@ import { verifyChips, verifyPagination, EMPTY_HITS, + Storage, } from "@/utils/test-helpers"; import { OsMainView, OsTableColumn } from "@/components"; @@ -27,6 +28,8 @@ const verifyTable = (recordCount: number) => { }; describe("OsMainView", () => { + global.localStorage = new Storage(); + const setup = ( columns: OsTableColumn[], hits: opensearch.Hits, @@ -48,6 +51,9 @@ describe("OsMainView", () => { }; }; + beforeEach(() => { + global.localStorage.clear(); + }); describe("SPAs", () => { it("should display without filters", async () => { const spaHits = getFilteredHits(["CHIP SPA", "Medicaid SPA"]); @@ -286,4 +292,42 @@ describe("OsMainView", () => { verifyPagination(recordCount); }); }); + + describe("Local Storage to display Columns", () => { + it("should store hidden column in local storage", async () => { + const spaHits = getFilteredHits(["CHIP SPA", "Medicaid SPA"]); + setup( + [...DEFAULT_COLUMNS, HIDDEN_COLUMN], + spaHits, + getDashboardQueryString({ + filters: DEFAULT_FILTERS, + tab: "spas", + }), + ); + expect(global.localStorage.getItem("osColumns")).toBe(JSON.stringify(["origin.keyword"])); + }); + + it("should load hidden columns based on local storage", async () => { + const spaHits = getFilteredHits(["CHIP SPA", "Medicaid SPA"]); + expect(global.localStorage.setItem("osColumns", JSON.stringify(["authority.keyword"]))); + const { user } = setup( + [...DEFAULT_COLUMNS, HIDDEN_COLUMN], + spaHits, + getDashboardQueryString({ + filters: DEFAULT_FILTERS, + tab: "spas", + }), + ); + + expect(screen.queryByRole("dialog")).toBeNull(); + await user.click(screen.queryByRole("button", { name: "Columns (1 hidden)" })); + const columns = screen.queryByRole("dialog"); + expect(within(columns).getByText("Submission Source")).toBeInTheDocument(); + expect(within(columns).getByText("Submission Source").parentElement).toHaveClass( + "text-gray-800", + ); + expect(within(columns).getByText("Authority")).toBeInTheDocument(); + expect(within(columns).getByText("Authority").parentElement).toHaveClass("text-gray-400"); + }); + }); }); diff --git a/react-app/src/components/Opensearch/main/index.tsx b/react-app/src/components/Opensearch/main/index.tsx index f4c7b809a..76193a61f 100644 --- a/react-app/src/components/Opensearch/main/index.tsx +++ b/react-app/src/components/Opensearch/main/index.tsx @@ -6,6 +6,16 @@ import { useOsContext } from "./Provider"; import { useOsUrl } from "./useOpensearch"; import { OsTableColumn } from "./types"; import { FilterChips } from "./Filtering"; +import { useLocalStorage } from "@/hooks/useLocalStorage"; + +const createLSColumns = (columns: OsTableColumn[]): string[] => { + const columnsVisalbe = columns.filter((col) => col.hidden); + const columnFields = columnsVisalbe.reduce((acc, curr) => { + if (curr.field) acc.push(curr.field); + return acc; + }, []); + return columnFields; +}; export const OsMainView: FC<{ columns: OsTableColumn[]; @@ -13,15 +23,24 @@ export const OsMainView: FC<{ const context = useOsContext(); const url = useOsUrl(); + const [localStorageCol, setLocalStorageCol] = useLocalStorage( + "osColumns", + createLSColumns(props.columns), + ); + const [osColumns, setOsColumns] = useState( props.columns.map((COL) => ({ ...COL, - hidden: !!COL?.hidden, + hidden: localStorageCol.includes(COL.field), locked: COL?.locked ?? false, })), ); const onToggle = (field: string) => { + if (localStorageCol.includes(field)) + setLocalStorageCol(() => localStorageCol.filter((x) => x != field)); + else setLocalStorageCol([...localStorageCol, field]); + setOsColumns((state) => { return state?.map((S) => { if (S.field !== field) return S; diff --git a/react-app/src/components/TimeoutModal/index.tsx b/react-app/src/components/TimeoutModal/index.tsx index bf9a9e9bb..491802ffb 100644 --- a/react-app/src/components/TimeoutModal/index.tsx +++ b/react-app/src/components/TimeoutModal/index.tsx @@ -13,6 +13,7 @@ import { Auth } from "aws-amplify"; import { intervalToDuration } from "date-fns"; import pluralize from "pluralize"; import { useEffect, useState } from "react"; +import { removeItemLocalStorage } from "@/hooks/useLocalStorage"; const TWENTY_MINS_IN_MILS = 1000 * 60 * 20; const TEN_MINS_IN_MILS = 60 * 10; @@ -30,6 +31,7 @@ export const TimeoutModal = () => { const onLogOut = () => { setIsModalOpen(false); Auth.signOut(); + removeItemLocalStorage(); }; const onExtendSession = () => { diff --git a/react-app/src/features/dashboard/Lists/spas/index.test.tsx b/react-app/src/features/dashboard/Lists/spas/index.test.tsx index 1a2256872..b1d0d27bc 100644 --- a/react-app/src/features/dashboard/Lists/spas/index.test.tsx +++ b/react-app/src/features/dashboard/Lists/spas/index.test.tsx @@ -24,6 +24,7 @@ import { APPROVED_ITEM_EXPORT, BLANK_ITEM, BLANK_ITEM_EXPORT, + Storage, } from "@/utils/test-helpers"; import { TEST_STATE_SUBMITTER_USER, @@ -227,6 +228,7 @@ const verifyRow = ( describe("SpasList", () => { const setup = async (hits: opensearch.Hits, queryString: string) => { + global.localStorage = new Storage(); const user = userEvent.setup(); const rendered = renderDashboard( , diff --git a/react-app/src/features/dashboard/Lists/waivers/index.test.tsx b/react-app/src/features/dashboard/Lists/waivers/index.test.tsx index a76f4098e..976bd0953 100644 --- a/react-app/src/features/dashboard/Lists/waivers/index.test.tsx +++ b/react-app/src/features/dashboard/Lists/waivers/index.test.tsx @@ -25,6 +25,7 @@ import { APPROVED_ITEM_EXPORT, BLANK_ITEM, BLANK_ITEM_EXPORT, + Storage, } from "@/utils/test-helpers"; import { TEST_STATE_SUBMITTER_USER, @@ -249,6 +250,7 @@ const verifyRow = ( describe("WaiversList", () => { const setup = async (hits: opensearch.Hits, queryString: string) => { + global.localStorage = new Storage(); const user = userEvent.setup(); const rendered = renderDashboard( , diff --git a/react-app/src/features/dashboard/index.tsx b/react-app/src/features/dashboard/index.tsx index 27f40b776..b26703722 100644 --- a/react-app/src/features/dashboard/index.tsx +++ b/react-app/src/features/dashboard/index.tsx @@ -17,6 +17,7 @@ import { } from "@/components"; import { isStateUser } from "shared-utils"; import { Link, Navigate, redirect } from "react-router"; +import { removeItemLocalStorage } from "@/hooks/useLocalStorage"; const loader = (queryClient: QueryClient) => { return async () => { @@ -85,7 +86,8 @@ export const Dashboard = () => {
+ onValueChange={(tab) => { + removeItemLocalStorage("osColumns"); osData.onSet( (s) => ({ ...s, @@ -94,8 +96,8 @@ export const Dashboard = () => { search: "", }), true, - ) - } + ); + }} >
diff --git a/react-app/src/hooks/UseMediaQuery.test.tsx b/react-app/src/hooks/UseMediaQuery.test.tsx index 69b727361..152a12b5a 100644 --- a/react-app/src/hooks/UseMediaQuery.test.tsx +++ b/react-app/src/hooks/UseMediaQuery.test.tsx @@ -5,7 +5,7 @@ import { useMediaQuery } from "./useMediaQuery"; describe("UseMediaQuery", () => { // https://vitest.dev/api/vi.html#vi-stubglobal it("returns false if viewport doesn't match media query", () => { - globalThis.window.matchMedia = vi.fn().mockImplementation(() => ({ + global.window.matchMedia = vi.fn().mockImplementation(() => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {}, diff --git a/react-app/src/hooks/useLocalStorage.test.tsx b/react-app/src/hooks/useLocalStorage.test.tsx new file mode 100644 index 000000000..f2dd6cfb8 --- /dev/null +++ b/react-app/src/hooks/useLocalStorage.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "vitest"; +import { useLocalStorage, removeItemLocalStorage } from "./useLocalStorage"; +import { Storage } from "@/utils/test-helpers"; + +describe("UseLocalStorage", () => { + global.lobalStorage = new Storage(); + + afterEach(() => { + global.lobalStorage.clear(); + }); + it("sets local storage with key osQuery to null", () => { + const { result } = renderHook(() => useLocalStorage("osQuery", null)); + expect(global.localStorage.getItem("osQuery")).toBe(null); + expect(result.current[0]).toEqual(null); + }); + + it("sets local storage with key osColumns to hidden columns", () => { + const { result } = renderHook(() => useLocalStorage("osColumns", ["test"])); + expect(global.localStorage.getItem("osColumns")).toBe(JSON.stringify(["test"])); + expect(result.current[0]).toEqual(["test"]); + }); + + it("holds values on rerender", () => { + const { result, rerender } = renderHook(() => useLocalStorage("osColumns", ["test"])); + expect(result.current[0]).toEqual(["test"]); + rerender(); + expect(result.current[0]).toEqual(["test"]); + }); + + it("clears storage after removeItemLocalStorage is called", () => { + const { result } = renderHook(() => useLocalStorage("osColumns", ["test"])); + expect(global.localStorage.getItem("osColumns")).toBe(JSON.stringify(["test"])); + expect(result.current[0]).toEqual(["test"]); + removeItemLocalStorage(); + expect(global.localStorage.getItem("osColumns")).toBe(null); + }); +}); diff --git a/react-app/src/hooks/useLocalStorage.ts b/react-app/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..ea62abfb2 --- /dev/null +++ b/react-app/src/hooks/useLocalStorage.ts @@ -0,0 +1,45 @@ +import { useState, useEffect } from "react"; + +interface GenericInitialValue { + [index: number]: unknown; +} +type keyType = "osQuery" | "osColumns"; + +export const removeItemLocalStorage = (key?: keyType) => { + if (key) window.localStorage.removeItem(key); + else { + window.localStorage.removeItem("osQuery"); + window.localStorage.removeItem("osColumns"); + } +}; + +export const useLocalStorage = (key: keyType, initialValue: GenericInitialValue) => { + const [storedValue, setStoredValue] = useState(() => { + if (typeof window === "undefined") return initialValue; + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (e) { + console.log("Error while getting local storage: ", e); + } + }); + + useEffect(() => { + const updateLocalStorage = () => { + if (typeof window !== "undefined") { + if (storedValue === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } + }; + try { + updateLocalStorage(); + } catch (e) { + console.log("Error setting local storage", e); + } + }, [key, storedValue]); + + return [storedValue, setStoredValue]; +}; diff --git a/react-app/src/hooks/useParams.ts b/react-app/src/hooks/useParams.ts index 87be452e1..11fa2942f 100644 --- a/react-app/src/hooks/useParams.ts +++ b/react-app/src/hooks/useParams.ts @@ -1,6 +1,8 @@ import LZ from "lz-string"; import { useMemo } from "react"; import { useSearchParams } from "react-router"; +import { useLocalStorage } from "./useLocalStorage"; + /** * useLzQuery syncs a url query parameter with a given state. * LZ is a library which can compresses JSON into a uri string @@ -8,21 +10,27 @@ import { useSearchParams } from "react-router"; */ export const useLzUrl = (props: { key: string; initValue?: T }) => { const [params, setParams] = useSearchParams(); + const [query, setQuery] = useLocalStorage("osQuery", null); const queryString = params.get(props.key) || ""; const state: T = useMemo(() => { - if (!queryString) return props.initValue; + if (!queryString) { + if (query) return JSON.parse(query); + return props.initValue; + } const decompress = LZ.decompressFromEncodedURIComponent(queryString); if (!decompress) return props.initValue; try { + setQuery(decompress); return JSON.parse(decompress); } catch { return props.initValue; } - }, [queryString]); + // adding props.initValue causes this to loop + }, [queryString, query, setQuery]); const onSet = (arg: (arg: T) => T | T, shouldIsolate?: boolean) => { const val = (() => { diff --git a/react-app/src/utils/test-helpers/index.tsx b/react-app/src/utils/test-helpers/index.tsx index 52e9b37d5..f9df9b6cf 100644 --- a/react-app/src/utils/test-helpers/index.tsx +++ b/react-app/src/utils/test-helpers/index.tsx @@ -1,5 +1,6 @@ export * from "./dashboard"; export * from "./render"; +export * from "./mockStorage"; export * from "./renderForm"; export * from "./skipCleanup"; export * from "./uploadFiles"; diff --git a/react-app/src/utils/test-helpers/mockStorage.ts b/react-app/src/utils/test-helpers/mockStorage.ts new file mode 100644 index 000000000..a6d412af3 --- /dev/null +++ b/react-app/src/utils/test-helpers/mockStorage.ts @@ -0,0 +1,66 @@ +// adapted from https://github.com/mswjs/local-storage-polyfill/blob/main/src/index.ts + +export const STORAGE_MAP_SYMBOL = Symbol("map"); + +export class Storage { + private [STORAGE_MAP_SYMBOL]: Map; + + constructor() { + this[STORAGE_MAP_SYMBOL] = new Map(); + } + + /** + * Returns the number of key/value pairs. + */ + get length(): number { + return this[STORAGE_MAP_SYMBOL].size; + } + + /** + * Returns the current value associated with the given key, or null if the given key does not exist. + */ + public getItem(key: Key): string | null { + console.log(`getting item for key: ${key} value: ${this[STORAGE_MAP_SYMBOL].get(key) || null}`); + return this[STORAGE_MAP_SYMBOL].get(key) || null; + } + + /** + * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. + */ + public key(index: number): string | null { + console.log(`looking for key index: ${index}`); + const keys = Array.from(this[STORAGE_MAP_SYMBOL].keys()); + return keys[index] || null; + } + + /** + * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. + * + * Unlike the browser implementation, does not throw when the value cannot be set + * because no such policy can be configured in Node.js. Does not emit the storage + * event on Window because there's no window. + */ + public setItem(key: Key, value: string): void { + console.log(`setting ${key}: ${value}`); + this[STORAGE_MAP_SYMBOL].set(key, value); + } + + /** + * Removes the key/value pair with the given key, if a key/value pair with the given key exists. + * + * Does not dispatch the storage event on Window. + */ + public removeItem(key: Key): void { + console.log(`removing item for key: ${key}`); + this[STORAGE_MAP_SYMBOL].delete(key); + } + + /** + * Removes all key/value pairs, if there are any. + * + * Does not dispatch the storage event on Window. + */ + public clear(): void { + this[STORAGE_MAP_SYMBOL].clear(); + } +}