diff --git a/backend/openapi.yml b/backend/openapi.yml
index 5a959bb14e..e6a05a53ba 100644
--- a/backend/openapi.yml
+++ b/backend/openapi.yml
@@ -3564,9 +3564,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 ec937e06e7..94258466d1 100644
--- a/backend/ops_api/ops/schemas/projects.py
+++ b/backend/ops_api/ops/schemas/projects.py
@@ -208,7 +208,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 ce5ec2c24c..d7c278c7db 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
@@ -227,12 +226,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)
@@ -244,35 +241,63 @@ 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
@@ -287,55 +312,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
@@ -626,7 +674,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)
@@ -634,16 +682,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 7801972cd0..1cb0f59812 100644
--- a/backend/ops_api/tests/ops/project/test_project.py
+++ b/backend/ops_api/tests/ops/project/test_project.py
@@ -346,6 +346,35 @@ 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.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."""
response = auth_client.get(url_for("api.projects-group"))
@@ -1716,12 +1745,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..2e90ca5e14 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,314 @@ 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");
+ });
+
+ 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 fe43c75d85..7293fa3834 100644
--- a/frontend/src/api/opsAPI.js
+++ b/frontend/src/api/opsAPI.js
@@ -476,11 +476,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.id}`);
+ });
+ }
+ }
+
+ // 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}`);
@@ -535,6 +582,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 = [];
@@ -1150,6 +1201,7 @@ export const {
useGetProjectSpendingByIdQuery,
useGetAgreementSpendingByIdQuery,
useGetProjectFundingByIdQuery,
+ useGetProjectsFilterOptionsQuery,
useGetProjectsByPortfolioQuery,
useGetResearchProjectsQuery,
useGetResearchProjectsByPortfolioQuery,
diff --git a/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx b/frontend/src/components/Projects/ProjectTitleComboBox/ProjectTitleComboBox.jsx
index 59fb263aff..5af7485766 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}
{
+ const projectTypeOptions = [
+ {
+ id: PROJECT_TYPE_RESEARCH,
+ title: PROJECT_TYPE_LABELS[PROJECT_TYPE_RESEARCH]
+ },
+ {
+ id: PROJECT_TYPE_ADMIN_SUPPORT,
+ title: PROJECT_TYPE_LABELS[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/components/UI/FilterButton/FilterButton.jsx b/frontend/src/components/UI/FilterButton/FilterButton.jsx
index 8a5bfb3d1c..9abc00e64a 100644
--- a/frontend/src/components/UI/FilterButton/FilterButton.jsx
+++ b/frontend/src/components/UI/FilterButton/FilterButton.jsx
@@ -9,11 +9,24 @@ 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
new file mode 100644
index 0000000000..72ad81b45b
--- /dev/null
+++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.hooks.js
@@ -0,0 +1,111 @@
+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.
+ * @param {boolean} showModal - Whether the modal is currently open.
+ */
+export const useProjectFilterButton = (filters, setFilters, showModal) => {
+ 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();
+
+ // 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) {
+ 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..493df1bbbe
--- /dev/null
+++ b/frontend/src/pages/projects/list/ProjectFilterButton/ProjectFilterButton.jsx
@@ -0,0 +1,135 @@
+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";
+import { useEffect } from "react";
+import React from "react";
+
+/**
+ * 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 [showModal, setShowModal] = React.useState(false);
+
+ const {
+ fiscalYear,
+ setFiscalYear,
+ portfolio,
+ setPortfolio,
+ projectSearch,
+ setProjectSearch,
+ agreementSearch,
+ setAgreementSearch,
+ projectType,
+ setProjectType,
+ applyFilter,
+ resetFilter,
+ currentFiscalYear
+ } = 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}`;
+
+ const fieldsetList = [
+ ,
+ ,
+ ,
+ ,
+
+ ];
+
+ useEffect(() => {
+ 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..c7244ff566
--- /dev/null
+++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.hooks.js
@@ -0,0 +1,79 @@
+import { useMemo } from "react";
+
+/**
+ * @typedef {import("../ProjectFilterButton/ProjectFilterTypes.d.ts").Filters} Filters
+ */
+
+/**
+ * @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 = 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"
+ };
+
+ // 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;
+};
+
+/**
+ * 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) => 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;
+ }
+};
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..3bdd70c0d9
--- /dev/null
+++ b/frontend/src/pages/projects/list/ProjectFilterTags/ProjectFilterTags.test.jsx
@@ -0,0 +1,286 @@
+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: "FY 2023" },
+ { id: 2024, title: "FY 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: "FY 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: "FY 2023" },
+ { id: 2024, title: "FY 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: "FY 2023" },
+ { id: 2024, title: "FY 2024" },
+ { id: 2025, title: "FY 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={