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

Filter by conditions in eCR Library (frontend) #2981

Merged
merged 43 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
971dbad
First pass, filter conditions functionality
angelathe Nov 26, 2024
8f84c72
add select/deselect all functionality
angelathe Nov 26, 2024
1495841
update some styling, maintain checkbox state when toggling filter button
angelathe Nov 27, 2024
757a9a2
styling updates, wip
angelathe Dec 2, 2024
8126024
checkbox color, add icon, add uswds sprite.svg to assets
angelathe Dec 2, 2024
504c78a
adjust padding to fix checkbox focus ring cut off
angelathe Dec 2, 2024
1412872
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 2, 2024
276a521
fix icon not displaying by adding static file route
angelathe Dec 3, 2024
7da8e1a
fix unintentional scrolling bug
angelathe Dec 3, 2024
138c46d
update filter row top border
angelathe Dec 3, 2024
fd750f7
wip, add comments, decompose conditions filter to separate const
angelathe Dec 3, 2024
de1625c
fix scrolling bug by adding position-relative
angelathe Dec 3, 2024
7b4703a
add snapshot and unit tests
angelathe Dec 3, 2024
8e32d2e
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 3, 2024
ff72d75
add JSDocs
angelathe Dec 3, 2024
14c5330
remove css classes and use utilities instead
angelathe Dec 3, 2024
98f6cfd
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 4, 2024
88afe13
Merge branch 'angela/2751-condition-frontend' of https://github.com/C…
angelathe Dec 4, 2024
48a403a
update snapshot test
angelathe Dec 4, 2024
d11e9f9
update select all/deselect all functionality s.t. default is all cond…
angelathe Dec 4, 2024
21fe38b
update so that filters reset if clicking off filter before clicking t…
angelathe Dec 6, 2024
661a138
update basepath so it works in prod
angelathe Dec 6, 2024
7f3a51d
update tests
angelathe Dec 6, 2024
d0172cb
update styles in diff button states, update icon size, make capitaliz…
angelathe Dec 6, 2024
ec2c4e3
Remove log
angelathe Dec 6, 2024
c207e4e
use as form/fieldset, update sync state bug, update tests
angelathe Dec 9, 2024
5f72fc8
remove manual checkboxing for select all, lets react handle the render
angelathe Dec 9, 2024
37522e2
rework state management, update tests
angelathe Dec 9, 2024
c0cd22f
Merge branch 'angela/2751-condition-frontend' of https://github.com/C…
angelathe Dec 9, 2024
75432a1
code review changes, minor
angelathe Dec 10, 2024
aa41879
query should persist over a reload
angelathe Dec 10, 2024
5b7f370
update backend so default (all conditions) would leave out condition …
angelathe Dec 11, 2024
071294d
use import for icon
angelathe Dec 11, 2024
08541c1
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 11, 2024
f0ab109
Update base_path env var name
angelathe Dec 11, 2024
75ef9d1
update snapshot test
angelathe Dec 11, 2024
9f10fa4
Merge branch 'angela/2751-condition-frontend' of https://github.com/C…
angelathe Dec 11, 2024
b27ad09
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 11, 2024
07ef590
re-use resetFilterConditions
angelathe Dec 11, 2024
82b41e0
one more nit
angelathe Dec 11, 2024
9551a75
update ecr library height to accommodate fiter bar
angelathe Dec 11, 2024
3312b57
Merge branch 'main' into angela/2751-condition-frontend
angelathe Dec 12, 2024
3f9637a
update env var name for base path
angelathe Dec 12, 2024
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
262 changes: 262 additions & 0 deletions containers/ecr-viewer/src/app/components/Filters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
"use client";

import React, { useCallback, useEffect, useState } from "react";
import { Button, Icon } from "@trussworks/react-uswds";
import { useRouter, usePathname, useSearchParams } from "next/navigation";

const basePath =
process.env.NODE_ENV === "production" ? process.env.BASEPATH : "";

/**
* Functional component that renders Filters section in eCR Library.
* Includes Filter component for reportable conditions.
* @returns The rendered Filters component.
*/
export const Filters = () => {
return (
<div>
<div className="border-top border-base-lighter"></div>
<div className="margin-x-3 margin-y-105 display-flex flex-align-center gap-105">
<span className="line-height-sans-6">FILTERS:</span>
<FilterReportableConditions />
</div>
</div>
);
};

/**
* Functional component for filtering eCRs in the Library based on reportable conditions.
* @returns The rendered FilterReportableConditions component.
* - Fetches conditions from the `/api/conditions` endpoint.
* - Users can select specific conditions or select all conditions.
* - Updates the browser's query string when the filter is applied.
*/
const FilterReportableConditions = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const [isFilterBoxOpen, setIsFilterBoxOpen] = useState(false);
const [filterConditions, setFilterConditions] = useState<{
[key: string]: boolean;
}>({});

// Fetch list of conditions
useEffect(() => {
const fetchConditions = async () => {
try {
const response = await fetch(`${basePath}/api/conditions`);
if (!response.ok) {
throw new Error("Failed to fetch conditions");
}
const allConditions = await response.json();
resetFilterConditions(allConditions);
} catch (error) {
console.error("Error fetching conditions:", error);
angelathe marked this conversation as resolved.
Show resolved Hide resolved
}
};

fetchConditions();
}, []);

// Build list of conditions to filter on
const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = event.target;
setFilterConditions((prev) => {
return { ...prev, [value]: checked };
});
};

// Check/Uncheck all boxes based on Select all checkbox
const handleSelectAll = (event: React.ChangeEvent<HTMLInputElement>) => {
const { checked } = event.target;

const updatedConditions = Object.keys(filterConditions).reduce(
(dict, condition) => {
dict[condition] = checked;
return dict;
},
{} as { [key: string]: boolean },
);

setFilterConditions(updatedConditions);
};

const isAllSelected = Object.values(filterConditions).every(
(val) => val === true,
);

const setQueryParam = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", "1");
params.set(name, value);
return params;
},
[searchParams],
);

const deleteQueryParam = useCallback(
angelathe marked this conversation as resolved.
Show resolved Hide resolved
(name: string, value?: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", "1");
params.delete(name, value);
return params;
},
[searchParams],
);

const updateFilterConditionsQuery = (filterConditions: {
[key: string]: boolean;
}) => {
const filterConditionsSearch = Object.keys(filterConditions)
.filter((key) => filterConditions[key] === true)
.join("|");
const pathConditions = isAllSelected
? deleteQueryParam("condition")
: setQueryParam("condition", filterConditionsSearch);
if (searchParams.get("condition") !== pathConditions.get("condition")) {
router.push(pathname + "?" + pathConditions);
}
};

const resetFilterConditions = (conditions: string[]) => {
const conditionParam = searchParams.get("condition");
const conditionsToTrue = new Set(conditionParam?.split("|") || []);

const conditionValue = (c: string) => {
if (conditionParam === null) {
return true;
} else {
return conditionsToTrue.has(c);
}
};
const prevFilterConditions = conditions.reduce(
(dict: { [key: string]: boolean }, condition: string) => {
dict[condition] = conditionValue(condition);
return dict;
},
{} as { [key: string]: boolean },
);
setFilterConditions(prevFilterConditions);
};

return (
<div>
<div className="position-relative display-flex flex-column">
<Button
className={`${isAllSelected ? "filter-button" : "filters-applied"}`}
id="button-filter-conditions"
aria-label="Filter by reportable condition"
aria-haspopup="listbox"
aria-expanded={isFilterBoxOpen}
onClick={() => {
if (isFilterBoxOpen) {
resetFilterConditions(Object.keys(filterConditions));
}
setIsFilterBoxOpen(!isFilterBoxOpen);
}}
type="button"
>
<span className="square-205 usa-icon">
<Icon.Coronavirus aria-hidden className="square-205" />
</span>
<span className="text-ink"> Reportable Condition </span>
<span
className="usa-tag padding-05 bg-base-darker radius-md"
data-testid="filter-conditions-tag"
>
{
Object.keys(filterConditions).filter(
(key) => filterConditions[key] === true,
).length
}
</span>
</Button>

{isFilterBoxOpen && (
<div className="usa-combo-box top-full left-0">
<form
onSubmit={(e) => {
e.preventDefault();
updateFilterConditionsQuery(filterConditions);
setIsFilterBoxOpen(false);
}}
>
<fieldset className="usa-combo-box border-0 padding-0 margin-top-1 bg-white position-absolute radius-md shadow-2 z-top maxh-6205 width-4305">
{/* Title */}
<legend className="line-height-sans-6 text-bold font-sans-xs bg-white width-full padding-y-1 padding-x-105">
Filter by Reportable Condition
</legend>

{/* Select All checkbox */}
<div className="display-flex flex-column">
<div
className="checkbox-color usa-checkbox padding-bottom-1 padding-x-105"
key={"all"}
>
<input
id="condition-all"
className="usa-checkbox__input"
type="checkbox"
value="all"
onChange={handleSelectAll}
checked={isAllSelected}
/>
<label
className="line-height-sans-6 font-sans-xs margin-y-0 usa-checkbox__label"
htmlFor={"condition-all"}
>
{isAllSelected ? "Deselect all" : "Select all"}
</label>
</div>
<div className="border-top-1px border-base-lighter margin-x-105"></div>

{/* (Scroll) Filter Conditions checkboxes */}
<div className="position-relative bg-white overflow-y-auto maxh-38 display-flex flex-column gap-1 padding-y-1 padding-x-105">
{Object.keys(filterConditions).map((condition) => (
<div
className="checkbox-color usa-checkbox"
key={condition}
>
<input
id={`condition-${condition}`}
className="usa-checkbox__input"
type="checkbox"
value={condition}
onChange={handleCheckboxChange}
checked={filterConditions[condition]}
/>
<label
className="line-height-sans-6 font-sans-xs margin-y-0 usa-checkbox__label"
htmlFor={`condition-${condition}`}
>
{condition}
</label>
</div>
))}
</div>
</div>

{/* Apply Filter Button */}
<div className="display-flex flex-column flex-stretch padding-x-105">
<div className="border-top-1px border-base-lighter margin-x-neg-105"></div>
<Button
type="submit"
id="button-filter-conditions-apply"
className="margin-y-1 margin-x-0 padding-y-1 padding-x-205 flex-fill"
aria-label="Apply Filter"
>
Apply Filter
</Button>
</div>
</fieldset>
</form>
</div>
)}
</div>
</div>
);
};

