From 31e07c23130c77a32d51832de161c74a57fb6084 Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Fri, 1 May 2026 10:29:23 -0500 Subject: [PATCH 1/8] feat: adding filter button to project page --- backend/openapi.yml | 20 +- backend/ops_api/ops/schemas/projects.py | 2 +- backend/ops_api/ops/services/projects.py | 25 +- .../ops_api/tests/ops/project/test_project.py | 26 +- frontend/cypress/e2e/projectsList.cy.js | 260 +++++++++++ frontend/src/api/opsAPI.js | 56 ++- .../ProjectTypeComboBox.jsx | 59 +++ .../Projects/ProjectTypeComboBox/index.js | 1 + .../ProjectFilterButton.hooks.js | 97 ++++ .../ProjectFilterButton.jsx | 127 +++++ .../ProjectFilterButton.module.css | 23 + .../ProjectFilterButton.test.jsx | 433 ++++++++++++++++++ .../ProjectFilterTypes.d.ts | 13 + .../ProjectFilterTags.hooks.js | 135 ++++++ .../ProjectFilterTags/ProjectFilterTags.jsx | 29 ++ .../ProjectFilterTags.test.jsx | 295 ++++++++++++ .../src/pages/projects/list/ProjectsList.jsx | 94 ++-- 17 files changed, 1645 insertions(+), 50 deletions(-) create mode 100644 frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx create mode 100644 frontend/src/components/Projects/ProjectTypeComboBox/index.js create mode 100644 frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js create mode 100644 frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx create mode 100644 frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.module.css create mode 100644 frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.test.jsx create mode 100644 frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterTypes.d.ts create mode 100644 frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js create mode 100644 frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.jsx create mode 100644 frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx diff --git a/backend/openapi.yml b/backend/openapi.yml index 1f7fd26600..b08413071f 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -3524,9 +3524,23 @@ paths: agreement_names: type: array items: - type: string - description: List of agreement names and nicknames associated with projects (sorted alphabetically, no duplicates) - example: ["Agreement 001", "Contract Alpha", "Grant Beta"] + type: object + required: + - id + - name + properties: + id: + type: integer + name: + type: string + description: List of agreement names and nicknames associated with projects (sorted alphabetically by name, no duplicates) + example: + - id: 1 + name: "Agreement 001" + - id: 2 + name: "Contract Alpha" + - id: 3 + name: "Grant Beta" "401": description: Unauthorized "500": diff --git a/backend/ops_api/ops/schemas/projects.py b/backend/ops_api/ops/schemas/projects.py index 107122476e..b7ce580b1b 100644 --- a/backend/ops_api/ops/schemas/projects.py +++ b/backend/ops_api/ops/schemas/projects.py @@ -207,7 +207,7 @@ class ProjectListFilterOptionResponseSchema(Schema): portfolios = fields.List(fields.Dict(keys=fields.String(), values=fields.Raw()), required=True) project_titles = fields.List(fields.Dict(keys=fields.String(), values=fields.Raw()), required=True) project_types = fields.List(fields.String(), required=True) - agreement_names = fields.List(fields.String(), required=True) + agreement_names = fields.List(fields.Nested(AgreementNameListItem), required=True) class ProjectFundingRequestSchema(Schema): diff --git a/backend/ops_api/ops/services/projects.py b/backend/ops_api/ops/services/projects.py index d47f7199e5..9513bbd658 100644 --- a/backend/ops_api/ops/services/projects.py +++ b/backend/ops_api/ops/services/projects.py @@ -624,7 +624,7 @@ def get_filter_options(self) -> dict[str, Any]: ) project_types = sorted([pt.name for pt in self.db_session.scalars(project_types_query).all()]) - # Step 6: Agreement names and nick_names - Query both and create a sorted list + # Step 6: Agreement names and nick_names - Query both and create a sorted list of dicts agreement_names_query = ( select(Agreement.id, Agreement.name, Agreement.nick_name) .join(Project, Agreement.project_id == Project.id) @@ -632,16 +632,19 @@ def get_filter_options(self) -> dict[str, Any]: .where(Agreement.name.isnot(None)) ) - # Collect all names and nick_names into a single sorted list - # Don't need ids because the match query matches directly on name and nick_name. - agreement_values = set() # Use set to avoid duplicates - for _, a_name, a_nick_name in self.db_session.execute(agreement_names_query).all(): - if a_name: - agreement_values.add(a_name) - if a_nick_name: - agreement_values.add(a_nick_name) - - agreement_names = sorted(list(agreement_values)) + # Collect all names and nick_names into a list of dicts with ids + # Use a dict keyed by name to avoid duplicates while preserving id + agreement_values_dict = {} # Key: name, Value: id + for a_id, a_name, a_nick_name in self.db_session.execute(agreement_names_query).all(): + if a_name and a_name not in agreement_values_dict: + agreement_values_dict[a_name] = a_id + if a_nick_name and a_nick_name not in agreement_values_dict: + agreement_values_dict[a_nick_name] = a_id + + # Convert to list of dicts and sort by name + agreement_names = sorted( + [{"id": a_id, "name": name} for name, a_id in agreement_values_dict.items()], key=lambda x: x["name"] + ) # Build response filters = { diff --git a/backend/ops_api/tests/ops/project/test_project.py b/backend/ops_api/tests/ops/project/test_project.py index 4fabc54f85..e798509c66 100644 --- a/backend/ops_api/tests/ops/project/test_project.py +++ b/backend/ops_api/tests/ops/project/test_project.py @@ -344,6 +344,22 @@ def test_project_type_filter_single_type(auth_client, loaded_db): assert len(response_research.json["data"]) + len(response_admin.json["data"]) == len(response_all.json["data"]) +def test_agreement_and_fiscal_year_filter(auth_client, loaded_db): + """Test filtering by agreement and fiscal year at the same time""" + response_research = auth_client.get( + url_for( + "api.projects-group", + agreement_search=["AA #1: Fathers and Continuous Learning (FCL)"], + fiscal_year=[2044], + limit=50, + ) + ) + + assert response_research == 200 + projects = [p for p in response_research.json["data"]] + assert len(projects) == 1 + + def test_projects_get_all_includes_summary(auth_client, loaded_db): """Test that GET /projects/ includes summary with total_projects, projects_by_type, and amounts_by_type.""" response = auth_client.get(url_for("api.projects-group")) @@ -1440,12 +1456,18 @@ def test_filter_options_agreement_types_sorted(self, auth_client, app_ctx): assert project_types == sorted(project_types) def test_filter_options_agreement_names_sorted_by_name(self, auth_client, app_ctx): - """Agreement names should be sorted alphabetically by name.""" + """Agreement names should be sorted alphabetically by name and returned as list of dicts with id and name.""" response = auth_client.get(url_for("api.projects-filters")) assert response.status_code == 200 agreement_names = response.json["agreement_names"] - names = [a for a in agreement_names] + assert len(agreement_names) > 0 + # Verify structure: each item should have id and name + for agreement in agreement_names: + assert "id" in agreement + assert "name" in agreement + # Verify sorted by name + names = [a["name"] for a in agreement_names] assert names == sorted(names) diff --git a/frontend/cypress/e2e/projectsList.cy.js b/frontend/cypress/e2e/projectsList.cy.js index cb159851b2..d6c02e2f5e 100644 --- a/frontend/cypress/e2e/projectsList.cy.js +++ b/frontend/cypress/e2e/projectsList.cy.js @@ -1,6 +1,8 @@ /// import { terminalLog, testLogin } from "./utils"; +const getAppliedFilters = () => cy.contains("span", "Filters Applied:").parent(); + beforeEach(() => { testLogin("budget-team"); cy.visit("/projects"); @@ -72,4 +74,262 @@ describe("Projects List Page", () => { .should("have.attr", "aria-sort") .and("match", /ascending|descending/); }); + + it("the filter button works as expected", () => { + cy.get("button").contains("Filter").click(); + + // Set a number of filters + cy.get(".fiscal-year-combobox__control").click(); + cy.get(".fiscal-year-combobox__menu").contains(".fiscal-year-combobox__option", "FY 2044").click(); + + cy.get(".portfolios-combobox__control").click(); + cy.get(".portfolios-combobox__menu").contains(".portfolios-combobox__option", "Child Care Research").click(); + + // Click Apply + cy.get("button").contains("Apply").click(); + + // Check that the correct tags are displayed + getAppliedFilters().within(() => { + cy.contains("FY 2044").should("exist"); + cy.contains("Child Care Research").should("exist"); + }); + + // Verify filters were applied — table shows results or zero-results message + cy.get("tbody tr", { timeout: 10000 }).should("exist"); + + // Reset + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + // Wait for filters to be cleared + cy.wait(1000); + + // Check that no tags are displayed + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by fiscal year", () => { + cy.get("button").contains("Filter").click(); + + // Select a fiscal year + cy.get(".fiscal-year-combobox__control").click(); + cy.get(".fiscal-year-combobox__menu").should("be.visible"); + cy.get(".fiscal-year-combobox__menu").contains("FY 2043").click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify the filter tag is displayed + getAppliedFilters().within(() => { + cy.contains("FY 2043", { timeout: 10000 }).should("exist"); + }); + + // Verify the table is filtered correctly + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + // Wait for table to reload with all projects + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by portfolio", () => { + cy.get("button").contains("Filter").click(); + + // Select a portfolio + cy.get(".portfolios-combobox__control").click(); + cy.get(".portfolios-combobox__menu").should("be.visible"); + cy.get(".portfolios-combobox__menu").contains("Child Care Research").click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify the filter tag is displayed + getAppliedFilters().within(() => { + cy.contains("Child Care Research", { timeout: 10000 }).should("exist"); + }); + + // Verify the table is filtered correctly + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + // Wait for table to reload + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by project title", () => { + cy.get("#fiscal-year-select").select("All"); + + cy.get("button").contains("Filter").click(); + + // Select a project title + cy.get(".project-title-combobox__control").click(); + cy.get(".project-title-combobox__menu").should("be.visible"); + // Select the first project option that appears + cy.get(".project-title-combobox__menu .project-title-combobox__option").first().click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify a filter tag is displayed (we don't know the exact project name) + getAppliedFilters().should("exist"); + + // Verify the table shows filtered results + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by project type", () => { + cy.get("button").contains("Filter").click(); + + // Select project type + cy.get(".project-type-combobox__control").click(); + cy.get(".project-type-combobox__menu").should("be.visible"); + cy.get(".project-type-combobox__menu").contains("Research").click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify the filter tag is displayed + getAppliedFilters().within(() => { + cy.contains("Research", { timeout: 10000 }).should("exist"); + }); + + // Verify the table shows only research projects + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + // All visible rows should show "Research" as the type + cy.get("tbody tr td:nth-child(2)").each(($el) => { + cy.wrap($el).should("contain.text", "Research"); + }); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by agreement title", () => { + cy.get("button").contains("Filter").click(); + + // Select an agreement title + cy.get(".agreement-name-combobox__control").click(); + cy.get(".agreement-name-combobox__menu").should("be.visible"); + // Select the first agreement option that appears + cy.get(".agreement-name-combobox__menu .agreement-name-combobox__option").first().click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify a filter tag is displayed + getAppliedFilters().should("exist"); + + // Verify the table shows filtered results + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("filters projects by multiple criteria", () => { + cy.get("button").contains("Filter").click(); + + // Select multiple filters + cy.get(".fiscal-year-combobox__control").click(); + cy.get(".fiscal-year-combobox__menu").should("be.visible"); + cy.get(".fiscal-year-combobox__menu").contains("FY 2044").click(); + + cy.get(".portfolios-combobox__control").click(); + cy.get(".portfolios-combobox__menu").should("be.visible"); + cy.get(".portfolios-combobox__menu").contains("Child Care Research").click(); + + cy.get(".project-type-combobox__control").click(); + cy.get(".project-type-combobox__menu").should("be.visible"); + cy.get(".project-type-combobox__menu").contains("Research").click(); + + // Apply the filter + cy.get("button").contains("Apply").click(); + + // Verify all filter tags are displayed + getAppliedFilters().within(() => { + cy.contains("FY 2044", { timeout: 10000 }).should("exist"); + cy.contains("Child Care Research").should("exist"); + cy.contains("Research").should("exist"); + }); + + // Verify the table shows filtered results + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + + // Reset the filter + cy.get("button").contains("Filter").click(); + cy.get("button").contains("Reset").click(); + cy.get("button").contains("Apply").click(); + + cy.get("tbody tr", { timeout: 30000 }).should("have.length.at.least", 1); + cy.contains("span", "Filters Applied:").should("not.exist"); + }); + + it("removes individual filter tags when clicked", () => { + cy.get("button").contains("Filter").click(); + + // Set multiple filters + cy.get(".fiscal-year-combobox__control").click(); + cy.get(".fiscal-year-combobox__menu").contains("FY 2044").click(); + + cy.get(".portfolios-combobox__control").click(); + cy.get(".portfolios-combobox__menu").contains("Child Care Research").click(); + + cy.get("button").contains("Apply").click(); + + // Verify both tags are displayed + getAppliedFilters().within(() => { + cy.contains("FY 2044").should("exist"); + cy.contains("Child Care Research").should("exist"); + }); + + // Click the X button on the fiscal year tag + cy.get('[aria-label="Remove FY 2044 filter"]').click(); + + // Wait for table to reload + cy.wait(1000); + + // Verify only the portfolio tag remains + getAppliedFilters().within(() => { + cy.contains("FY 2044").should("not.exist"); + cy.contains("Child Care Research").should("exist"); + }); + + // Click the X button on the portfolio tag + cy.get('[aria-label="Remove Child Care Research filter"]').click(); + + // Wait for table to reload + cy.wait(1000); + + // Verify no filter tags remain + cy.contains("span", "Filters Applied:").should("not.exist"); + }); }); diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 6bc4a1dcb3..cae7df6041 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -452,11 +452,58 @@ export const opsApi = createApi({ providesTags: ["ResearchProjects"] }), getProjects: builder.query({ - query: ({ sortConditions, sortDescending, page, limit, fiscalYear } = {}) => { + query: ({ sortConditions, sortDescending, page, limit, fiscalYear, filters } = {}) => { const queryParams = []; - if (fiscalYear && fiscalYear !== "All") { + + // Add filter parameters if they exist and are not empty + if (filters) { + // fiscal_year filter + if (filters.fiscalYear && filters.fiscalYear.length > 0) { + filters.fiscalYear.forEach((fy) => { + if (fy.id !== "all") { + queryParams.push(`fiscal_year=${fy.id}`); + } + }); + } + + // portfolio_id filter + if (filters.portfolio && filters.portfolio.length > 0) { + filters.portfolio.forEach((portfolio) => { + queryParams.push(`portfolio_id=${portfolio.id}`); + }); + } + + // project_search filter + if (filters.projectSearch && filters.projectSearch.length > 0) { + filters.projectSearch.forEach((project) => { + queryParams.push(`project_search=${encodeURIComponent(project.title)}`); + }); + } + + // agreement_search filter + if (filters.agreementSearch && filters.agreementSearch.length > 0) { + filters.agreementSearch.forEach((agreement) => { + queryParams.push(`agreement_search=${encodeURIComponent(agreement.title)}`); + }); + } + + // project_type filter + if (filters.projectType && filters.projectType.length > 0) { + filters.projectType.forEach((type) => { + queryParams.push(`project_type=${type.name}`); + }); + } + } + + // Legacy fiscal year parameter (when not using filters) + if ( + fiscalYear && + fiscalYear !== "All" && + (!filters || !filters.fiscalYear || filters.fiscalYear.length === 0) + ) { queryParams.push(`fiscal_year=${fiscalYear}`); } + if (sortConditions) { queryParams.push(`sort_field=${sortConditions}`); queryParams.push(`sort_descending=${sortDescending}`); @@ -507,6 +554,10 @@ export const opsApi = createApi({ query: ({ id, fiscalYear }) => `/projects/${id}/funding/?fiscal_year=${fiscalYear}`, providesTags: ["ResearchProjects"] }), + getProjectsFilterOptions: builder.query({ + query: () => `/projects-filters/`, + providesTags: ["ResearchProjects"] + }), getProjectsByPortfolio: builder.query({ query: ({ fiscal_year, portfolio_id, search }) => { const queryParams = []; @@ -1121,6 +1172,7 @@ export const { useGetProjectByIdQuery, useGetProjectSpendingByIdQuery, useGetProjectFundingByIdQuery, + useGetProjectsFilterOptionsQuery, useGetProjectsByPortfolioQuery, useGetResearchProjectsQuery, useGetResearchProjectsByPortfolioQuery, diff --git a/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx b/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx new file mode 100644 index 0000000000..ca85dea683 --- /dev/null +++ b/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx @@ -0,0 +1,59 @@ +import ComboBox from "../../UI/Form/ComboBox"; +import { PROJECT_TYPE_RESEARCH, PROJECT_TYPE_ADMIN_SUPPORT, PROJECT_TYPE_LABELS } from "../ProjectTypes.constants"; + +/** + * A comboBox for choosing Project Type(s). + * @param {Object} props - The component props. + * @param {object[]} props.selectedProjectTypes - The currently selected project types. + * @param {Function} props.setSelectedProjectTypes - A function to call when the selected project types change. + * @param {string} [props.legendClassname] - Additional CSS classes to apply to the label/legend (optional). + * @param {string} [props.defaultString] - Initial text to display in select (optional). + * @param {Object} [props.overrideStyles] - Some CSS styles to override the default (optional). + * @returns {React.ReactElement} - The rendered component. + */ +export const ProjectTypeComboBox = ({ + selectedProjectTypes, + setSelectedProjectTypes, + legendClassname = "usa-label margin-top-0", + defaultString = "", + overrideStyles = { minWidth: "22.7rem" } +}) => { + const projectTypeOptions = [ + { + id: PROJECT_TYPE_RESEARCH, + title: PROJECT_TYPE_LABELS[PROJECT_TYPE_RESEARCH], + name: PROJECT_TYPE_RESEARCH + }, + { + id: PROJECT_TYPE_ADMIN_SUPPORT, + title: PROJECT_TYPE_LABELS[PROJECT_TYPE_ADMIN_SUPPORT], + name: PROJECT_TYPE_ADMIN_SUPPORT + } + ]; + + return ( +
+
+ +
+ +
+
+
+ ); +}; + +export default ProjectTypeComboBox; diff --git a/frontend/src/components/Projects/ProjectTypeComboBox/index.js b/frontend/src/components/Projects/ProjectTypeComboBox/index.js new file mode 100644 index 0000000000..7e0672e6ab --- /dev/null +++ b/frontend/src/components/Projects/ProjectTypeComboBox/index.js @@ -0,0 +1 @@ +export { default } from "./ProjectTypeComboBox"; diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js new file mode 100644 index 0000000000..51238c0547 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js @@ -0,0 +1,97 @@ +import React from "react"; +import { getCurrentFiscalYear } from "../../../../helpers/utils"; + +/** + * A filter for Projects list. + * @param {import('./ProjectFilterTypes').Filters} filters - The current filters. + * @param {Function} setFilters - A function to call to set the filters. + */ +export const useProjectFilterButton = (filters, setFilters) => { + const [fiscalYear, setFiscalYear] = React.useState(/** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([])); + const [portfolio, setPortfolio] = React.useState(/** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([])); + const [projectSearch, setProjectSearch] = React.useState( + /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) + ); + const [agreementSearch, setAgreementSearch] = React.useState( + /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) + ); + const [projectType, setProjectType] = React.useState( + /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) + ); + const currentFiscalYear = getCurrentFiscalYear(); + + // The useEffect() hook calls below are used to set the state appropriately when the filter tags (X) are clicked. + React.useEffect(() => { + if (filters.fiscalYear) { + setFiscalYear(filters.fiscalYear); + } + }, [filters.fiscalYear]); + + React.useEffect(() => { + if (filters.portfolio) { + setPortfolio(filters.portfolio); + } + }, [filters.portfolio]); + + React.useEffect(() => { + if (filters.projectSearch) { + setProjectSearch(filters.projectSearch); + } + }, [filters.projectSearch]); + + React.useEffect(() => { + if (filters.agreementSearch) { + setAgreementSearch(filters.agreementSearch); + } + }, [filters.agreementSearch]); + + React.useEffect(() => { + if (filters.projectType) { + setProjectType(filters.projectType); + } + }, [filters.projectType]); + + const applyFilter = () => { + setFilters( + /** @param {import('./ProjectFilterTypes').Filters} prevState */ + (prevState) => { + return { + ...prevState, + fiscalYear: fiscalYear, + portfolio: portfolio, + projectSearch: projectSearch, + agreementSearch: agreementSearch, + projectType: projectType + }; + } + ); + }; + + const resetFilter = () => { + setFilters({ + fiscalYear: [], + portfolio: [], + projectSearch: [], + agreementSearch: [], + projectType: [] + }); + }; + + return { + fiscalYear, + setFiscalYear, + portfolio, + setPortfolio, + projectSearch, + setProjectSearch, + agreementSearch, + setAgreementSearch, + projectType, + setProjectType, + applyFilter, + resetFilter, + currentFiscalYear + }; +}; + +export default useProjectFilterButton; diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx new file mode 100644 index 0000000000..f6caed506f --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx @@ -0,0 +1,127 @@ +import Modal from "react-modal"; +import customStyles from "./ProjectFilterButton.module.css"; +import FiscalYearComboBox from "../../../../components/UI/Form/FiscalYearComboBox"; +import PortfoliosComboBox from "../../../../components/Portfolios/PortfoliosComboBox"; +import ProjectTitleComboBox from "../../../../components/Projects/ProjectTitleComboBox"; +import ProjectTypeComboBox from "../../../../components/Projects/ProjectTypeComboBox"; +import AgreementNameComboBox from "../../../../components/Agreements/AgreementNameComboBox"; +import FilterButton from "../../../../components/UI/FilterButton/FilterButton"; +import useProjectFilterButton from "./ProjectFilterButton.hooks"; +import { FILTER_MODAL_FULL_WIDTH } from "../../../../constants"; + +/** + * A filter for projects. + * @param {Object} props - The component props. + * @param {Object} props.filters - The current filters. + * @param {Function} props.setFilters - A function to call to set the filters. + * @param {Object} props.projectFilterOptions - The filter options from API. + * @param {boolean} [props.isLoadingOptions] - Whether the filter options are loading. + * @returns {JSX.Element} - The project filter button component. + */ +export const ProjectFilterButton = ({ filters, setFilters, projectFilterOptions, isLoadingOptions = false }) => { + const { + fiscalYear, + setFiscalYear, + portfolio, + setPortfolio, + projectSearch, + setProjectSearch, + agreementSearch, + setAgreementSearch, + projectType, + setProjectType, + applyFilter, + resetFilter, + currentFiscalYear + } = useProjectFilterButton(filters, setFilters); + + const fieldStyles = "usa-fieldset margin-bottom-205"; + const legendStyles = `usa-legend font-sans-3xs margin-top-0 padding-bottom-1 ${customStyles.legendColor}`; + + const fieldsetList = [ +
+ +
, +
+ +
, +
+ +
, +
+ +
, +
+ +
+ ]; + + Modal.setAppElement("#root"); + + return ( + + ); +}; + +export default ProjectFilterButton; diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.module.css b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.module.css new file mode 100644 index 0000000000..ea8ad52080 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.module.css @@ -0,0 +1,23 @@ +.legendColor { + color: var(--base-dark); +} + +.filterButton { + width: min-content; +} + +.filterModal { + position: absolute; + top: 50px; + right: 0; + min-width: 25rem; + border: 1px solid var(--gray-10); +} + +.filterOverlay { + position: relative; +} + +.modalBackgroundColor { + background-color: var(--gray-2); +} diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.test.jsx b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.test.jsx new file mode 100644 index 0000000000..35ed6f838a --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.test.jsx @@ -0,0 +1,433 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import ProjectFilterButton from "./ProjectFilterButton"; + +// Mock ComboBox components to avoid RTK Query API calls +vi.mock("../../../../components/UI/Form/FiscalYearComboBox", () => ({ + default: ({ selectedFiscalYears, setSelectedFiscalYears, label }) => ( +
+ + +
+ ) +})); + +vi.mock("../../../../components/Portfolios/PortfoliosComboBox", () => ({ + default: ({ selectedPortfolios, setSelectedPortfolios }) => ( +
+ + +
+ ) +})); + +vi.mock("../../../../components/Projects/ProjectTitleComboBox", () => ({ + default: ({ selectedProjects, setSelectedProjects }) => ( +
+ + +
+ ) +})); + +vi.mock("../../../../components/Projects/ProjectTypeComboBox", () => ({ + default: ({ selectedProjectTypes, setSelectedProjectTypes }) => ( +
+ + +
+ ) +})); + +vi.mock("../../../../components/Agreements/AgreementNameComboBox", () => ({ + default: ({ selectedAgreementNames, setSelectedAgreementNames }) => ( +
+ + +
+ ) +})); + +// Mock react-modal +vi.mock("react-modal", () => { + const Modal = ({ isOpen, children }) => (isOpen ?
{children}
: null); + Modal.setAppElement = vi.fn(); + return { + default: Modal + }; +}); + +// Mock ResizeObserver +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +window.ResizeObserver = ResizeObserver; + +describe("ProjectFilterButton", () => { + const mockSetFilters = vi.fn(); + + const defaultFilters = { + fiscalYear: [], + portfolio: [], + projectSearch: [], + agreementSearch: [], + projectType: [] + }; + + const mockProjectFilterOptions = { + fiscal_years: [2023, 2024, 2025], + portfolios: [ + { id: 1, name: "Portfolio A" }, + { id: 2, name: "Portfolio B" } + ], + agreement_names: ["Agreement 1", "Agreement 2"] + }; + + // Create a simple mock store without RTK Query middleware + const mockStore = configureStore({ + reducer: { + userSlice: (state = { activeUser: { id: 1, roles: [] } }) => state + } + }); + + // Helper to render with Router and Redux context + const renderWithRouter = (ui) => { + return render( + + {ui} + + ); + }; + + beforeEach(() => { + mockSetFilters.mockClear(); + }); + + it("should render the filter button", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Filters")).toBeInTheDocument(); + }); + + it("should open modal when filter button is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByTestId("modal")).toBeInTheDocument(); + }); + }); + + it("should display all five filter fieldsets in modal", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByTestId("fiscal-year-combobox")).toBeInTheDocument(); + }); + + expect(screen.getByTestId("portfolios-combobox")).toBeInTheDocument(); + expect(screen.getByTestId("project-title-combobox")).toBeInTheDocument(); + expect(screen.getByTestId("project-type-combobox")).toBeInTheDocument(); + expect(screen.getByTestId("agreement-name-combobox")).toBeInTheDocument(); + }); + + it("should display Apply and Reset buttons in modal", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /apply/i })).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + it("should call setFilters when Apply is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /apply/i })).toBeInTheDocument(); + }); + + const applyButton = screen.getByRole("button", { name: /apply/i }); + await user.click(applyButton); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should reset filters when Reset is clicked", async () => { + const user = userEvent.setup(); + const filtersWithSelections = { + fiscalYear: [{ id: 2023, title: "2023" }], + portfolio: [{ id: 1, name: "Portfolio A" }], + projectSearch: [{ title: "Project Alpha" }], + agreementSearch: [{ title: "Agreement 1" }], + projectType: [{ title: "RESEARCH" }] + }; + + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /reset/i })).toBeInTheDocument(); + }); + + const resetButton = screen.getByRole("button", { name: /reset/i }); + await user.click(resetButton); + + expect(mockSetFilters).toHaveBeenCalledWith({ + fiscalYear: [], + portfolio: [], + projectSearch: [], + agreementSearch: [], + projectType: [] + }); + }); + + it("should close modal when Apply is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByTestId("modal")).toBeInTheDocument(); + }); + + const applyButton = screen.getByRole("button", { name: /apply/i }); + await user.click(applyButton); + + await waitFor(() => { + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + }); + + it("should sync state with filters prop via useEffect", async () => { + const { rerender } = renderWithRouter( + + ); + + const updatedFilters = { + fiscalYear: [{ id: 2023, title: "2023" }], + portfolio: [{ id: 1, name: "Portfolio A" }], + projectSearch: [{ title: "Project Alpha" }], + agreementSearch: [{ title: "Agreement 1" }], + projectType: [{ title: "RESEARCH" }] + }; + + rerender( + + ); + + // Internal state should sync with updated filters + expect(screen.getByText("Filters")).toBeInTheDocument(); + }); + + it("should handle loading state for filter options", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Filters")).toBeInTheDocument(); + }); + + it("should handle empty filter options", () => { + const emptyOptions = { + fiscal_years: [], + portfolios: [], + agreement_names: [] + }; + + renderWithRouter( + + ); + + expect(screen.getByText("Filters")).toBeInTheDocument(); + }); + + it("should handle undefined filter options", () => { + renderWithRouter( + + ); + + expect(screen.getByText("Filters")).toBeInTheDocument(); + }); + + it("should display correct filter labels", async () => { + const user = userEvent.setup(); + renderWithRouter( + + ); + + const filterButton = screen.getByText("Filters"); + await user.click(filterButton); + + await waitFor(() => { + expect(screen.getByText("Compare Fiscal Years")).toBeInTheDocument(); + }); + + expect(screen.getByText("Portfolio")).toBeInTheDocument(); + expect(screen.getByText("Project Title")).toBeInTheDocument(); + expect(screen.getByText("Project Type")).toBeInTheDocument(); + expect(screen.getByText("Agreement Title")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterTypes.d.ts b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterTypes.d.ts new file mode 100644 index 0000000000..b05af23496 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterTypes.d.ts @@ -0,0 +1,13 @@ +export type FilterOption = { + id: number | string; + title: string; + name?: string; +}; + +export type Filters = { + fiscalYear?: FilterOption[]; + portfolio?: FilterOption[]; + projectSearch?: FilterOption[]; + agreementSearch?: FilterOption[]; + projectType?: FilterOption[]; +}; diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js new file mode 100644 index 0000000000..ed5c516891 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js @@ -0,0 +1,135 @@ +import { useState, useEffect, useCallback } from "react"; +/** + * @typedef {Object} FYFilterItem + * @property {string} title + */ + +/** + * @typedef {Object} PortfolioFilterItem + * @property {string} name + */ + +/** + * @typedef {Object} FilterItem + * @property {string} title + */ + +/** + * @typedef {Object} Filters + * @property {FYFilterItem[]} fiscalYear + * @property {PortfolioFilterItem[]} portfolio + * @property {FilterItem[]} projectSearch + * @property {FilterItem[]} agreementSearch + * @property {FilterItem[]} projectType + */ + +/** + * @typedef {Object} Tag + * @property {string} tagText + * @property {string} filter + */ + +/** + * Custom hook for managing tags list + * @param {Filters} filters + * @returns {Tag[]} + */ +export const useTagsList = (filters) => { + const [tagsList, setTagsList] = useState([]); + + /** + * @param {keyof Filters} filterKey + * @param {string} filterName + */ + const updateTags = useCallback( + (filterKey, filterName) => { + if (filterKey == "portfolio") { + const selectedTags = + filters[filterKey]?.map((item) => ({ + tagText: item.name, + filter: filterName + })) ?? []; + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + } else if (filterKey == "fiscalYear") { + const selectedTags = + filters[filterKey]?.map((item) => ({ + tagText: "FY " + item.title, + filter: filterName + })) ?? []; + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + } else { + const selectedTags = + filters[filterKey]?.map((item) => ({ + tagText: item.title, + filter: filterName + })) ?? []; + setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); + } + }, + [filters] + ); + + useEffect(() => { + updateTags("fiscalYear", "fiscalYear"); + }, [filters.fiscalYear, updateTags]); + + useEffect(() => { + updateTags("portfolio", "portfolio"); + }, [filters.portfolio, updateTags]); + + useEffect(() => { + updateTags("projectSearch", "projectSearch"); + }, [filters.projectSearch, updateTags]); + + useEffect(() => { + updateTags("agreementSearch", "agreementSearch"); + }, [filters.agreementSearch, updateTags]); + + useEffect(() => { + updateTags("projectType", "projectType"); + }, [filters.projectType, updateTags]); + + return tagsList; +}; + +/** + * Removes a filter tag + * @param {Tag} tag - The tag to remove + * @param {function(function(Filters): Filters): void} setFilters - Function to update filters + */ +export const removeFilter = (tag, setFilters) => { + switch (tag.filter) { + case "fiscalYear": + setFilters((prevState) => ({ + ...prevState, + fiscalYear: prevState.fiscalYear.filter((fiscalYear) => "FY " + fiscalYear.title !== tag.tagText) + })); + break; + case "portfolio": + setFilters((prevState) => ({ + ...prevState, + portfolio: prevState.portfolio.filter((portfolio) => portfolio.name !== tag.tagText) + })); + break; + case "projectSearch": + setFilters((prevState) => ({ + ...prevState, + projectSearch: prevState.projectSearch.filter((project) => project.title !== tag.tagText) + })); + break; + case "agreementSearch": + setFilters((prevState) => ({ + ...prevState, + agreementSearch: prevState.agreementSearch.filter((agreement) => agreement.title !== tag.tagText) + })); + break; + case "projectType": + setFilters((prevState) => ({ + ...prevState, + projectType: prevState.projectType.filter((type) => type.title !== tag.tagText) + })); + break; + default: + console.warn(`Unknown filter type: ${tag.filter}`); + } +}; diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.jsx b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.jsx new file mode 100644 index 0000000000..23f5351d99 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.jsx @@ -0,0 +1,29 @@ +import { isEmpty, groupBy } from "lodash"; +import FilterTags from "../../../../components/UI/FilterTags/FilterTags"; +import FilterTagsWrapper from "../../../../components/UI/FilterTags/FilterTagsWrapper"; +import { removeFilter, useTagsList } from "./ProjectFilterTags.hooks"; +/** + * A filter tags component for projects. + * @param {Object} props - The component props. + * @param {import("./ProjectFilterTags.hooks").Filters} props.filters - The current filters. + * @param {Function} props.setFilters - A function to call to set the filters. + * @returns {JSX.Element} - The project filter tags element. + */ +export const ProjectFilterTags = ({ filters, setFilters }) => { + const tagsList = useTagsList(filters); + + const tagsListByFilter = groupBy(tagsList, "filter"); + const tagsListByFilterMerged = Object.values(tagsListByFilter).flat(); + + return ( + !isEmpty(tagsList) && ( + + removeFilter(tag, setFilters)} + tagsList={tagsListByFilterMerged} + /> + + ) + ); +}; +export default ProjectFilterTags; diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx new file mode 100644 index 0000000000..c858897979 --- /dev/null +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx @@ -0,0 +1,295 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import ProjectFilterTags from "./ProjectFilterTags"; + +describe("ProjectFilterTags", () => { + const mockSetFilters = vi.fn(); + + const mockFilters = { + fiscalYear: [], + portfolio: [], + projectSearch: [], + agreementSearch: [], + projectType: [] + }; + + beforeEach(() => { + mockSetFilters.mockClear(); + }); + + it("should not render when no filters are active", () => { + render( + + ); + + // Should render nothing when no filters are active + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("should render fiscal year filter tags", () => { + const filtersWithFY = { + ...mockFilters, + fiscalYear: [ + { id: 2023, title: "2023" }, + { id: 2024, title: "2024" } + ] + }; + + render( + + ); + + expect(screen.getByText("FY 2023")).toBeInTheDocument(); + expect(screen.getByText("FY 2024")).toBeInTheDocument(); + }); + + it("should render portfolio filter tags", () => { + const filtersWithPortfolio = { + ...mockFilters, + portfolio: [ + { id: 1, name: "Portfolio A" }, + { id: 2, name: "Portfolio B" } + ] + }; + + render( + + ); + + expect(screen.getByText("Portfolio A")).toBeInTheDocument(); + expect(screen.getByText("Portfolio B")).toBeInTheDocument(); + }); + + it("should render project search filter tags", () => { + const filtersWithProjects = { + ...mockFilters, + projectSearch: [ + { title: "Project Alpha" }, + { title: "Project Beta" } + ] + }; + + render( + + ); + + expect(screen.getByText("Project Alpha")).toBeInTheDocument(); + expect(screen.getByText("Project Beta")).toBeInTheDocument(); + }); + + it("should render agreement search filter tags", () => { + const filtersWithAgreements = { + ...mockFilters, + agreementSearch: [ + { title: "Agreement 1" }, + { title: "Agreement 2" } + ] + }; + + render( + + ); + + expect(screen.getByText("Agreement 1")).toBeInTheDocument(); + expect(screen.getByText("Agreement 2")).toBeInTheDocument(); + }); + + it("should render project type filter tags", () => { + const filtersWithTypes = { + ...mockFilters, + projectType: [ + { title: "RESEARCH" }, + { title: "ADMINISTRATIVE_AND_SUPPORT" } + ] + }; + + render( + + ); + + expect(screen.getByText("RESEARCH")).toBeInTheDocument(); + expect(screen.getByText("ADMINISTRATIVE_AND_SUPPORT")).toBeInTheDocument(); + }); + + it("should render all filter types together", () => { + const allFilters = { + fiscalYear: [{ id: 2023, title: "2023" }], + portfolio: [{ id: 1, name: "Portfolio A" }], + projectSearch: [{ title: "Project Alpha" }], + agreementSearch: [{ title: "Agreement 1" }], + projectType: [{ title: "RESEARCH" }] + }; + + render( + + ); + + expect(screen.getByText("FY 2023")).toBeInTheDocument(); + expect(screen.getByText("Portfolio A")).toBeInTheDocument(); + expect(screen.getByText("Project Alpha")).toBeInTheDocument(); + expect(screen.getByText("Agreement 1")).toBeInTheDocument(); + expect(screen.getByText("RESEARCH")).toBeInTheDocument(); + }); + + it("should remove fiscal year filter when tag is clicked", async () => { + const user = userEvent.setup(); + const filtersWithFY = { + ...mockFilters, + fiscalYear: [ + { id: 2023, title: "2023" }, + { id: 2024, title: "2024" } + ] + }; + + render( + + ); + + const fy2023Icon = screen.getByLabelText("Remove FY 2023 filter"); + await user.click(fy2023Icon); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should remove portfolio filter when tag is clicked", async () => { + const user = userEvent.setup(); + const filtersWithPortfolio = { + ...mockFilters, + portfolio: [ + { id: 1, name: "Portfolio A" }, + { id: 2, name: "Portfolio B" } + ] + }; + + render( + + ); + + const portfolioAIcon = screen.getByLabelText("Remove Portfolio A filter"); + await user.click(portfolioAIcon); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should remove project search filter when tag is clicked", async () => { + const user = userEvent.setup(); + const filtersWithProjects = { + ...mockFilters, + projectSearch: [{ title: "Project Alpha" }] + }; + + render( + + ); + + const projectIcon = screen.getByLabelText("Remove Project Alpha filter"); + await user.click(projectIcon); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should remove agreement search filter when tag is clicked", async () => { + const user = userEvent.setup(); + const filtersWithAgreements = { + ...mockFilters, + agreementSearch: [{ title: "Agreement 1" }] + }; + + render( + + ); + + const agreementIcon = screen.getByLabelText("Remove Agreement 1 filter"); + await user.click(agreementIcon); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should remove project type filter when tag is clicked", async () => { + const user = userEvent.setup(); + const filtersWithTypes = { + ...mockFilters, + projectType: [{ title: "RESEARCH" }] + }; + + render( + + ); + + const typeIcon = screen.getByLabelText("Remove RESEARCH filter"); + await user.click(typeIcon); + + expect(mockSetFilters).toHaveBeenCalled(); + }); + + it("should handle multiple tags of same filter type", () => { + const filtersWithMultiple = { + ...mockFilters, + fiscalYear: [ + { id: 2023, title: "2023" }, + { id: 2024, title: "2024" }, + { id: 2025, title: "2025" } + ] + }; + + render( + + ); + + expect(screen.getByText("FY 2023")).toBeInTheDocument(); + expect(screen.getByText("FY 2024")).toBeInTheDocument(); + expect(screen.getByText("FY 2025")).toBeInTheDocument(); + }); + + it("should handle empty arrays for all filter types", () => { + render( + + ); + + // Should not render any tags + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/projects/list/ProjectsList.jsx b/frontend/src/pages/projects/list/ProjectsList.jsx index 586b3235df..6c95287f3c 100644 --- a/frontend/src/pages/projects/list/ProjectsList.jsx +++ b/frontend/src/pages/projects/list/ProjectsList.jsx @@ -1,7 +1,7 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import { PacmanLoader } from "react-spinners"; -import { useGetProjectsQuery, useLazyGetProjectsQuery } from "../../../api/opsAPI"; +import { useGetProjectsQuery, useLazyGetProjectsQuery, useGetProjectsFilterOptionsQuery } from "../../../api/opsAPI"; import App from "../../../App"; import DebugCode from "../../../components/DebugCode"; import TablePageLayout from "../../../components/Layouts/TablePageLayout"; @@ -17,6 +17,8 @@ import { getCurrentFiscalYear } from "../../../helpers/utils"; import useAlert from "../../../hooks/use-alert.hooks"; import icons from "../../../uswds/img/sprite.svg"; import { handleProjectsExport, PROJECT_SORT_CODES } from "./ProjectsList.helpers"; +import ProjectFilterButton from "./ProjectFilterButton/ProjectFilterButton"; +import ProjectFilterTags from "./ProjectFilterTags/ProjectFilterTags"; /** * Page component for the projects list with server-side pagination, sorting, and fiscal year filtering. @@ -31,6 +33,15 @@ const ProjectsList = () => { const { setAlert } = useAlert(); const [getAllProjectsTrigger] = useLazyGetProjectsQuery(); const { sortDescending, sortCondition, setSortConditions } = useSetSortConditions(PROJECT_SORT_CODES.TITLE, false); + const [filters, setFilters] = React.useState({ + fiscalYear: [], + portfolio: [], + projectSearch: [], + agreementSearch: [], + projectType: [] + }); + + const { data: projectFilterOptions, isLoading: isLoadingProjectFilterOptions } = useGetProjectsFilterOptionsQuery(); const { data: projectsResponse, @@ -38,6 +49,9 @@ const ProjectsList = () => { isFetching, isError } = useGetProjectsQuery({ + filters: { + ...filters + }, sortConditions: sortCondition, sortDescending, page: currentPage - 1, @@ -113,37 +127,55 @@ const ProjectsList = () => { title="Projects" subtitle="All Projects" details="This is a list of all projects across OPRE for the selected fiscal year. Draft budget lines are not included in the Totals." + FilterTags={ + + } FilterButton={ -
- {totalCount > 0 && ( - - )} -
+ <> +
+
+ {totalCount > 0 && ( + + )} +
+
+ +
+
+ } TabsSection={
From 86b0d69724fda5edf768c2d4b9c89ee643684710 Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Fri, 1 May 2026 11:24:58 -0500 Subject: [PATCH 2/8] fix: use subqueries to fix multiple filter issue --- backend/ops_api/ops/services/projects.py | 141 +++++++++++++++-------- frontend/cypress/e2e/projectsList.cy.js | 2 +- 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/backend/ops_api/ops/services/projects.py b/backend/ops_api/ops/services/projects.py index 9513bbd658..783a7d369a 100644 --- a/backend/ops_api/ops/services/projects.py +++ b/backend/ops_api/ops/services/projects.py @@ -20,7 +20,6 @@ User, ) from ops_api.ops.services.ops_service import OpsService, ResourceNotFoundError, ValidationError -from ops_api.ops.utils.query_helpers import QueryHelper @dataclass @@ -225,12 +224,10 @@ def _get_research_projects_query(filters: ProjectFilters): Returns: SQLAlchemy select statement """ + # Base query with all necessary eager loading stmt = ( select(ResearchProject) .distinct(ResearchProject.id) - .join(Agreement, isouter=True) - .join(BudgetLineItem, isouter=True) - .join(CAN, isouter=True) .options( selectinload(ResearchProject.agreements).selectinload(Agreement.services_components), selectinload(ResearchProject.agreements) @@ -242,35 +239,60 @@ def _get_research_projects_query(filters: ProjectFilters): ) ) - query_helper = QueryHelper(stmt) + # For filtering, we use EXISTS subqueries to ensure each filter can match different + # related records. This prevents the issue where a single joined row must satisfy + # multiple conditions across different tables (e.g., one agreement for fiscal year + # and a different agreement for agreement name). + where_clauses = [] - # Apply portfolio filter (OR logic - match any portfolio) + # Apply portfolio filter using EXISTS subquery if filters.portfolio_id: - query_helper.add_column_in_list(CAN.portfolio_id, filters.portfolio_id) + portfolio_subquery = ( + select(1) + .select_from(Agreement) + .join(BudgetLineItem) + .join(CAN) + .where(Agreement.project_id == ResearchProject.id) + .where(CAN.portfolio_id.in_(filters.portfolio_id)) + .exists() + ) + where_clauses.append(portfolio_subquery) - # Apply fiscal year filter (OR logic - match any fiscal year) + # Apply fiscal year filter using EXISTS subquery if filters.fiscal_year: - if len(filters.fiscal_year) == 1: - fiscal_year = filters.fiscal_year[0] - query_helper.add_column_equals(BudgetLineItem.fiscal_year, fiscal_year) - else: - # Multiple fiscal years - use IN clause - query_helper.add_column_in_list(BudgetLineItem.fiscal_year, filters.fiscal_year) + fy_subquery = ( + select(1) + .select_from(Agreement) + .join(BudgetLineItem) + .where(Agreement.project_id == ResearchProject.id) + .where(BudgetLineItem.fiscal_year.in_(filters.fiscal_year)) + .exists() + ) + where_clauses.append(fy_subquery) # Apply project search filter on project title (OR logic, exact match on title/short title) if filters.project_search: - query_helper.where_clauses.append( - or_(Project.title.in_(filters.project_search), Project.short_title.in_(filters.project_search)) + where_clauses.append( + or_(ResearchProject.title.in_(filters.project_search), ResearchProject.short_title.in_(filters.project_search)) ) - # Apply agreement search filter on agreement name and nick_name (exact match - OR logic) - # Projects are returned if any agreement has name OR nick_name matching any search term + # Apply agreement search filter using EXISTS subquery if filters.agreement_search: - query_helper.where_clauses.append( - or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search)) + agreement_subquery = ( + select(1) + .select_from(Agreement) + .where(Agreement.project_id == ResearchProject.id) + .where( + or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search)) + ) + .exists() ) + where_clauses.append(agreement_subquery) + + # Apply all where clauses + if where_clauses: + stmt = stmt.where(*where_clauses) - stmt = query_helper.get_stmt() logger.debug(f"SQL: {stmt}") return stmt @@ -285,55 +307,78 @@ def _get_administrative_and_support_projects_query(filters: ProjectFilters): Returns: SQLAlchemy select statement """ + # Base query with all necessary eager loading stmt = ( select(AdministrativeAndSupportProject) .distinct(AdministrativeAndSupportProject.id) - .join(Agreement, isouter=True) - .join(BudgetLineItem, isouter=True) - .join(CAN, isouter=True) .options( - selectinload(ResearchProject.agreements).selectinload(Agreement.services_components), - selectinload(ResearchProject.agreements) + selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.services_components), + selectinload(AdministrativeAndSupportProject.agreements) .selectinload(Agreement.budget_line_items) .selectinload(BudgetLineItem.can), - selectinload(ResearchProject.agreements).selectinload(Agreement.special_topics), - selectinload(ResearchProject.agreements).selectinload(Agreement.research_methodologies), - selectinload(ResearchProject.agreements).selectinload(Agreement.team_members), + selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.special_topics), + selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.research_methodologies), + selectinload(AdministrativeAndSupportProject.agreements).selectinload(Agreement.team_members), ) ) - query_helper = QueryHelper(stmt) + # For filtering, we use EXISTS subqueries to ensure each filter can match different + # related records. This prevents the issue where a single joined row must satisfy + # multiple conditions across different tables (e.g., one agreement for fiscal year + # and a different agreement for agreement name). + where_clauses = [] - # Apply portfolio filter (OR logic - match any portfolio) + # Apply portfolio filter using EXISTS subquery if filters.portfolio_id: - query_helper.add_column_in_list(CAN.portfolio_id, filters.portfolio_id) + portfolio_subquery = ( + select(1) + .select_from(Agreement) + .join(BudgetLineItem) + .join(CAN) + .where(Agreement.project_id == AdministrativeAndSupportProject.id) + .where(CAN.portfolio_id.in_(filters.portfolio_id)) + .exists() + ) + where_clauses.append(portfolio_subquery) - # Apply fiscal year filter (OR logic - match any fiscal year) + # Apply fiscal year filter using EXISTS subquery if filters.fiscal_year: - if len(filters.fiscal_year) == 1: - fiscal_year = filters.fiscal_year[0] - query_helper.add_column_equals(BudgetLineItem.fiscal_year, fiscal_year) - else: - # Multiple fiscal years - use IN clause - query_helper.add_column_in_list(BudgetLineItem.fiscal_year, filters.fiscal_year) + fy_subquery = ( + select(1) + .select_from(Agreement) + .join(BudgetLineItem) + .where(Agreement.project_id == AdministrativeAndSupportProject.id) + .where(BudgetLineItem.fiscal_year.in_(filters.fiscal_year)) + .exists() + ) + where_clauses.append(fy_subquery) - # Apply project search filter on project title (AND logic - must match all search terms) + # Apply project search filter on project title (OR logic, exact match on title/short title) if filters.project_search: - query_helper.where_clauses.append( + where_clauses.append( or_( - Project.title.in_(filters.project_search), - Project.short_title.in_(filters.project_search), + AdministrativeAndSupportProject.title.in_(filters.project_search), + AdministrativeAndSupportProject.short_title.in_(filters.project_search), ) ) - # Apply agreement search filter on agreement name and nick_name (exact match - OR logic) - # Projects are returned if any agreement has name OR nick_name matching any search term + # Apply agreement search filter using EXISTS subquery if filters.agreement_search: - query_helper.where_clauses.append( - or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search)) + agreement_subquery = ( + select(1) + .select_from(Agreement) + .where(Agreement.project_id == AdministrativeAndSupportProject.id) + .where( + or_(Agreement.name.in_(filters.agreement_search), Agreement.nick_name.in_(filters.agreement_search)) + ) + .exists() ) + where_clauses.append(agreement_subquery) + + # Apply all where clauses + if where_clauses: + stmt = stmt.where(*where_clauses) - stmt = query_helper.get_stmt() logger.debug(f"SQL: {stmt}") return stmt diff --git a/frontend/cypress/e2e/projectsList.cy.js b/frontend/cypress/e2e/projectsList.cy.js index d6c02e2f5e..9aeddb8d9e 100644 --- a/frontend/cypress/e2e/projectsList.cy.js +++ b/frontend/cypress/e2e/projectsList.cy.js @@ -312,7 +312,7 @@ describe("Projects List Page", () => { }); // Click the X button on the fiscal year tag - cy.get('[aria-label="Remove FY 2044 filter"]').click(); + cy.get('[aria-label="Remove FY FY 2044 filter"]').click(); // Wait for table to reload cy.wait(1000); From 3824d0c27d33657c4f7785d106e3c26db7905b27 Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Fri, 1 May 2026 11:44:23 -0500 Subject: [PATCH 3/8] style: fixing formatting --- backend/ops_api/ops/services/projects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/ops_api/ops/services/projects.py b/backend/ops_api/ops/services/projects.py index 783a7d369a..0c20b18f0c 100644 --- a/backend/ops_api/ops/services/projects.py +++ b/backend/ops_api/ops/services/projects.py @@ -273,7 +273,10 @@ def _get_research_projects_query(filters: ProjectFilters): # Apply project search filter on project title (OR logic, exact match on title/short title) if filters.project_search: where_clauses.append( - or_(ResearchProject.title.in_(filters.project_search), ResearchProject.short_title.in_(filters.project_search)) + or_( + ResearchProject.title.in_(filters.project_search), + ResearchProject.short_title.in_(filters.project_search), + ) ) # Apply agreement search filter using EXISTS subquery From 9065fd49ce3c4fa151c69faccbdc6e4cae6db10d Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Fri, 1 May 2026 11:57:28 -0500 Subject: [PATCH 4/8] feat: refactoring project filter titles to match design --- .../Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx | 7 +++++-- .../list/ProjectFilterButton/ProjectFilterButton.jsx | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx b/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx index 59fb263aff..65c1aea568 100644 --- a/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx +++ b/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx @@ -10,6 +10,8 @@ import ComboBox from "../../UI/Form/ComboBox"; * @param {string} [props.legendClassname] - Additional CSS classes to apply to the label/legend (optional). * @param {string} [props.defaultString] - Initial text to display in select (optional). * @param {Object} [props.overrideStyles] - Some CSS styles to override the default (optional). + * @param {boolean} props.isLoading - IS the data for the component loading or not + * @param {string} props.filterLabel - The title to display above the component. * @returns {React.ReactElement} - The rendered component. */ export const ProjectTitleComboBox = ({ @@ -19,7 +21,8 @@ export const ProjectTitleComboBox = ({ legendClassname = "usa-label margin-top-0", defaultString = "", overrideStyles = { minWidth: "22.7rem" }, - isLoading = false + isLoading = false, + filterLabel = "Project Title" }) => { // Transform project_titles data to ComboBox format const projectOptions = useMemo(() => { @@ -38,7 +41,7 @@ export const ProjectTitleComboBox = ({ className={legendClassname} htmlFor="project-title-combobox-input" > - Project Title + {filterLabel}
,
]; From 763b18fa0c5e2ca8532b943988cf42cb8f58f5f7 Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Fri, 1 May 2026 12:00:59 -0500 Subject: [PATCH 5/8] style: fix formatting in frontend --- .../ProjectFilterButton.hooks.js | 4 +++- .../ProjectFilterTags/ProjectFilterTags.test.jsx | 15 +++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js index 51238c0547..0a6c7543e1 100644 --- a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js @@ -7,7 +7,9 @@ import { getCurrentFiscalYear } from "../../../../helpers/utils"; * @param {Function} setFilters - A function to call to set the filters. */ export const useProjectFilterButton = (filters, setFilters) => { - const [fiscalYear, setFiscalYear] = React.useState(/** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([])); + const [fiscalYear, setFiscalYear] = React.useState( + /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) + ); const [portfolio, setPortfolio] = React.useState(/** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([])); const [projectSearch, setProjectSearch] = React.useState( /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx index c858897979..b8cd1d09a5 100644 --- a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx @@ -73,10 +73,7 @@ describe("ProjectFilterTags", () => { it("should render project search filter tags", () => { const filtersWithProjects = { ...mockFilters, - projectSearch: [ - { title: "Project Alpha" }, - { title: "Project Beta" } - ] + projectSearch: [{ title: "Project Alpha" }, { title: "Project Beta" }] }; render( @@ -93,10 +90,7 @@ describe("ProjectFilterTags", () => { it("should render agreement search filter tags", () => { const filtersWithAgreements = { ...mockFilters, - agreementSearch: [ - { title: "Agreement 1" }, - { title: "Agreement 2" } - ] + agreementSearch: [{ title: "Agreement 1" }, { title: "Agreement 2" }] }; render( @@ -113,10 +107,7 @@ describe("ProjectFilterTags", () => { it("should render project type filter tags", () => { const filtersWithTypes = { ...mockFilters, - projectType: [ - { title: "RESEARCH" }, - { title: "ADMINISTRATIVE_AND_SUPPORT" } - ] + projectType: [{ title: "RESEARCH" }, { title: "ADMINISTRATIVE_AND_SUPPORT" }] }; render( From 1e3d9267e4610b548015a7b737230bfe2a6de61a Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Mon, 4 May 2026 09:08:34 -0500 Subject: [PATCH 6/8] fix: fixing duplicate "fy" in filter tag --- frontend/cypress/e2e/projectsList.cy.js | 2 +- .../list/ProjectFilterTags/ProjectFilterTags.hooks.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/projectsList.cy.js b/frontend/cypress/e2e/projectsList.cy.js index 9aeddb8d9e..d6c02e2f5e 100644 --- a/frontend/cypress/e2e/projectsList.cy.js +++ b/frontend/cypress/e2e/projectsList.cy.js @@ -312,7 +312,7 @@ describe("Projects List Page", () => { }); // Click the X button on the fiscal year tag - cy.get('[aria-label="Remove FY FY 2044 filter"]').click(); + cy.get('[aria-label="Remove FY 2044 filter"]').click(); // Wait for table to reload cy.wait(1000); diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js index ed5c516891..9fe0d0aed4 100644 --- a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js @@ -53,7 +53,7 @@ export const useTagsList = (filters) => { } else if (filterKey == "fiscalYear") { const selectedTags = filters[filterKey]?.map((item) => ({ - tagText: "FY " + item.title, + tagText: item.title, filter: filterName })) ?? []; setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); @@ -102,7 +102,7 @@ export const removeFilter = (tag, setFilters) => { case "fiscalYear": setFilters((prevState) => ({ ...prevState, - fiscalYear: prevState.fiscalYear.filter((fiscalYear) => "FY " + fiscalYear.title !== tag.tagText) + fiscalYear: prevState.fiscalYear.filter((fiscalYear) => fiscalYear.title !== tag.tagText) })); break; case "portfolio": From b66580354686a6a042ba78b308269a5657e7197f Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Tue, 5 May 2026 08:10:31 -0500 Subject: [PATCH 7/8] fix: respond to pr comments --- .../ops_api/tests/ops/project/test_project.py | 15 ++- frontend/cypress/e2e/projectsList.cy.js | 52 ++++++++++ frontend/src/api/opsAPI.js | 2 +- .../ProjectTitleComboBox.jsx | 2 +- .../ProjectTypeComboBox.jsx | 6 +- .../UI/FilterButton/FilterButton.jsx | 10 +- .../ProjectFilterButton.hooks.js | 14 ++- .../ProjectFilterButton.jsx | 13 ++- .../ProjectFilterTags.hooks.js | 94 ++++--------------- .../ProjectFilterTags.test.jsx | 16 ++-- 10 files changed, 128 insertions(+), 96 deletions(-) diff --git a/backend/ops_api/tests/ops/project/test_project.py b/backend/ops_api/tests/ops/project/test_project.py index 42866d9f40..1cb0f59812 100644 --- a/backend/ops_api/tests/ops/project/test_project.py +++ b/backend/ops_api/tests/ops/project/test_project.py @@ -357,10 +357,23 @@ def test_agreement_and_fiscal_year_filter(auth_client, loaded_db): ) ) - assert response_research == 200 + assert response_research.status_code == 200 projects = [p for p in response_research.json["data"]] assert len(projects) == 1 + response_2 = auth_client.get( + url_for( + "api.projects-group", + agreement_search=["AA #1: Fathers and Continuous Learning (FCL)"], + fiscal_year=[2045], + limit=50, + ) + ) + + assert response_2.status_code == 200 + projects = [p for p in response_2.json["data"]] + assert len(projects) == 0 + def test_projects_get_all_includes_summary(auth_client, loaded_db): """Test that GET /projects/ includes summary with total_projects, projects_by_type, and amounts_by_type.""" diff --git a/frontend/cypress/e2e/projectsList.cy.js b/frontend/cypress/e2e/projectsList.cy.js index d6c02e2f5e..2e90ca5e14 100644 --- a/frontend/cypress/e2e/projectsList.cy.js +++ b/frontend/cypress/e2e/projectsList.cy.js @@ -332,4 +332,56 @@ describe("Projects List Page", () => { // Verify no filter tags remain cy.contains("span", "Filters Applied:").should("not.exist"); }); + + it("clears unapplied filter values when filter is closed and reopened but keeps applied values", () => { + // Apply initial filters + cy.get("button").contains("Filter").click(); + + cy.get(".fiscal-year-combobox__control").click(); + cy.get(".fiscal-year-combobox__menu").contains("FY 2044").click(); + + cy.get(".portfolios-combobox__control").click(); + cy.get(".portfolios-combobox__menu").contains("Child Care Research").click(); + + cy.get("button").contains("Apply").click(); + + // Verify applied filters are displayed + getAppliedFilters().within(() => { + cy.contains("FY 2044").should("exist"); + cy.contains("Child Care Research").should("exist"); + }); + + // Reopen filter and select additional values WITHOUT applying + cy.get("button").contains("Filter").click(); + + cy.get(".project-type-combobox__control").click(); + cy.get(".project-type-combobox__menu").contains("Admin & Support").click(); + + // Verify the unapplied value is selected in the dropdown + cy.get(".project-type-combobox__control").should("contain", "Admin & Support"); + + // Close the filter without applying (click outside or press Escape) + cy.get("#filter-close").click(); // Click outside the filter modal + + // Reopen the filter + cy.get("button").contains("Filter").click(); + + // Verify applied filters are still selected + cy.get(".fiscal-year-combobox__control").should("contain", "FY 2044"); + cy.get(".portfolios-combobox__control").should("contain", "Child Care Research"); + + // Verify unapplied filter value (project type) was cleared + cy.get(".project-type-combobox__control").should("not.contain", "Admin & Support"); + + // Verify applied filter tags still exist + getAppliedFilters().within(() => { + cy.contains("FY 2044").should("exist"); + cy.contains("Child Care Research").should("exist"); + }); + + // Verify project type was not applied (should not have a tag) + getAppliedFilters().within(() => { + cy.contains("Admin & Support").should("not.exist"); + }); + }); }); diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js index 45fa7da306..7293fa3834 100644 --- a/frontend/src/api/opsAPI.js +++ b/frontend/src/api/opsAPI.js @@ -514,7 +514,7 @@ export const opsApi = createApi({ // project_type filter if (filters.projectType && filters.projectType.length > 0) { filters.projectType.forEach((type) => { - queryParams.push(`project_type=${type.name}`); + queryParams.push(`project_type=${type.id}`); }); } } diff --git a/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx b/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx index 65c1aea568..5af7485766 100644 --- a/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx +++ b/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx @@ -10,7 +10,7 @@ import ComboBox from "../../UI/Form/ComboBox"; * @param {string} [props.legendClassname] - Additional CSS classes to apply to the label/legend (optional). * @param {string} [props.defaultString] - Initial text to display in select (optional). * @param {Object} [props.overrideStyles] - Some CSS styles to override the default (optional). - * @param {boolean} props.isLoading - IS the data for the component loading or not + * @param {boolean} props.isLoading - Is the data for the component loading or not * @param {string} props.filterLabel - The title to display above the component. * @returns {React.ReactElement} - The rendered component. */ diff --git a/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx b/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx index ca85dea683..948458bb85 100644 --- a/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx +++ b/frontend/src/components/Projects/ProjectTypeComboBox/ProjectTypeComboBox.jsx @@ -21,13 +21,11 @@ export const ProjectTypeComboBox = ({ const projectTypeOptions = [ { id: PROJECT_TYPE_RESEARCH, - title: PROJECT_TYPE_LABELS[PROJECT_TYPE_RESEARCH], - name: PROJECT_TYPE_RESEARCH + title: PROJECT_TYPE_LABELS[PROJECT_TYPE_RESEARCH] }, { id: PROJECT_TYPE_ADMIN_SUPPORT, - title: PROJECT_TYPE_LABELS[PROJECT_TYPE_ADMIN_SUPPORT], - name: PROJECT_TYPE_ADMIN_SUPPORT + title: PROJECT_TYPE_LABELS[PROJECT_TYPE_ADMIN_SUPPORT] } ]; diff --git a/frontend/src/components/UI/FilterButton/FilterButton.jsx b/frontend/src/components/UI/FilterButton/FilterButton.jsx index 8a5bfb3d1c..e19ec224a4 100644 --- a/frontend/src/components/UI/FilterButton/FilterButton.jsx +++ b/frontend/src/components/UI/FilterButton/FilterButton.jsx @@ -9,11 +9,17 @@ import customStyles from "./FilterButton.module.css"; * @param {Function} props.applyFilter - A function to call after clicking the Apply button. * @param {Function} props.resetFilter - A function to call after clicking the Reset button. * @param {Object[]} props.fieldsetList - An array of fieldsets to display in the modal. + * @param {boolean} [props.showModal] - Optional controlled state for modal visibility. + * @param {Function} [props.setShowModal] - Optional controlled setter for modal visibility. * @param {boolean} [props.disabled] - Whether the button is disabled. * @returns {JSX.Element} - The procurement shop select element. */ -export const FilterButton = ({ applyFilter, resetFilter, fieldsetList, disabled = false }) => { - const [showModal, setShowModal] = React.useState(false); +export const FilterButton = ({ applyFilter, resetFilter, fieldsetList, showModal: externalShowModal, setShowModal: externalSetShowModal, disabled = false }) => { + const [internalShowModal, setInternalShowModal] = React.useState(false); + + // Use external state if provided, otherwise use internal state + const showModal = externalShowModal !== undefined ? externalShowModal : internalShowModal; + const setShowModal = externalSetShowModal || setInternalShowModal; const handleApplyFilter = () => { applyFilter(); diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js index 0a6c7543e1..72ad81b45b 100644 --- a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js @@ -5,8 +5,9 @@ import { getCurrentFiscalYear } from "../../../../helpers/utils"; * A filter for Projects list. * @param {import('./ProjectFilterTypes').Filters} filters - The current filters. * @param {Function} setFilters - A function to call to set the filters. + * @param {boolean} showModal - Whether the modal is currently open. */ -export const useProjectFilterButton = (filters, setFilters) => { +export const useProjectFilterButton = (filters, setFilters, showModal) => { const [fiscalYear, setFiscalYear] = React.useState( /** @type {import('./ProjectFilterTypes').FilterOption[]} */ ([]) ); @@ -22,6 +23,17 @@ export const useProjectFilterButton = (filters, setFilters) => { ); const currentFiscalYear = getCurrentFiscalYear(); + // Reset local state to match filters when modal opens (prevents stale selections from persisting) + React.useEffect(() => { + if (showModal) { + setFiscalYear(filters.fiscalYear ?? []); + setPortfolio(filters.portfolio ?? []); + setProjectSearch(filters.projectSearch ?? []); + setAgreementSearch(filters.agreementSearch ?? []); + setProjectType(filters.projectType ?? []); + } + }, [showModal, filters]); + // The useEffect() hook calls below are used to set the state appropriately when the filter tags (X) are clicked. React.useEffect(() => { if (filters.fiscalYear) { diff --git a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx index cd3838095b..493df1bbbe 100644 --- a/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx +++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx @@ -8,6 +8,8 @@ import AgreementNameComboBox from "../../../../components/Agreements/AgreementNa import FilterButton from "../../../../components/UI/FilterButton/FilterButton"; import useProjectFilterButton from "./ProjectFilterButton.hooks"; import { FILTER_MODAL_FULL_WIDTH } from "../../../../constants"; +import { useEffect } from "react"; +import React from "react"; /** * A filter for projects. @@ -19,6 +21,8 @@ import { FILTER_MODAL_FULL_WIDTH } from "../../../../constants"; * @returns {JSX.Element} - The project filter button component. */ export const ProjectFilterButton = ({ filters, setFilters, projectFilterOptions, isLoadingOptions = false }) => { + const [showModal, setShowModal] = React.useState(false); + const { fiscalYear, setFiscalYear, @@ -33,7 +37,7 @@ export const ProjectFilterButton = ({ filters, setFilters, projectFilterOptions, applyFilter, resetFilter, currentFiscalYear - } = useProjectFilterButton(filters, setFilters); + } = useProjectFilterButton(filters, setFilters, showModal); const fieldStyles = "usa-fieldset margin-bottom-205"; const legendStyles = `usa-legend font-sans-3xs margin-top-0 padding-bottom-1 ${customStyles.legendColor}`; @@ -114,13 +118,16 @@ export const ProjectFilterButton = ({ filters, setFilters, projectFilterOptions, ]; - Modal.setAppElement("#root"); - + useEffect(() => { + Modal.setAppElement("#root"); + }, []); return ( ); }; diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js index 9fe0d0aed4..c7244ff566 100644 --- a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js @@ -1,26 +1,7 @@ -import { useState, useEffect, useCallback } from "react"; -/** - * @typedef {Object} FYFilterItem - * @property {string} title - */ +import { useMemo } from "react"; /** - * @typedef {Object} PortfolioFilterItem - * @property {string} name - */ - -/** - * @typedef {Object} FilterItem - * @property {string} title - */ - -/** - * @typedef {Object} Filters - * @property {FYFilterItem[]} fiscalYear - * @property {PortfolioFilterItem[]} portfolio - * @property {FilterItem[]} projectSearch - * @property {FilterItem[]} agreementSearch - * @property {FilterItem[]} projectType + * @typedef {import("../ProjectFilterButton/ProjectFilterTypes.d.ts").Filters} Filters */ /** @@ -35,59 +16,24 @@ import { useState, useEffect, useCallback } from "react"; * @returns {Tag[]} */ export const useTagsList = (filters) => { - const [tagsList, setTagsList] = useState([]); - - /** - * @param {keyof Filters} filterKey - * @param {string} filterName - */ - const updateTags = useCallback( - (filterKey, filterName) => { - if (filterKey == "portfolio") { - const selectedTags = - filters[filterKey]?.map((item) => ({ - tagText: item.name, - filter: filterName - })) ?? []; - setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); - } else if (filterKey == "fiscalYear") { - const selectedTags = - filters[filterKey]?.map((item) => ({ - tagText: item.title, - filter: filterName - })) ?? []; - setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); - } else { - const selectedTags = - filters[filterKey]?.map((item) => ({ - tagText: item.title, - filter: filterName - })) ?? []; - setTagsList((prevState) => [...prevState.filter((t) => t.filter !== filterName), ...selectedTags]); - } - }, - [filters] - ); - - useEffect(() => { - updateTags("fiscalYear", "fiscalYear"); - }, [filters.fiscalYear, updateTags]); - - useEffect(() => { - updateTags("portfolio", "portfolio"); - }, [filters.portfolio, updateTags]); - - useEffect(() => { - updateTags("projectSearch", "projectSearch"); - }, [filters.projectSearch, updateTags]); - - useEffect(() => { - updateTags("agreementSearch", "agreementSearch"); - }, [filters.agreementSearch, updateTags]); + const tagsList = useMemo(() => { + // Map each filter key to the property name we need to extract + const propertyMap = { + portfolio: "name", + fiscalYear: "title", + projectSearch: "title", + agreementSearch: "title", + projectType: "title" + }; - useEffect(() => { - updateTags("projectType", "projectType"); - }, [filters.projectType, updateTags]); + // Transform all filters into tags in one pass + return Object.entries(propertyMap).flatMap(([filterKey, propertyName]) => + (filters[filterKey] ?? []).map((item) => ({ + tagText: item[propertyName], + filter: filterKey + })) + ); + }, [filters]); return tagsList; }; @@ -129,7 +75,5 @@ export const removeFilter = (tag, setFilters) => { projectType: prevState.projectType.filter((type) => type.title !== tag.tagText) })); break; - default: - console.warn(`Unknown filter type: ${tag.filter}`); } }; diff --git a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx index b8cd1d09a5..3bdd70c0d9 100644 --- a/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx +++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx @@ -34,8 +34,8 @@ describe("ProjectFilterTags", () => { const filtersWithFY = { ...mockFilters, fiscalYear: [ - { id: 2023, title: "2023" }, - { id: 2024, title: "2024" } + { id: 2023, title: "FY 2023" }, + { id: 2024, title: "FY 2024" } ] }; @@ -123,7 +123,7 @@ describe("ProjectFilterTags", () => { it("should render all filter types together", () => { const allFilters = { - fiscalYear: [{ id: 2023, title: "2023" }], + fiscalYear: [{ id: 2023, title: "FY 2023" }], portfolio: [{ id: 1, name: "Portfolio A" }], projectSearch: [{ title: "Project Alpha" }], agreementSearch: [{ title: "Agreement 1" }], @@ -149,8 +149,8 @@ describe("ProjectFilterTags", () => { const filtersWithFY = { ...mockFilters, fiscalYear: [ - { id: 2023, title: "2023" }, - { id: 2024, title: "2024" } + { id: 2023, title: "FY 2023" }, + { id: 2024, title: "FY 2024" } ] }; @@ -254,9 +254,9 @@ describe("ProjectFilterTags", () => { const filtersWithMultiple = { ...mockFilters, fiscalYear: [ - { id: 2023, title: "2023" }, - { id: 2024, title: "2024" }, - { id: 2025, title: "2025" } + { id: 2023, title: "FY 2023" }, + { id: 2024, title: "FY 2024" }, + { id: 2025, title: "FY 2025" } ] }; From ebdc9005d247b4a2a830f46304ce0bec5aa5a4b4 Mon Sep 17 00:00:00 2001 From: rajohnson90 Date: Tue, 5 May 2026 13:27:31 -0500 Subject: [PATCH 8/8] style: fixing formatting with prettier --- frontend/src/components/UI/FilterButton/FilterButton.jsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/UI/FilterButton/FilterButton.jsx b/frontend/src/components/UI/FilterButton/FilterButton.jsx index e19ec224a4..9abc00e64a 100644 --- a/frontend/src/components/UI/FilterButton/FilterButton.jsx +++ b/frontend/src/components/UI/FilterButton/FilterButton.jsx @@ -14,7 +14,14 @@ import customStyles from "./FilterButton.module.css"; * @param {boolean} [props.disabled] - Whether the button is disabled. * @returns {JSX.Element} - The procurement shop select element. */ -export const FilterButton = ({ applyFilter, resetFilter, fieldsetList, showModal: externalShowModal, setShowModal: externalSetShowModal, disabled = false }) => { +export const FilterButton = ({ + applyFilter, + resetFilter, + fieldsetList, + showModal: externalShowModal, + setShowModal: externalSetShowModal, + disabled = false +}) => { const [internalShowModal, setInternalShowModal] = React.useState(false); // Use external state if provided, otherwise use internal state