feat: add project filtering with fix for multi-filter queries#5614
feat: add project filtering with fix for multi-filter queries#5614rajohnson90 wants to merge 9 commits intomainfrom
Conversation
There was a problem hiding this comment.
Bug: Pagination not reset when filters change
ProjectsList.jsx:69-71 — the useEffect that resets pagination only depends on sort/fiscal year.
filters is missing from the dependency array. If a user is on page 3 and applies a filter that returns fewer results, they'll see an empty page. filters needs to be added here.
johndeange
left a comment
There was a problem hiding this comment.
Overview
Adds project filtering UI (modal + tags) and fixes a backend bug where combining fiscal year + agreement filters returned empty results. The EXISTS-subquery approach is sound and matches patterns used elsewhere in the codebase. Two blocking issues before merge.
🔴 Blocking
1. Broken assertion in new backend test
backend/ops_api/tests/ops/project/test_project.py:360
assert response_research == 200response_research is a TestResponse object, not an int — this comparison is always False. Should be:
assert response_research.status_code == 200This test is the whole motivation for the backend fix, so it needs to actually execute. Consider also adding a negative case (agreement matches but FY doesn't → empty result).
2. Frontend/backend contract mismatch on `agreement_names`
Backend now returns `agreement_names: [{id, name}]`, but `frontend/src/api/opsAPI.js:486` reads `.title`:
queryParams.push(\`agreement_search=\${encodeURIComponent(agreement.title)}\`);If `AgreementNameComboBox` transforms `{id, name}` → items with `title` before they land in `agreementSearch` state, this works — but the transform isn't visible in this diff and is easy to break. Please verify end-to-end and ideally add a test that asserts the produced query string. The mocked combobox in `ProjectFilterButton.test.jsx` uses `{title: "Agreement 1"}`, so the unit tests wouldn't catch a shape mismatch.
Important
Inconsistent shape for `projectType` filter
`opsAPI.js:500` sends `type.name`, but `ProjectFilterTags.hooks.js:111` removes by `type.title`, and tests use both `{title: "RESEARCH"}` and `{name: PROJECT_TYPE_RESEARCH}`. Pick one key (recommend `name` since it's an enum value) and apply everywhere.
`Modal.setAppElement("#root")` inside render
`ProjectFilterButton.jsx:109` — runs on every render. Move to module level or a `useEffect` with empty deps.
`useTagsList` is more complex than needed
`ProjectFilterTags.hooks.js` has 5 `useEffect`s, a `useCallback`, and three near-duplicate branches that differ only by `item.name` vs `item.title`. This can collapse to a single `useMemo` derived from `filters` — no state, no effects.
const tagsList = useMemo(() => {
const map = { portfolio: 'name', fiscalYear: 'title', projectSearch: 'title', agreementSearch: 'title', projectType: 'title' };
return Object.entries(map).flatMap(([key, prop]) =>
(filters[key] ?? []).map(item => ({ tagText: item[prop], filter: key }))
);
}, [filters]);`useProjectFilterButton` sync pattern
The 5 `useEffect`s that copy `filters.X` → local state have a subtle UX quirk: if the user opens the modal, makes selections, closes without Apply, the internal state persists on re-open. Confirm this matches the design.
Minor
- `console.warn` in `removeFilter` default case — unreachable with current types, remove it.
- `ProjectFilterTypes.d.ts` defines `Filters` but `ProjectFilterTags.hooks.js` redefines it via JSDoc — consolidate.
- `ProjectTitleComboBox.jsx:13-14` JSDoc typo: "IS the data for" → "Is the data for".
- `.distinct(ResearchProject.id)` is kept but no longer needed now that outer joins are gone — can be removed.
- PR description's A11y checklist is all unchecked, and "Screenshots" section appears twice.
- Cypress `cy.wait(1000)` calls — prefer asserting on a changed-state condition rather than a fixed wait.
- `selectinload` chains are duplicated verbatim between the two query builders — candidate for a shared helper if we want to DRY further.
Strengths
- EXISTS-subquery fix is correct and addresses a real user-facing bug.
- Good Cypress coverage across filter combinations.
- Thorough unit tests on the filter button modal.
- No injection concerns — all values go through SQLAlchemy parameter binding.
Requesting changes for the two blocking items; the rest are polish.
johndeange
left a comment
There was a problem hiding this comment.
Re-review after "respond to pr comments" commit
Both blocking issues from my previous review are resolved:
- ✅
assert response_research.status_code == 200fixed, negative case added - ✅
Modal.setAppElementmoved touseEffect - ✅
useTagsListcollapsed to a singleuseMemo(much cleaner) - ✅
console.warnremoved, type defs consolidated
Minor items for follow-up
- Pagination reset on filter change — Wei flagged that the pagination
useEffectdoesn't depend onfilters. Please confirm this is handled (here or follow-up). agreement_searchdata shape — pipeline works (E2E proves it), but the{id, name}→{id, title}transform inAgreementNameComboBoxisn't visible in this diff. A brief comment would help the next dev.projectTypenaming asymmetry —opsAPI.jssendstype.id,removeFilterfilters bytype.title. Works because both exist on each item, but naming is inconsistent across the filter lifecycle.
None of these are blocking. Approving — nice work on the EXISTS-subquery fix and the filter UI. 👍
What changed
This PR adds comprehensive project filtering to the Projects list page and fixes a critical backend bug that prevented multiple filters from working together correctly.
Backend query fix (
backend/ops_api/ops/services/projects.py)Replaced LEFT OUTER JOIN-based filtering with EXISTS subqueries in
_get_research_projects_queryand_get_administrative_and_support_projects_query. The original approach required a single joined row to satisfy all filter conditions simultaneously, which failed when:The new approach uses independent EXISTS subqueries for each filter (portfolio, fiscal year, agreement search), allowing each filter to match different related records while still applying AND logic at the project level.
Frontend filtering UI (
frontend/src/)ProjectFilterButton/: New modal-based filter component with multi-select dropdowns for fiscal year, portfolio, project title, project type, and agreement nameProjectFilterTags/: Displays active filters as removable tags with clear visual feedbackProjectTypeComboBox/: Reusable project type selector componentProjectsList.jsxto integrate filtering UI and handle filter stateAPI enhancement
Agreement names in the
/projects/filtersendpoint now return{id, name}objects instead of plain strings, aligning with other filter options and enabling better frontend handling.Tests
Issue
#5241
How to test
Backend tests:
cd backend/ops_api pipenv run pytest tests/ops/project/test_project.py::test_agreement_and_fiscal_year_filter -xvs pipenv run pytest tests/ops/project/test_project.py::TestProjectFilterOptions -xvsFrontend E2E tests:
Manual verification:
/projectsA11y impact
Screenshots
The image below shows the UI element

Definition of Done Checklist
Links
Related context: