Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ui): add local storage to filters #1113

Merged
merged 20 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ded6a2
renamed some variables; added some comments
andieswift Jan 29, 2025
83a3f01
added local storage/fixed weird UI bug
andieswift Feb 4, 2025
4680a30
Merge branch 'main' into filter-state
andieswift Feb 4, 2025
6a073d6
mocked local storage
andieswift Feb 4, 2025
7016886
Merge branch 'main' into filter-state
andieswift Feb 4, 2025
8c25cc1
updated parsing logic to be after getting storage/removed comment
andieswift Feb 4, 2025
b07bfe4
added tests for hooks useEffect where local storage logic was added
andieswift Feb 4, 2025
7c8992c
removed elint ignore
andieswift Feb 4, 2025
318c9d5
removed ignore lint but added comments of why i didnt change it
andieswift Feb 4, 2025
61402c9
removed filter local storage; replaced wiht query local storage & col…
andieswift Feb 6, 2025
f85485e
switching the order of clearing cache
andieswift Feb 6, 2025
4a5b11d
added some typing/renamed
andieswift Feb 6, 2025
f703158
removing vitest file & fixing a new bug i made lol
andieswift Feb 6, 2025
f6ef122
actually you cannot clear all cache right before logining out, so I u…
andieswift Feb 6, 2025
24ffb73
Merge branch 'main' into filter-state
andieswift Feb 6, 2025
4fe66ac
added tiffs local storage class to affected files
andieswift Feb 6, 2025
d92663c
added some tests based on code added
andieswift Feb 6, 2025
29238a6
Merge branch 'main' into filter-state
andieswift Feb 6, 2025
8b4d237
Merge branch 'main' into filter-state
andieswift Feb 6, 2025
a13f4b2
reset column storage on tab change
andieswift Feb 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions react-app/src/components/Layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { FILTER_STORAGE_KEY } from "../Opensearch/main/Filtering/Drawer";
/**
* Custom hook that generates a list of navigation links based on the user's status and whether the current page is the FAQ page.
*
Expand Down Expand Up @@ -81,6 +82,8 @@ const UserDropdownMenu = () => {
};

const handleLogout = async () => {
// removing any filtering user may have on the dashboard
localStorage.removeItem(FILTER_STORAGE_KEY);
await Auth.signOut();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useFilterDrawerContext } from "../FilterProvider";
import { useLabelMapping } from "@/hooks";
import { UTCDate } from "@date-fns/utc";
import { format } from "date-fns";
import { FILTER_STORAGE_KEY } from "../Drawer";

export const DATE_FORMAT = "M/d/yyyy";
export interface RenderProp {
Expand Down Expand Up @@ -94,6 +95,11 @@ export const FilterChips: FC = () => {
filters = filters.filter((f) => f.field !== filter.field);
}

localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({ filters: filters, tab: url.state.tab }),
);

return {
...s,
filters: filters,
Expand All @@ -102,12 +108,14 @@ export const FilterChips: FC = () => {
});
};

const handleChipClick = () =>
const handleChipClick = () => {
localStorage.removeItem(FILTER_STORAGE_KEY);
url.onSet((s) => ({
...s,
filters: [],
pagination: { ...s.pagination, number: 0 },
}));
};

return (
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,17 +125,19 @@ export function FilterableDateRange({ value, onChange, ...props }: Props) {
side="left"
sideOffset={1}
>
<Calendar
disabled={disableDates}
initialFocus
mode="range"
defaultMonth={selectedDate?.from}
selected={selectedDate}
numberOfMonths={2}
className="bg-white"
onSelect={onSelect}
{...props}
/>
<div className="hidden lg:block">
<Calendar
disabled={disableDates}
initialFocus
mode="range"
defaultMonth={selectedDate?.from}
selected={selectedDate}
numberOfMonths={2}
className="bg-white"
onSelect={onSelect}
{...props}
/>
</div>
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is old code that was removed I added back for this ticket: https://jiraent.cms.gov/browse/OY2-32770

<div className="lg:hidden flex align-center">
<Calendar
disabled={disableDates}
Expand Down
51 changes: 45 additions & 6 deletions react-app/src/components/Opensearch/main/Filtering/Drawer/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

type FilterGroup = Partial<Record<opensearch.main.Field, C.DrawerFilterableGroup>>;

export const FILTER_STORAGE_KEY = "osFilter";

export const useFilterState = () => {
const { data: user } = useGetUser();
const url = useOsUrl();
Expand Down Expand Up @@ -74,6 +76,7 @@
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[];
Expand All @@ -91,7 +94,12 @@

return true;
});
localStorage.setItem(
FILTER_STORAGE_KEY,
JSON.stringify({ filters: updateFilters, tab: url.state.tab }),
);

// this changes the tanstack query; which is used to query the data
url.onSet((state) => ({
...state,
filters: updateFilters,
Expand All @@ -107,24 +115,53 @@
setAccordionValues(updateAccordion);
};

const onFilterReset = () =>
const onFilterReset = () => {
url.onSet((s) => ({
...s,
filters: [],
pagination: { ...s.pagination, number: 0 },
}));
localStorage.removeItem(FILTER_STORAGE_KEY);
};

const filtersApplied = checkMultiFilter(url.state.filters, 1);

// update initial filter state + accordion default open items
// on filter initialization
useEffect(() => {
// check if any filters where saved in storage
const filterStorage: string | null = localStorage.getItem(FILTER_STORAGE_KEY);
if (!filterStorage) return;

const filterState: { filters: C.DrawerFilterableGroup[]; tab: string } =
JSON.parse(filterStorage);

// we should delete the local storage if it doesn't match current tab
if (filterState.tab !== url.state.tab) {
localStorage.removeItem(FILTER_STORAGE_KEY);
return;
}

// this changes the tanstack query; which is used to query the data
url.onSet((state) => ({
...state,
filters: filterState.filters,
pagination: { ...state.pagination, number: 0 },
}));

// eslint-disable-next-line
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this comment

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a very intensional comment, this useEffect should only run on first render. Which is why the use effect array is empty and should not be changed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What value does it expect?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is expecting url

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay Brian talked me into removing the comment, but I have added my own comment of why I have not put the values the linter suggests in the dependency array. Just so if someone wants to address these lint errors they can get a better idea of the whole picture.

This sound cool to you?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's a solid compromise. Will approve

}, []);
Copy link
Collaborator

@asharonbaltazar asharonbaltazar Feb 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's time to have a library do this for us. You can just make a file called useLocalStorage and copy some code off the internet (with acc).

I've seen @daniel-belcher's work with local storage and we keep repeating the same logic. The logic is complex and it doesn't help that these components now look so much more daunting than they need to be

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm with it, but can we get with Erika to make a ticket, and point out the places we want to utilize this library/utility. Sounds like here, and then the code that Daniel is working with. All for it, I think it should be worked separately though only because I would like to whole sale replace it for Daniels code as well and in doing that we would then have to have it retested.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so I am not going to move this work to a hook rn based off this comment^


// 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]) => {
const updateAccordions = [...accordionValues] as any[];
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);
Expand All @@ -135,12 +172,14 @@
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]);

Check warning on line 182 in react-app/src/components/Opensearch/main/Filtering/Drawer/hooks.ts

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'accordionValues' and 'setFilters'. Either include them or remove the dependency array

const aggs = useMemo(() => {
return Object.entries(_aggs || {}).reduce(
Expand All @@ -157,7 +196,7 @@
},
{} as Record<opensearch.main.Field, { label: string; value: string }[]>,
);
}, [_aggs]);
}, [_aggs, labelMap]);

return {
aggs,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OsFilterDrawer } from "./index";
import { opensearch } from "shared-types";
import { renderFilterDrawer, getDashboardQueryString } from "@/utils/test-helpers";
import { FILTER_STORAGE_KEY } from "./index";

const setup = (
filters: opensearch.Filterable<opensearch.main.Field>[],
Expand All @@ -21,6 +22,23 @@ const setup = (
};

describe("OsFilterDrawer", () => {
beforeEach(() => {
global.localStorage = {
setItem: vi.fn(),
getItem: vi.fn(),
removeItem: vi.fn(),
key: vi.fn(),
clear: vi.fn(),
length: 0,
};

vi.spyOn(global.localStorage, "getItem").mockReturnValue(null);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you store this spy in variable you wouldn't have to recreate it to change the value, for instance
const localStorageSpy = vi.spyOn(global.localStorage, "getItem").mockReturnValue(null); but you would need to move it above the beforeEach


afterEach(() => {
vi.clearAllMocks();
});

describe("SPA Filters", () => {
it("should display the drawer closed initially", () => {
setup([], "spas");
Expand Down Expand Up @@ -279,6 +297,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");
Expand Down Expand Up @@ -417,4 +436,69 @@ describe("OsFilterDrawer", () => {
expect(screen.getByRole("button", { name: "Reset" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
describe("localStorage", () => {
it("should load filters from localStorage", async () => {
const storedFilterState = [
{
label: "State",
field: "state.keyword",
component: "multiSelect",
prefix: "must",
type: "terms",
value: ["MD"],
},
];
vi.spyOn(global.localStorage, "getItem").mockReturnValue(
JSON.stringify({
filters: storedFilterState,
tab: "spas",
}),
);
const { user } = setup([], "spas");

await user.click(screen.getByRole("button", { name: "Filters" }));

const state = screen.getByRole("heading", {
name: "State",
level: 3,
}).parentElement;
expect(state.getAttribute("data-state")).toEqual("open");

const combo = screen.getByRole("combobox");
expect(combo).toBeInTheDocument();
expect(screen.queryByLabelText("Remove MD")).toBeInTheDocument();
});

it("should not set filters if the tab does not match", async () => {
const storedFilterState = [
{
label: "State",
field: "state.keyword",
component: "multiSelect",
prefix: "must",
type: "terms",
value: ["MD"],
},
];

vi.spyOn(global.localStorage, "getItem").mockReturnValue(
JSON.stringify({
filters: storedFilterState,
tab: "spas",
}),
);

const { user } = setup([], "waivers");
await user.click(screen.getByRole("button", { name: "Filters" }));

expect(global.localStorage.removeItem).toHaveBeenCalledWith(FILTER_STORAGE_KEY);
});

it("should do nothing if no filters are stored locally", async () => {
vi.spyOn(global.localStorage, "getItem").mockReturnValue(null);
const { user } = setup([], "spas");
await user.click(screen.getByRole("button", { name: "Filters" }));
expect(global.localStorage.getItem).toHaveBeenCalledWith(FILTER_STORAGE_KEY);
});
});
});
52 changes: 29 additions & 23 deletions react-app/src/components/Opensearch/main/Filtering/Drawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Sheet open={hook.drawer.drawerOpen} onOpenChange={hook.drawer.setDrawerState}>
<Sheet
open={filterDrawerHook.drawer.drawerOpen}
onOpenChange={filterDrawerHook.drawer.setDrawerState}
>
<SheetTrigger asChild>
<Button
variant="outline"
Expand All @@ -37,44 +43,44 @@ export const OsFilterDrawer = () => {
<Button
className="w-full my-2"
variant="outline"
disabled={!hook.filtersApplied}
onClick={hook.onFilterReset}
disabled={!filterDrawerHook.filtersApplied}
onClick={filterDrawerHook.onFilterReset}
>
Reset
</Button>
<Accordion
value={hook.accordionValues}
onValueChange={hook.onAccordionChange}
value={filterDrawerHook.accordionValues}
onValueChange={filterDrawerHook.onAccordionChange}
type="multiple"
>
{Object.values(hook.filters).map((PK) => (
<AccordionItem key={`filter-${PK.field}`} value={PK.field}>
<AccordionTrigger className="underline">{PK.label}</AccordionTrigger>
{Object.values(filterDrawerHook.filters).map((filter) => (
<AccordionItem key={`filter-${filter.field}`} value={filter.field}>
<AccordionTrigger className="underline">{filter.label}</AccordionTrigger>
<AccordionContent className="px-0">
{PK.component === "multiSelect" && (
{filter.component === "multiSelect" && (
<F.FilterableSelect
value={hook.filters[PK.field]?.value as string[]}
onChange={hook.onFilterChange(PK.field)}
options={hook.aggs?.[PK.field]}
value={filterDrawerHook.filters[filter.field]?.value as string[]}
onChange={filterDrawerHook.onFilterChange(filter.field)}
options={filterDrawerHook.aggs?.[filter.field]}
/>
)}
{PK.component === "multiCheck" && (
{filter.component === "multiCheck" && (
<F.FilterableMultiCheck
value={hook.filters[PK.field]?.value as string[]}
onChange={hook.onFilterChange(PK.field)}
options={hook.aggs?.[PK.field]}
value={filterDrawerHook.filters[filter.field]?.value as string[]}
onChange={filterDrawerHook.onFilterChange(filter.field)}
options={filterDrawerHook.aggs?.[filter.field]}
/>
)}
{PK.component === "dateRange" && (
{filter.component === "dateRange" && (
<F.FilterableDateRange
value={hook.filters[PK.field]?.value as opensearch.RangeValue}
onChange={hook.onFilterChange(PK.field)}
value={filterDrawerHook.filters[filter.field]?.value as opensearch.RangeValue}
onChange={filterDrawerHook.onFilterChange(filter.field)}
/>
)}
{PK.component === "boolean" && (
{filter.component === "boolean" && (
<F.FilterableBoolean
value={hook.filters[PK.field]?.value as boolean}
onChange={hook.onFilterChange(PK.field)}
value={filterDrawerHook.filters[filter.field]?.value as boolean}
onChange={filterDrawerHook.onFilterChange(filter.field)}
/>
)}
</AccordionContent>
Expand Down
Loading
Loading