export default Filters;
10 changes: 6 additions & 4 deletions containers/ecr-viewer/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import EcrPaginationWrapper from "@/app/components/EcrPaginationWrapper";
import EcrTable from "@/app/components/EcrTable";
import LibrarySearch from "./components/LibrarySearch";
import NotFound from "./not-found";
import Filters from "@/app/components/Filters";
import { EcrTableLoading } from "./components/EcrTableClient";

/**
Expand All @@ -23,14 +24,14 @@ const HomePage = async ({
const sortColumn = (searchParams?.columnId as string) || "date_created";
const sortDirection = (searchParams?.direction as string) || "DESC";
const searchTerm = searchParams?.search as string | undefined;
// Placeholder for given array of conditions to filter on, remove in #2751
const filterConditions = undefined; // Ex. ["Anthrax (disorder)", "Measles (disorder)"];
const filterConditions = searchParams?.condition as string | undefined;
const filterConditionsArr = filterConditions?.split("|");

const isNonIntegratedViewer =
process.env.NEXT_PUBLIC_NON_INTEGRATED_VIEWER === "true";
let totalCount: number = 0;
if (isNonIntegratedViewer) {
totalCount = await getTotalEcrCount(searchTerm, filterConditions);
totalCount = await getTotalEcrCount(searchTerm, filterConditionsArr);
}

return isNonIntegratedViewer ? (
Expand All @@ -44,6 +45,7 @@ const HomePage = async ({
textBoxClassName="width-21-9375"
/>
</div>
<Filters />
<EcrPaginationWrapper totalCount={totalCount}>
<Suspense fallback={<EcrTableLoading />}>
<EcrTable
Expand All @@ -52,7 +54,7 @@ const HomePage = async ({
sortColumn={sortColumn}
sortDirection={sortDirection}
searchTerm={searchTerm}
filterConditions={filterConditions}
filterConditions={filterConditionsArr}
/>
</Suspense>
</EcrPaginationWrapper>
Expand Down
30 changes: 20 additions & 10 deletions containers/ecr-viewer/src/app/services/listEcrDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,9 @@ export const generateWhereStatementPostgres = (
rawType: true,
toPostgres: () => {
const statementSearch = generateSearchStatement(searchTerm).toPostgres();
const statementConditions =
generateFilterConditionsStatement(filterConditions).toPostgres();
const statementConditions = filterConditions
? generateFilterConditionsStatement(filterConditions).toPostgres()
: "NULL IS NULL";

return `(${statementSearch}) AND (${statementConditions})`;
},
Expand All @@ -301,8 +302,9 @@ const generateWhereStatementSqlServer = (
filterConditions?: string[],
) => {
const statementSearch = generateSearchStatementSqlServer(searchTerm);
const statementConditions =
generateFilterConditionsStatementSqlServer(filterConditions);
const statementConditions = filterConditions
? generateFilterConditionsStatementSqlServer(filterConditions)
: "NULL IS NULL";

return `(${statementSearch}) AND (${statementConditions})`;
};
Expand Down Expand Up @@ -349,13 +351,17 @@ const generateSearchStatementSqlServer = (searchTerm?: string) => {
* @returns custom type format object for use by pg-promise
*/
export const generateFilterConditionsStatement = (
filterConditions?: string[],
filterConditions: string[],
) => ({
rawType: true,
toPostgres: () => {
const { pgPromise } = getDB();
if (!filterConditions) {
return pgPromise.as.format("NULL IS NULL");
if (
Array.isArray(filterConditions) &&
filterConditions.every((item) => item === "")
) {
const subQuery = `SELECT DISTINCT erc_sub.eICR_ID FROM ecr_rr_conditions erc_sub WHERE erc_sub.condition IS NOT NULL`;
return `ed.eICR_ID NOT IN (${subQuery})`;
}

const whereStatement = filterConditions
Expand All @@ -371,10 +377,14 @@ export const generateFilterConditionsStatement = (
});

const generateFilterConditionsStatementSqlServer = (
filterConditions?: string[],
filterConditions: string[],
) => {
if (!filterConditions) {
return "NULL IS NULL";
if (
Array.isArray(filterConditions) &&
filterConditions.every((item) => item === "")
) {
const subQuery = `SELECT DISTINCT erc_sub.eICR_ID FROM ecr_rr_conditions erc_sub WHERE erc_sub.condition IS NOT NULL`;
return `ed.eICR_ID NOT IN (${subQuery})`;
}

const whereStatement = filterConditions
Expand Down
Loading
Loading