diff --git a/backend/openapi.yml b/backend/openapi.yml
index 0ad0ac591e..6662f238e6 100644
--- a/backend/openapi.yml
+++ b/backend/openapi.yml
@@ -4747,9 +4747,26 @@ paths:
content:
application/json:
schema:
- type: array
- items:
- $ref: "#/components/schemas/GenericChangeRequest"
+ type: object
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/GenericChangeRequest"
+ count:
+ type: integer
+ description: Total number of change requests after permission filtering, before pagination
+ limit:
+ type: integer
+ description: Maximum number of change requests returned
+ offset:
+ type: integer
+ description: Number of change requests skipped
+ required:
+ - data
+ - count
+ - limit
+ - offset
"400":
description: Bad Request
"401":
diff --git a/backend/ops_api/ops/resources/change_requests.py b/backend/ops_api/ops/resources/change_requests.py
index a1347c1f3e..9600e6dcd5 100644
--- a/backend/ops_api/ops/resources/change_requests.py
+++ b/backend/ops_api/ops/resources/change_requests.py
@@ -52,13 +52,17 @@ def get(self) -> Response:
}
service = ChangeRequestService(current_app.db_session)
- change_requests, _ = service.get_list(data=filters)
-
- if change_requests:
- response = make_response_with_headers(self._response_schema_collection.dump(change_requests))
- else:
- response = make_response_with_headers([], 200)
- return response
+ change_requests, metadata = service.get_list(data=filters)
+
+ data = self._response_schema_collection.dump(change_requests) if change_requests else []
+ return make_response_with_headers(
+ {
+ "data": data,
+ "count": metadata["count"],
+ "limit": metadata["limit"],
+ "offset": metadata["offset"],
+ }
+ )
class ChangeRequestItemAPI(BaseItemAPI):
diff --git a/backend/ops_api/ops/services/change_requests.py b/backend/ops_api/ops/services/change_requests.py
index c66bd73c2b..791a804a15 100644
--- a/backend/ops_api/ops/services/change_requests.py
+++ b/backend/ops_api/ops/services/change_requests.py
@@ -117,13 +117,14 @@ def get_list(self, data: dict | None = None) -> tuple[list[ChangeRequest], dict
reviewer_user_id = data.get("reviewer_user_id")
if not reviewer_user_id:
raise ValidationError({"reviewer_user_id": "This field is required."})
- results = find_in_review_requests_by_user(
+ # Get in review users, provide default limit of 10 and offset of 0
+ page, total_count = find_in_review_requests_by_user(
int(reviewer_user_id),
- data.get("limit"),
- data.get("offset"),
+ data.get("limit", 10),
+ data.get("offset", 0),
)
- return results, None
+ return page, {"count": total_count, "limit": data.get("limit"), "offset": data.get("offset")}
# --- Review & Authorization Logic ---
diff --git a/backend/ops_api/ops/utils/change_requests_helpers.py b/backend/ops_api/ops/utils/change_requests_helpers.py
index 7fad9cce6f..e14e5ca5e7 100644
--- a/backend/ops_api/ops/utils/change_requests_helpers.py
+++ b/backend/ops_api/ops/utils/change_requests_helpers.py
@@ -1,19 +1,22 @@
from typing import Type
from flask import current_app
-from sqlalchemy import or_, select
+from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import with_polymorphic
from models import (
+ CAN,
+ Agreement,
AgreementChangeRequest,
+ BudgetLineItem,
BudgetLineItemChangeRequest,
BudgetLineItemStatus,
ChangeRequest,
ChangeRequestStatus,
ChangeRequestType,
Division,
+ Portfolio,
)
-from ops_api.ops.utils.agreements_helpers import get_division_directors_for_agreement
from ops_api.ops.utils.budget_line_items_helpers import (
convert_BLI_status_name_to_pretty_string,
)
@@ -69,30 +72,51 @@ def get_division_ids_user_can_review_for(user_id: int) -> set[int]:
return {row[0] for row in current_app.db_session.execute(stmt).all()}
+def _get_reviewable_agreement_ids_subquery(user_id: int):
+ """Subquery returning agreement IDs where the user is a division director or deputy via BLI→CAN→Portfolio→Division."""
+ return (
+ select(Agreement.id)
+ .distinct()
+ .join(Agreement.budget_line_items)
+ .join(BudgetLineItem.can)
+ .join(CAN.portfolio)
+ .join(Portfolio.division)
+ .where(
+ or_(
+ Division.division_director_id == user_id,
+ Division.deputy_division_director_id == user_id,
+ )
+ )
+ .scalar_subquery()
+ )
+
+
def find_in_review_requests_by_user(user_id: int, limit: int = 10, offset: int = 0):
- # Prepare polymorphic loading to access subtype fields
cr_poly = with_polymorphic(ChangeRequest, [AgreementChangeRequest, BudgetLineItemChangeRequest])
- # Get explicit divisions
reviewable_division_ids = get_division_ids_user_can_review_for(user_id)
+ reviewable_agreement_ids_subq = _get_reviewable_agreement_ids_subquery(user_id)
- # Query all in-review change requests
- stmt = select(cr_poly).where(cr_poly.status == ChangeRequestStatus.IN_REVIEW).limit(limit).offset(offset)
-
- results = current_app.db_session.execute(stmt).scalars().all()
- filtered_results = []
+ bli_filter = and_(
+ ChangeRequest.change_request_type == ChangeRequestType.BUDGET_LINE_ITEM_CHANGE_REQUEST,
+ BudgetLineItemChangeRequest.managing_division_id.in_(reviewable_division_ids),
+ )
+ agr_filter = and_(
+ ChangeRequest.change_request_type == ChangeRequestType.AGREEMENT_CHANGE_REQUEST,
+ AgreementChangeRequest.agreement_id.in_(reviewable_agreement_ids_subq),
+ )
- # Filter results based on user's review permissions
- for cr in results:
- if isinstance(cr, BudgetLineItemChangeRequest):
- if cr.managing_division_id in reviewable_division_ids:
- filtered_results.append(cr)
- elif isinstance(cr, AgreementChangeRequest):
- division_directors, deputies = get_division_directors_for_agreement(cr.agreement)
- if user_id in division_directors or user_id in deputies:
- filtered_results.append(cr)
+ stmt = (
+ select(cr_poly)
+ .where(ChangeRequest.status == ChangeRequestStatus.IN_REVIEW)
+ .where(or_(bli_filter, agr_filter))
+ .order_by(ChangeRequest.id.desc())
+ )
- return filtered_results
+ count_stmt = select(func.count()).select_from(stmt.subquery())
+ total_count = current_app.db_session.execute(count_stmt).scalar()
+ results = current_app.db_session.execute(stmt.limit(limit).offset(offset)).scalars().all()
+ return results, total_count
def build_review_outcome_title_and_message(
diff --git a/backend/ops_api/tests/ops/agreement/test_agreement_change_requests.py b/backend/ops_api/tests/ops/agreement/test_agreement_change_requests.py
index 780c531b8f..551e613b05 100644
--- a/backend/ops_api/tests/ops/agreement/test_agreement_change_requests.py
+++ b/backend/ops_api/tests/ops/agreement/test_agreement_change_requests.py
@@ -238,8 +238,8 @@ def test_update_procurement_shop_creates_change_request_e2e(
)
assert request.status_code == 200
- assert len(request.json) == 1
- assert matching_crs[0].id == request.json[0]["id"]
+ assert len(request.json["data"]) == 1
+ assert matching_crs[0].id == request.json["data"][0]["id"]
# approve the change request
response = division_director_auth_client.patch(
diff --git a/backend/ops_api/tests/ops/services/test_change_requests.py b/backend/ops_api/tests/ops/services/test_change_requests.py
index 34df7d66ff..d1e336e930 100644
--- a/backend/ops_api/tests/ops/services/test_change_requests.py
+++ b/backend/ops_api/tests/ops/services/test_change_requests.py
@@ -94,10 +94,10 @@ def test_delete_not_found(service, mock_db_session):
def test_get_list_uses_find_in_review_requests_by_user(service):
with patch("ops_api.ops.services.change_requests.find_in_review_requests_by_user") as mock_find:
- mock_find.return_value = [Mock()]
- results, pagination = service.get_list({"reviewer_user_id": 1})
+ mock_find.return_value = [Mock()], 1
+ results, pagination = service.get_list({"reviewer_user_id": 1, "limit": 10, "offset": 0})
assert isinstance(results, list)
assert len(results) == 1
- assert pagination is None
- mock_find.assert_called_once_with(1, None, None)
+ assert pagination == {"count": 1, "limit": 10, "offset": 0}
+ mock_find.assert_called_once_with(1, 10, 0)
diff --git a/backend/ops_api/tests/ops/utils/test_change_requests_helpers.py b/backend/ops_api/tests/ops/utils/test_change_requests_helpers.py
index 5e29259839..ca027a7694 100644
--- a/backend/ops_api/tests/ops/utils/test_change_requests_helpers.py
+++ b/backend/ops_api/tests/ops/utils/test_change_requests_helpers.py
@@ -4,6 +4,7 @@
from models import (
AgreementChangeRequest,
+ BudgetLineItem,
BudgetLineItemChangeRequest,
BudgetLineItemStatus,
ChangeRequest,
@@ -64,6 +65,45 @@ def test_build_approve_url_raises_on_unrecognized():
cr_helpers.build_approve_url(cr, agreement_id=1, fe_url="http://localhost")
+def test_find_in_review_requests_by_user_with_pagination(loaded_db, app_ctx):
+ dave_director = loaded_db.get(User, 522) # Dave Director — division 4
+ director_derrek = loaded_db.get(User, 525) # Director Derrek
+
+ # Assign Derrek as director of division 6
+ division_6: Division = loaded_db.get(Division, 6)
+ division_6.division_director_id = director_derrek.id
+ loaded_db.flush()
+
+ bli = loaded_db.get(BudgetLineItem, 15000)
+
+ def make_bli_cr(division_id: int) -> BudgetLineItemChangeRequest:
+ cr = BudgetLineItemChangeRequest()
+ cr.status = ChangeRequestStatus.IN_REVIEW
+ cr.budget_line_item_id = bli.id
+ cr.agreement_id = bli.agreement_id
+ cr.created_by = dave_director.id
+ cr.managing_division_id = division_id
+ cr.requested_change_data = {"amount": 100}
+ loaded_db.add(cr)
+ return cr
+
+ cr1 = make_bli_cr(4)
+ cr2 = make_bli_cr(4)
+ cr3 = make_bli_cr(4)
+ cr4 = make_bli_cr(6)
+ loaded_db.flush()
+
+ # Derrek can only review division 6; with a page size of 3 his lone CR still appears
+ results, total = cr_helpers.find_in_review_requests_by_user(user_id=director_derrek.id, limit=3, offset=0)
+ result_ids = [r.id for r in results]
+ assert cr1.id not in result_ids
+ assert cr2.id not in result_ids
+ assert cr3.id not in result_ids
+ assert cr4.id in result_ids
+ assert len(results) == 1
+ assert total == 1
+
+
def test_get_division_ids_user_can_review_for(loaded_db, app_ctx):
director = User(
first_name="Jane",
@@ -108,28 +148,23 @@ def test_get_division_ids_user_can_review_for(loaded_db, app_ctx):
@patch("ops_api.ops.utils.change_requests_helpers.current_app", new_callable=Mock)
@patch("ops_api.ops.utils.change_requests_helpers.get_division_ids_user_can_review_for")
-@patch("ops_api.ops.utils.change_requests_helpers.get_division_directors_for_agreement")
-def test_find_in_review_requests_by_user(mock_get_division_directors, mock_get_division_ids, mock_app, app_ctx):
- # Mocks 3 change requests
+def test_find_in_review_requests_by_user(mock_get_division_ids, mock_app, app_ctx):
cr1 = Mock(spec=BudgetLineItemChangeRequest, managing_division_id=1)
- cr2 = Mock(spec=AgreementChangeRequest, agreement="AGREEMENT")
- cr3 = Mock(spec=BudgetLineItemChangeRequest, managing_division_id=999) # Should be filtered out
+ cr2 = Mock(spec=AgreementChangeRequest, agreement_id=42)
- # Mock the return values for the database session to return the 3 change requests
- mock_app.db_session.execute.return_value.scalars.return_value.all.return_value = [
- cr1,
- cr2,
- cr3,
- ]
+ # execute is called twice: first for COUNT, then for the paginated results
+ count_result = Mock()
+ count_result.scalar.return_value = 2
+ rows_result = Mock()
+ rows_result.scalars.return_value.all.return_value = [cr1, cr2]
+ mock_app.db_session.execute.side_effect = [count_result, rows_result]
- # Mocks the helper functions
mock_get_division_ids.return_value = {1}
- mock_get_division_directors.return_value = ([123], [])
- result = cr_helpers.find_in_review_requests_by_user(user_id=123)
+ result, total = cr_helpers.find_in_review_requests_by_user(user_id=123)
assert cr1 in result
assert cr2 in result
- assert cr3 not in result # cr3 should be filtered out due to managing_division_id not being in {1}
+ assert total == 2
@pytest.mark.parametrize(
diff --git a/backend/ops_api/tests/ops/workflows/test_change_requests.py b/backend/ops_api/tests/ops/workflows/test_change_requests.py
index 010a4c303c..dc6c1f2cb3 100644
--- a/backend/ops_api/tests/ops/workflows/test_change_requests.py
+++ b/backend/ops_api/tests/ops/workflows/test_change_requests.py
@@ -239,7 +239,7 @@ def test_change_request_list(auth_client, app, test_user, test_admin_user, test_
# verify no change request in list to review for this user
response = auth_client.get(url_for("api.change-requests-list"), query_string={"userId": test_admin_user.id})
assert response.status_code == 200
- assert len(response.json) == 0
+ assert len(response.json["data"]) == 0
# create a change request
change_request1 = BudgetLineItemChangeRequest()
@@ -264,8 +264,9 @@ def test_change_request_list(auth_client, app, test_user, test_admin_user, test_
# verify there is one change request in the list to review for this user
response = auth_client.get(url_for("api.change-requests-list"), query_string={"userId": test_admin_user.id})
assert response.status_code == 200
- assert len(response.json) == 1
- cr1 = response.json[0]
+ assert response.json["count"] == 1
+ assert len(response.json["data"]) == 1
+ cr1 = response.json["data"][0]
assert "has_budget_change" in cr1
assert not cr1["has_status_change"]
assert "has_status_change" in cr1
@@ -287,7 +288,8 @@ def test_change_request_list(auth_client, app, test_user, test_admin_user, test_
# verify there is two change requests in the list to review for this user
response = auth_client.get(url_for("api.change-requests-list"), query_string={"userId": test_admin_user.id})
assert response.status_code == 200
- assert len(response.json) == 2
+ assert response.json["count"] == 2
+ assert len(response.json["data"]) == 2
# review (approve/reject) the change requests
change_request1.status = ChangeRequestStatus.APPROVED
@@ -300,7 +302,7 @@ def test_change_request_list(auth_client, app, test_user, test_admin_user, test_
response = auth_client.get(url_for("api.change-requests-list"), query_string={"userId": test_admin_user.id})
assert response.status_code == 200
- assert len(response.json) == 0
+ assert len(response.json["data"]) == 0
# cleanup
division1.division_director_id = 522
diff --git a/frontend/cypress.config.ci.js b/frontend/cypress.config.ci.js
index 20a4b2154d..7078de9685 100644
--- a/frontend/cypress.config.ci.js
+++ b/frontend/cypress.config.ci.js
@@ -55,7 +55,8 @@ export default defineConfig({
"cypress/e2e/procurementTracker.cy.js",
"cypress/e2e/procurementTrackerReadOnly.cy.js",
"cypress/e2e/reportingPage.cy.js",
- "cypress/e2e/procurementDashboard.cy.js"
+ "cypress/e2e/procurementDashboard.cy.js",
+ "cypress/e2e/changeRequestsPagination.cy.js"
],
// Adding custom task logging, for better a11y output
// ref: https://docs.cypress.io/api/commands/task#Usage
diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js
index bf83724fae..dab8728a1d 100644
--- a/frontend/cypress.config.js
+++ b/frontend/cypress.config.js
@@ -50,7 +50,8 @@ export default defineConfig({
"cypress/e2e/procurementTracker.cy.js",
"cypress/e2e/procurementTrackerReadOnly.cy.js",
"cypress/e2e/reportingPage.cy.js",
- "cypress/e2e/procurementDashboard.cy.js"
+ "cypress/e2e/procurementDashboard.cy.js",
+ "cypress/e2e/changeRequestsPagination.cy.js"
],
// Adding custom task logging, for better a11y output
// ref: https://docs.cypress.io/api/commands/task#Usage
diff --git a/frontend/cypress/e2e/agreementsPagination.cy.js b/frontend/cypress/e2e/agreementsPagination.cy.js
index ef1c7b8fd2..e25bf7c520 100644
--- a/frontend/cypress/e2e/agreementsPagination.cy.js
+++ b/frontend/cypress/e2e/agreementsPagination.cy.js
@@ -1,5 +1,5 @@
///
-import {terminalLog, testLogin} from "./utils";
+import { terminalLog, testLogin } from "./utils";
describe("Agreements List - Pagination", () => {
beforeEach(() => {
@@ -66,10 +66,12 @@ describe("Agreements List - Pagination", () => {
it("should hide Next button on last page", () => {
// Navigate to last page (need to find what the last page is)
// Get all page number buttons
- cy.get("button[aria-label^='Page']").last().then(($lastButton) => {
- // Click the last page number
- $lastButton.click();
- });
+ cy.get("button[aria-label^='Page']")
+ .last()
+ .then(($lastButton) => {
+ // Click the last page number
+ $lastButton.click();
+ });
// Next button should be hidden on last page
cy.get("button[aria-label='Next page']").should("not.be.visible");
diff --git a/frontend/cypress/e2e/agreementsPaginationSorting.cy.js b/frontend/cypress/e2e/agreementsPaginationSorting.cy.js
index 847218d439..534d8405d9 100644
--- a/frontend/cypress/e2e/agreementsPaginationSorting.cy.js
+++ b/frontend/cypress/e2e/agreementsPaginationSorting.cy.js
@@ -1,5 +1,5 @@
///
-import {terminalLog, testLogin} from "./utils";
+import { terminalLog, testLogin } from "./utils";
describe("Agreements List - Pagination Sorting", () => {
beforeEach(() => {
@@ -22,7 +22,7 @@ describe("Agreements List - Pagination Sorting", () => {
cy.get("thead th").contains("Agreement").click();
// Wait for sorted data to load
- cy.get(".usa-table tbody tr", {timeout: 10000}).should("have.length.at.least", 1);
+ cy.get(".usa-table tbody tr", { timeout: 10000 }).should("have.length.at.least", 1);
// Should reset to page 1
cy.get("button.usa-current").should("contain", "1");
@@ -33,7 +33,7 @@ describe("Agreements List - Pagination Sorting", () => {
cy.get("thead th").contains("Agreement").click();
// Wait for sorted data to load
- cy.get(".usa-table tbody tr", {timeout: 10000}).should("have.length.at.least", 1);
+ cy.get(".usa-table tbody tr", { timeout: 10000 }).should("have.length.at.least", 1);
// Navigate to page 2 and verify data loads
cy.get("button[aria-label='Next page']").then(($nextBtn) => {
@@ -41,7 +41,7 @@ describe("Agreements List - Pagination Sorting", () => {
cy.wrap($nextBtn).click();
// Verify page 2 has data (sort order is maintained)
- cy.get("tbody tr", {timeout: 10000}).should("have.length.at.least", 1);
+ cy.get("tbody tr", { timeout: 10000 }).should("have.length.at.least", 1);
}
});
});
diff --git a/frontend/cypress/e2e/approveChangeRequestsAtAgreementLevel.cy.js b/frontend/cypress/e2e/approveChangeRequestsAtAgreementLevel.cy.js
index 26e65d59b8..5ab27d9320 100644
--- a/frontend/cypress/e2e/approveChangeRequestsAtAgreementLevel.cy.js
+++ b/frontend/cypress/e2e/approveChangeRequestsAtAgreementLevel.cy.js
@@ -194,7 +194,7 @@ describe("Approve Change Requests at the Agreement Level", () => {
cy.wait(1000);
// Increase timeout for CI environments where page rendering can be slower
// Check for alert in a single assertion chain so Cypress retries the entire check
- cy.get(".usa-alert__body", {timeout: 30000})
+ cy.get(".usa-alert__body", { timeout: 30000 })
.should("be.visible")
.and("contain", "Changes Approved")
.and("contain", testAgreement.name)
@@ -372,9 +372,9 @@ describe("Approve Change Requests at the Agreement Level", () => {
cy.wait(500);
// Increase timeout for CI environments where page rendering can be slower
// First check if alert exists and log its content for debugging
- cy.get(".usa-alert__body", {timeout: 30000}).should("exist");
+ cy.get(".usa-alert__body", { timeout: 30000 }).should("exist");
// Check for alert in a single assertion chain so Cypress retries the entire check
- cy.get(".usa-alert__body", {timeout: 30000})
+ cy.get(".usa-alert__body", { timeout: 30000 })
.should("be.visible")
.and("contain", "Changes Approved")
.and("contain", testAgreement.name)
diff --git a/frontend/cypress/e2e/approveCrossDivisionCRs.cy.js b/frontend/cypress/e2e/approveCrossDivisionCRs.cy.js
index 56d127167d..3870fe7849 100644
--- a/frontend/cypress/e2e/approveCrossDivisionCRs.cy.js
+++ b/frontend/cypress/e2e/approveCrossDivisionCRs.cy.js
@@ -92,7 +92,7 @@ describe("Approve Cross Division Change Requests", () => {
}
})
.then((changeRequestsResponse) => {
- const pendingChangeRequestIds = (changeRequestsResponse.body || [])
+ const pendingChangeRequestIds = (changeRequestsResponse.body?.data || [])
.filter((changeRequest) => changeRequest.status === "IN_REVIEW")
.filter((changeRequest) => !preserveChangeRequestIds.includes(changeRequest.id))
.map((changeRequest) => changeRequest.id);
@@ -143,54 +143,59 @@ describe("Approve Cross Division Change Requests", () => {
// create BLI WITHIN division
.then((agreementId) => {
const bliData = { ...testBLIWithinDivision, agreement_id: agreementId };
- return cy.request({
- method: "POST",
- url: "http://localhost:8080/api/v1/budget-line-items/",
- body: bliData,
- headers: {
- Authorization: bearer_token,
- Accept: "application/json"
- }
- }).then((response) => {
- expect(response.status).to.eq(201);
- expect(response.body.id).to.exist;
- const bliId1 = response.body.id;
-
- // Create the second BLI (outside division)
- const bliData2 = { ...testBLIOutsideDivision, agreement_id: agreementId };
- return cy.request({
+ return cy
+ .request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
- body: bliData2,
+ body: bliData,
headers: {
Authorization: bearer_token,
Accept: "application/json"
}
- }).then((response2) => {
- expect(response2.status).to.eq(201);
- expect(response2.body.id).to.exist;
- const bliId2 = response2.body.id;
- return { agreementId, bliId1, bliId2 };
+ })
+ .then((response) => {
+ expect(response.status).to.eq(201);
+ expect(response.body.id).to.exist;
+ const bliId1 = response.body.id;
+
+ // Create the second BLI (outside division)
+ const bliData2 = { ...testBLIOutsideDivision, agreement_id: agreementId };
+ return cy
+ .request({
+ method: "POST",
+ url: "http://localhost:8080/api/v1/budget-line-items/",
+ body: bliData2,
+ headers: {
+ Authorization: bearer_token,
+ Accept: "application/json"
+ }
+ })
+ .then((response2) => {
+ expect(response2.status).to.eq(201);
+ expect(response2.body.id).to.exist;
+ const bliId2 = response2.body.id;
+ return { agreementId, bliId1, bliId2 };
+ });
});
- });
})
// submit PATCH CR for approval via REST
.then(({ agreementId, bliId1, bliId2 }) => {
let crId1;
// Submit PATCH for first BLI
- return cy.request({
- method: "PATCH",
- url: `http://localhost:8080/api/v1/budget-line-items/${bliId1}`,
- body: {
- id: bliId1,
- status: BLI_STATUS.PLANNED,
- requestor_notes: "Test requestor notes for BLI 1"
- },
- headers: {
- Authorization: bearer_token,
- Accept: "application/json"
- }
- })
+ return cy
+ .request({
+ method: "PATCH",
+ url: `http://localhost:8080/api/v1/budget-line-items/${bliId1}`,
+ body: {
+ id: bliId1,
+ status: BLI_STATUS.PLANNED,
+ requestor_notes: "Test requestor notes for BLI 1"
+ },
+ headers: {
+ Authorization: bearer_token,
+ Accept: "application/json"
+ }
+ })
.then((response) => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
diff --git a/frontend/cypress/e2e/changeRequestsPagination.cy.js b/frontend/cypress/e2e/changeRequestsPagination.cy.js
new file mode 100644
index 0000000000..33783dda48
--- /dev/null
+++ b/frontend/cypress/e2e/changeRequestsPagination.cy.js
@@ -0,0 +1,152 @@
+///
+
+import { BLI_STATUS } from "../../src/helpers/budgetLines.helpers";
+import { terminalLog, testLogin } from "./utils";
+
+const TOTAL_BLIS = 11;
+const PAGE_SIZE = 10;
+
+const testAgreement = {
+ agreement_type: "CONTRACT",
+ agreement_reason: "NEW_REQ",
+ name: `E2E CR Pagination ${Date.now()}`,
+ contract_type: "FIRM_FIXED_PRICE",
+ description: "Test pagination of change requests list",
+ service_requirement_type: "NON_SEVERABLE",
+ project_id: 1000,
+ product_service_code_id: 1,
+ awarding_entity_id: 2,
+ project_officer_id: 500,
+ team_members: [{ id: 520 }, { id: 504 }]
+};
+
+// CAN 501 is in a portfolio under division 4 (Dave Director's division)
+const baseBli = {
+ line_description: "SC1",
+ can_id: 501,
+ amount: 1_000_000,
+ status: BLI_STATUS.DRAFT,
+ date_needed: "2044-01-01",
+ proc_shop_fee_percentage: 5,
+ services_component_id: 1
+};
+
+describe("Change Requests List - Pagination", () => {
+ let agreementId;
+ let bliIds = [];
+ let budgetTeamToken;
+
+ before(() => {
+ // Create agreement and 11 BLIs as budget-team, then patch each DRAFT→PLANNED to create CRs
+ testLogin("budget-team");
+ cy.then(() => {
+ budgetTeamToken = `Bearer ${window.localStorage.getItem("access_token")}`;
+
+ cy.request({
+ method: "POST",
+ url: "http://localhost:8080/api/v1/agreements/",
+ body: { ...testAgreement, name: `E2E CR Pagination ${Date.now()}` },
+ headers: { Authorization: budgetTeamToken, "Content-Type": "application/json" }
+ }).then((res) => {
+ expect(res.status).to.eq(201);
+ agreementId = res.body.id;
+
+ // Create TOTAL_BLIS BLIs sequentially then patch each to PLANNED
+ const createAndPatch = (remaining, acc) => {
+ if (remaining === 0) return cy.wrap(acc);
+ return cy
+ .request({
+ method: "POST",
+ url: "http://localhost:8080/api/v1/budget-line-items/",
+ body: { ...baseBli, agreement_id: agreementId },
+ headers: { Authorization: budgetTeamToken }
+ })
+ .then((bliRes) => {
+ expect(bliRes.status).to.eq(201);
+ const bliId = bliRes.body.id;
+ return cy
+ .request({
+ method: "PATCH",
+ url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
+ body: { id: bliId, status: BLI_STATUS.PLANNED },
+ headers: { Authorization: budgetTeamToken }
+ })
+ .then((patchRes) => {
+ expect(patchRes.status).to.eq(202);
+ return createAndPatch(remaining - 1, [...acc, bliId]);
+ });
+ });
+ };
+
+ createAndPatch(TOTAL_BLIS, []).then((ids) => {
+ bliIds = ids;
+ });
+ });
+ });
+ });
+
+ after(() => {
+ // Delete BLIs and agreement as system-owner
+ testLogin("system-owner");
+ cy.then(() => {
+ const token = `Bearer ${window.localStorage.getItem("access_token")}`;
+ bliIds.forEach((id) => {
+ cy.request({
+ method: "DELETE",
+ url: `http://localhost:8080/api/v1/budget-line-items/${id}`,
+ headers: { Authorization: token },
+ failOnStatusCode: false
+ });
+ });
+ cy.request({
+ method: "DELETE",
+ url: `http://localhost:8080/api/v1/agreements/${agreementId}`,
+ headers: { Authorization: token }
+ }).then((res) => {
+ expect(res.status).to.eq(200);
+ });
+ });
+ });
+
+ afterEach(() => {
+ cy.injectAxe();
+ cy.checkA11y(null, null, terminalLog);
+ });
+
+ it("shows 10 change requests on page 1 and 1 change request on page 2", () => {
+ // Log in as division director (Dave Director, division 4)
+ testLogin("division-director");
+ cy.visit("/agreements?filter=change-requests");
+
+ // Intercept the first page request
+ cy.intercept({ method: "GET", url: "**/change-requests/*", query: { limit: "10", offset: "0" } }).as("page1");
+
+ // Wait for page 1 to load and assert 10 items in response
+ cy.wait("@page1").then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ const body = interception.response.body;
+ expect(body.count).to.be.at.least(TOTAL_BLIS);
+ expect(body.data.length).to.eq(PAGE_SIZE);
+ });
+
+ // Pagination nav should be visible
+ cy.get("nav[aria-label='Pagination']").should("exist");
+ cy.get("button.usa-current").should("contain", "1");
+
+ // Intercept the page 2 request before clicking
+ cy.intercept({ method: "GET", url: "**/change-requests/*", query: { limit: "10", offset: "10" } }).as("page2");
+
+ // Click to page 2
+ cy.get("button[aria-label='Page 2']").click();
+
+ // Wait for page 2 response and assert exactly 1 item
+ cy.wait("@page2").then((interception) => {
+ expect(interception.response.statusCode).to.eq(200);
+ const body = interception.response.body;
+ expect(body.data.length).to.eq(1);
+ });
+
+ // Current page indicator should show 2
+ cy.get("button.usa-current").should("contain", "2");
+ });
+});
diff --git a/frontend/cypress/e2e/helpCenter.cy.js b/frontend/cypress/e2e/helpCenter.cy.js
index 99a217ce49..49966de4cb 100644
--- a/frontend/cypress/e2e/helpCenter.cy.js
+++ b/frontend/cypress/e2e/helpCenter.cy.js
@@ -19,9 +19,7 @@ describe("Help Center", () => {
it("supports deep links for How-to Guide accordion items", () => {
cy.visit("/help-center#how-to-find-your-user-role");
- cy.get("button")
- .contains("How to find your user role")
- .should("have.attr", "aria-expanded", "true");
+ cy.get("button").contains("How to find your user role").should("have.attr", "aria-expanded", "true");
cy.get("button").contains("How to view notifications").click();
cy.url().should("include", "#how-to-view-notifications");
@@ -35,9 +33,7 @@ describe("Help Center", () => {
it("supports deep links for FAQ accordion items", () => {
cy.visit("/help-center/faq#how-do-i-learn-how-to-use-ops");
- cy.get("button")
- .contains("How do I learn how to use OPS?")
- .should("have.attr", "aria-expanded", "true");
+ cy.get("button").contains("How do I learn how to use OPS?").should("have.attr", "aria-expanded", "true");
cy.get("button").contains("What is OPS?").click();
cy.url().should("include", "#what-is-ops");
diff --git a/frontend/cypress/e2e/procurementShopChangeRequest.cy.js b/frontend/cypress/e2e/procurementShopChangeRequest.cy.js
index e609fde412..496145317d 100644
--- a/frontend/cypress/e2e/procurementShopChangeRequest.cy.js
+++ b/frontend/cypress/e2e/procurementShopChangeRequest.cy.js
@@ -296,7 +296,7 @@ describe("Procurement Shop Change Requests at the card level", () => {
.and("contain", "4.8%")
.and("contain", "$48,000.00");
- cy.get("[data-cy='review-card']").eq(1).trigger("mouseover");
+ cy.get("[data-cy='review-card']").first().trigger("mouseover");
cy.get("#approve").click();
// usa-modal__content class should exist
cy.get(".usa-modal__content").should("exist");
@@ -422,7 +422,7 @@ describe("Procurement Shop Change Requests at the card level", () => {
.and("contain", "4.8%")
.and("contain", "$48,000.00");
- cy.get("[data-cy='review-card']").eq(1).trigger("mouseover");
+ cy.get("[data-cy='review-card']").first().trigger("mouseover");
cy.get("#decline").click();
// usa-modal__content class should exist
cy.get(".usa-modal__content").should("exist");
@@ -538,7 +538,7 @@ describe("Procurement Shop Change Requests at the agreement level", () => {
cy.visit("/agreements?filter=change-requests");
cy.get("[data-cy='review-card']").should("exist");
- cy.get("[data-cy='approve-agreement']").eq(1).click();
+ cy.get("[data-cy='approve-agreement']").first().click();
cy.get("h1").contains(/approval for budget change/i); // check for proc_shop card
// NOTE: After Approval toggle is default on
cy.get("[data-cy='review-card']").contains(/procurement shop/i);
@@ -685,7 +685,7 @@ describe("Procurement Shop Change Requests at the agreement level", () => {
cy.visit("/agreements?filter=change-requests");
cy.get("[data-cy='review-card']").should("exist");
- cy.get("[data-cy='approve-agreement']").eq(1).click();
+ cy.get("[data-cy='approve-agreement']").first().click();
cy.get("h1").contains(/approval for budget change/i); // check for proc_shop card
// NOTE: After Approval toggle is default on
cy.get("[data-cy='review-card']").contains(/procurement shop/i);
diff --git a/frontend/cypress/e2e/projectFunding.cy.js b/frontend/cypress/e2e/projectFunding.cy.js
index 5c04de0075..4b6d5561c4 100644
--- a/frontend/cypress/e2e/projectFunding.cy.js
+++ b/frontend/cypress/e2e/projectFunding.cy.js
@@ -20,15 +20,11 @@ afterEach(() => {
describe("Project Funding Tab", () => {
it("renders the project title and Project Funding tab as selected", () => {
cy.get("h1").should("contain", "Human Services Interoperability Support");
- cy.get("[data-cy='project-tab-Project Funding']")
- .should("be.visible")
- .and("not.be.disabled");
+ cy.get("[data-cy='project-tab-Project Funding']").should("be.visible").and("not.be.disabled");
});
it("shows the FY selector defaulting to the current fiscal year", () => {
- cy.get("#fiscal-year-select")
- .should("be.visible")
- .and("have.value", String(currentFY));
+ cy.get("#fiscal-year-select").should("be.visible").and("have.value", String(currentFY));
});
it("renders the Project Funding Summary section heading and all three cards", () => {
diff --git a/frontend/cypress/e2e/reviewAgreement.cy.js b/frontend/cypress/e2e/reviewAgreement.cy.js
index ea453f9fa4..a82eab21b6 100644
--- a/frontend/cypress/e2e/reviewAgreement.cy.js
+++ b/frontend/cypress/e2e/reviewAgreement.cy.js
@@ -19,26 +19,26 @@ describe("agreement change accordion", () => {
cy.contains(".usa-accordion__button", "Review Agreement Details")
.closest(".usa-accordion")
.within(() => {
- cy.contains("Agreement Type").should("exist");
- cy.get('[data-cy="agreement-meta-description"]').contains("Test description");
- cy.get('[data-cy="agreement-meta-nickname"]').contains("TBD");
- cy.get('[data-cy="agreement-meta-type"]').contains("Contract");
- cy.get('[data-cy="agreement-meta-contract-number"]').contains("XXXX000000001");
- cy.get('[data-cy="agreement-meta-contract-type"]').contains("Firm Fixed Price (FFP)");
- cy.get('[data-cy="agreement-meta-psc"]').contains("Other Scientific and Technical Consulting Services");
- cy.get('[data-cy="agreement-meta-naics"]').contains("541690");
- cy.get('[data-cy="agreement-meta-program-support-code"]').contains("R410 - Research");
- cy.get('[data-cy="agreement-meta-procurement-shop"]').contains("GCS");
- cy.get('[data-cy="agreement-meta-reason"]').contains("Recompete");
- cy.get('[data-cy="agreement-meta-vendor"]').contains("Vendor 1");
- cy.get('[data-cy="agreement-meta-division-directors"]').should("contain", NO_DATA);
- cy.get('[data-cy="agreement-meta-team-leaders"]').should("contain", NO_DATA);
- cy.get('[data-cy="agreement-meta-Descriptive Study"]').contains("Descriptive Study");
- cy.get('[data-cy="agreement-meta-Impact Study"]').contains("Impact Study");
- cy.get('[data-cy="agreement-meta-Special Topic 1"]').contains("Special Topic 1");
- cy.get('[data-cy="agreement-meta-Special Topic 2"]').contains("Special Topic 2");
- cy.get('[data-cy="agreement-meta-project-officer"]').contains("Chris Fortunato");
- cy.get('[data-cy="agreement-meta-alternate-project-officer"]').contains(NO_DATA);
+ cy.contains("Agreement Type").should("exist");
+ cy.get('[data-cy="agreement-meta-description"]').contains("Test description");
+ cy.get('[data-cy="agreement-meta-nickname"]').contains("TBD");
+ cy.get('[data-cy="agreement-meta-type"]').contains("Contract");
+ cy.get('[data-cy="agreement-meta-contract-number"]').contains("XXXX000000001");
+ cy.get('[data-cy="agreement-meta-contract-type"]').contains("Firm Fixed Price (FFP)");
+ cy.get('[data-cy="agreement-meta-psc"]').contains("Other Scientific and Technical Consulting Services");
+ cy.get('[data-cy="agreement-meta-naics"]').contains("541690");
+ cy.get('[data-cy="agreement-meta-program-support-code"]').contains("R410 - Research");
+ cy.get('[data-cy="agreement-meta-procurement-shop"]').contains("GCS");
+ cy.get('[data-cy="agreement-meta-reason"]').contains("Recompete");
+ cy.get('[data-cy="agreement-meta-vendor"]').contains("Vendor 1");
+ cy.get('[data-cy="agreement-meta-division-directors"]').should("contain", NO_DATA);
+ cy.get('[data-cy="agreement-meta-team-leaders"]').should("contain", NO_DATA);
+ cy.get('[data-cy="agreement-meta-Descriptive Study"]').contains("Descriptive Study");
+ cy.get('[data-cy="agreement-meta-Impact Study"]').contains("Impact Study");
+ cy.get('[data-cy="agreement-meta-Special Topic 1"]').contains("Special Topic 1");
+ cy.get('[data-cy="agreement-meta-Special Topic 2"]').contains("Special Topic 2");
+ cy.get('[data-cy="agreement-meta-project-officer"]').contains("Chris Fortunato");
+ cy.get('[data-cy="agreement-meta-alternate-project-officer"]').contains(NO_DATA);
});
});
diff --git a/frontend/cypress/e2e/reviewChangeRequestsAtCardLevel.cy.js b/frontend/cypress/e2e/reviewChangeRequestsAtCardLevel.cy.js
index e9c213d3df..8da03b945f 100644
--- a/frontend/cypress/e2e/reviewChangeRequestsAtCardLevel.cy.js
+++ b/frontend/cypress/e2e/reviewChangeRequestsAtCardLevel.cy.js
@@ -1,7 +1,7 @@
///
-import {BLI_STATUS} from "../../src/helpers/budgetLines.helpers";
-import {terminalLog, testLogin} from "./utils";
+import { BLI_STATUS } from "../../src/helpers/budgetLines.helpers";
+import { terminalLog, testLogin } from "./utils";
let testAgreement;
let testBli;
@@ -74,7 +74,7 @@ describe("Review Change Requests at Card Level", () => {
})
// create BLI
.then((agreementId) => {
- const bliData = {...testBli, agreement_id: agreementId};
+ const bliData = { ...testBli, agreement_id: agreementId };
cy.request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
@@ -87,11 +87,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(201);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// submit PATCH CR for approval via REST
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.request({
method: "PATCH",
url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
@@ -108,11 +108,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// test interactions
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.contains("Sign-Out")
.click()
.then(() => {
@@ -214,7 +214,7 @@ describe("Review Change Requests at Card Level", () => {
...testBli,
status: BLI_STATUS.PLANNED
};
- const bliData = {...updatedBLIToPlanned, agreement_id: agreementId};
+ const bliData = { ...updatedBLIToPlanned, agreement_id: agreementId };
cy.request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
@@ -227,11 +227,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(201);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// submit PATCH CR for approval via REST
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.request({
method: "PATCH",
url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
@@ -248,11 +248,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// test interactions
- .then(({bliId}) => {
+ .then(({ bliId }) => {
cy.contains("Sign-Out")
.click()
.then(() => {
@@ -335,7 +335,7 @@ describe("Review Change Requests at Card Level", () => {
...testBli,
status: BLI_STATUS.PLANNED
};
- const bliData = {...updatedBLIToPlanned, agreement_id: agreementId};
+ const bliData = { ...updatedBLIToPlanned, agreement_id: agreementId };
cy.request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
@@ -348,11 +348,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(201);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// submit PATCH CR for approval via REST
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.request({
method: "PATCH",
url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
@@ -369,11 +369,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// test interactions
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.contains("Sign-Out")
.click()
.then(() => {
@@ -476,7 +476,7 @@ describe("Review Change Requests at Card Level", () => {
...testBli,
status: BLI_STATUS.PLANNED
};
- const bliData = {...updatedBLIToPlanned, agreement_id: agreementId};
+ const bliData = { ...updatedBLIToPlanned, agreement_id: agreementId };
cy.request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
@@ -489,11 +489,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(201);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// submit PATCH CR for approval via REST
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.request({
method: "PATCH",
url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
@@ -510,11 +510,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// test interactions
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.contains("Sign-Out")
.click()
.then(() => {
@@ -617,7 +617,7 @@ describe("Review Change Requests at Card Level", () => {
...testBli,
status: BLI_STATUS.PLANNED
};
- const bliData = {...updatedBLIToPlanned, agreement_id: agreementId};
+ const bliData = { ...updatedBLIToPlanned, agreement_id: agreementId };
cy.request({
method: "POST",
url: "http://localhost:8080/api/v1/budget-line-items/",
@@ -630,11 +630,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(201);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// submit PATCH CR for approval via REST
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.request({
method: "PATCH",
url: `http://localhost:8080/api/v1/budget-line-items/${bliId}`,
@@ -651,11 +651,11 @@ describe("Review Change Requests at Card Level", () => {
expect(response.status).to.eq(202);
expect(response.body.id).to.exist;
const bliId = response.body.id;
- return {agreementId, bliId};
+ return { agreementId, bliId };
});
})
// test interactions
- .then(({agreementId, bliId}) => {
+ .then(({ agreementId, bliId }) => {
cy.contains("Sign-Out")
.click()
.then(() => {
diff --git a/frontend/cypress/e2e/utils.js b/frontend/cypress/e2e/utils.js
index be5384cbd2..ded3b58281 100644
--- a/frontend/cypress/e2e/utils.js
+++ b/frontend/cypress/e2e/utils.js
@@ -18,7 +18,10 @@ export const terminalLog = (violations) => {
}));
cy.task("table", violationData);
- cy.task("log", JSON.stringify({ event: "a11y_violations", count: violationData.length, violations: violationData }));
+ cy.task(
+ "log",
+ JSON.stringify({ event: "a11y_violations", count: violationData.length, violations: violationData })
+ );
};
/**
*
diff --git a/frontend/cypress/support/a11yAllowlist.js b/frontend/cypress/support/a11yAllowlist.js
index 7e99f9255e..9368a21c80 100644
--- a/frontend/cypress/support/a11yAllowlist.js
+++ b/frontend/cypress/support/a11yAllowlist.js
@@ -24,7 +24,9 @@ export const validateA11yAllowlist = () => {
}
if (!dateOnlyRegex.test(entry.expiresOn)) {
- throw new Error(`Invalid expiresOn value for accessibility allowlist entry ${entry.id}: ${entry.expiresOn}`);
+ throw new Error(
+ `Invalid expiresOn value for accessibility allowlist entry ${entry.id}: ${entry.expiresOn}`
+ );
}
if (entry.expiresOn < today) {
throw new Error(
diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js
index d2b3557b61..016d6c953d 100644
--- a/frontend/cypress/support/commands.js
+++ b/frontend/cypress/support/commands.js
@@ -1,4 +1,3 @@
-
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
@@ -105,9 +104,7 @@ const violationHandler = (violations) => {
return;
}
- const details = unallowed
- .map((violation) => `${violation.id} (${violation.impact || "unknown"})`)
- .join(", ");
+ const details = unallowed.map((violation) => `${violation.id} (${violation.impact || "unknown"})`).join(", ");
throw new Error(`A11y regression gate failed for ${currentSpec}. Unallowlisted violations: ${details}`);
};
diff --git a/frontend/cypress/support/e2e.js b/frontend/cypress/support/e2e.js
index 1738cfb7ce..20eef1f436 100644
--- a/frontend/cypress/support/e2e.js
+++ b/frontend/cypress/support/e2e.js
@@ -57,10 +57,7 @@ Cypress.Commands.add("FakeAuth", (user) => {
// IF YOU REMOVE, IT FAILS WITH "INVALID TOKEN" - Tim D.
// Debugging: log out the localStorage "access_token" value
- const getToken = () =>
- cy.window({ timeout: 20000 })
- .its("localStorage")
- .invoke("getItem", "access_token");
+ const getToken = () => cy.window({ timeout: 20000 }).its("localStorage").invoke("getItem", "access_token");
// Wait until login flow writes access token before caching the session.
getToken()
diff --git a/frontend/src/api/opsAPI.js b/frontend/src/api/opsAPI.js
index 2cc6c23ff5..29831d7d0b 100644
--- a/frontend/src/api/opsAPI.js
+++ b/frontend/src/api/opsAPI.js
@@ -1124,9 +1124,14 @@ export const opsApi = createApi({
invalidatesTags: ["ServicesComponents", "Agreements", "BudgetLineItems", "AgreementHistory"]
}),
getChangeRequestsList: builder.query({
- query: ({ userId }) => ({
- url: `/change-requests/${userId ? `?userId=${userId}` : ""}`
- }),
+ query: ({ userId, limit, offset }) => {
+ const params = new URLSearchParams();
+ if (userId) params.append("userId", userId);
+ if (limit !== undefined) params.append("limit", limit);
+ if (offset !== undefined) params.append("offset", offset);
+ const qs = params.toString();
+ return { url: `/change-requests/${qs ? `?${qs}` : ""}` };
+ },
providesTags: ["ChangeRequests"]
}),
updateChangeRequest: builder.mutation({
diff --git a/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestList.test.jsx b/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestList.test.jsx
index 1b692d7c99..52ce114f2b 100644
--- a/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestList.test.jsx
+++ b/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestList.test.jsx
@@ -44,7 +44,7 @@ describe("ChangeRequestList", () => {
const store = mockStore(initialState);
it("renders without any change requests", () => {
- useGetChangeRequestsListQuery.mockReturnValue({ data: {} });
+ useGetChangeRequestsListQuery.mockReturnValue({ data: { data: [], count: 0, limit: 10, offset: 0 } });
useGetPendingPreAwardApprovalsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
useGetPendingBudgetRequisitionsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
useGetAgreementByIdQuery.mockReturnValue("Agreement Name");
@@ -59,7 +59,7 @@ describe("ChangeRequestList", () => {
);
expect(useGetChangeRequestsListQuery).toHaveBeenCalledWith(
- { userId: 500 },
+ { userId: 500, limit: 10, offset: 0 },
{ refetchOnMountOrArgChange: true, skip: false }
);
expect(screen.getByText(/no changes/i)).toBeInTheDocument();
@@ -67,7 +67,9 @@ describe("ChangeRequestList", () => {
it("renders with change requests", async () => {
const mockChangeRequests = [{ ...changeRequests[0] }, { ...changeRequests[1] }, { ...changeRequests[2] }];
- useGetChangeRequestsListQuery.mockReturnValue({ data: mockChangeRequests });
+ useGetChangeRequestsListQuery.mockReturnValue({
+ data: { data: mockChangeRequests, count: mockChangeRequests.length, limit: 10, offset: 0 }
+ });
useGetPendingPreAwardApprovalsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
useGetPendingBudgetRequisitionsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
useGetAgreementByIdQuery.mockReturnValue("Agreement Name");
@@ -96,7 +98,7 @@ describe("ChangeRequestList", () => {
);
expect(useGetChangeRequestsListQuery).toHaveBeenCalledWith(
- { userId: 500 },
+ { userId: 500, limit: 10, offset: 0 },
{ refetchOnMountOrArgChange: true, skip: false }
);
@@ -114,7 +116,7 @@ describe("ChangeRequestList", () => {
}
});
- useGetChangeRequestsListQuery.mockReturnValue({ data: undefined, isLoading: false, isError: false });
+ useGetChangeRequestsListQuery.mockReturnValue({ data: undefined, isLoading: false, isError: false }); // undefined simulates unloaded
useGetPendingPreAwardApprovalsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
useGetPendingBudgetRequisitionsQuery.mockReturnValue({ data: [], isLoading: false, isError: false });
@@ -127,7 +129,7 @@ describe("ChangeRequestList", () => {
);
expect(useGetChangeRequestsListQuery).toHaveBeenCalledWith(
- { userId: null },
+ { userId: null, limit: 10, offset: 0 },
{ refetchOnMountOrArgChange: true, skip: true }
);
expect(screen.getByText(/no changes/i)).toBeInTheDocument();
diff --git a/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestsList.jsx b/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestsList.jsx
index a89fd50056..d1ebac756b 100644
--- a/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestsList.jsx
+++ b/frontend/src/components/ChangeRequests/ChangeRequestsList/ChangeRequestsList.jsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { useState } from "react";
import { useSelector } from "react-redux";
import {
useGetChangeRequestsListQuery,
@@ -10,10 +11,11 @@ import ProcurementShopReviewCard from "../ProcurementShopReviewCard";
import StatusChangeReviewCard from "../StatusChangeReviewCard";
import PreAwardReviewCard from "../PreAwardReviewCard";
import BudgetTeamRequisitionReviewCard from "../BudgetTeamRequisitionReviewCard";
+import PaginationNav from "../../UI/PaginationNav/PaginationNav";
import { useNavigate } from "react-router-dom";
-// Budget Line Item status constant
const BLI_STATUS_IN_EXECUTION = "In Execution";
+const PAGE_SIZE = 10;
/**
* @component Change Requests List component.
@@ -25,12 +27,20 @@ const BLI_STATUS_IN_EXECUTION = "In Execution";
function ChangeRequestsList({ handleReviewChangeRequest }) {
const navigate = useNavigate();
const userId = useSelector((state) => state.auth?.activeUser?.id) ?? null;
- /** @type {{data?: ChangeRequest[] | undefined, isError: boolean, isLoading: boolean}} */
+ const [currentPage, setCurrentPage] = useState(1); // 1-indexed for UI
+
+ /** @type {{data?: {data: ChangeRequest[], count: number, limit: number, offset: number} | undefined, isError: boolean, isLoading: boolean}} */
const {
- data: changeRequests,
+ data: changeRequestsResponse,
isLoading: loadingChangeRequests,
isError: errorChangeRequests
- } = useGetChangeRequestsListQuery({ userId }, { skip: !userId, refetchOnMountOrArgChange: true });
+ } = useGetChangeRequestsListQuery(
+ { userId, limit: PAGE_SIZE, offset: (currentPage - 1) * PAGE_SIZE },
+ { skip: !userId, refetchOnMountOrArgChange: true }
+ );
+ const changeRequests = changeRequestsResponse?.data;
+ const totalCount = changeRequestsResponse?.count || 0;
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
// Fetch pending pre-award approvals
const {
@@ -46,7 +56,6 @@ function ChangeRequestsList({ handleReviewChangeRequest }) {
isError: errorBudgetRequisitions
} = useGetPendingBudgetRequisitionsQuery(undefined, { refetchOnMountOrArgChange: true });
- // Calculate sum and count of budget lines in EXECUTING status
const calculateExecutingTotal = (budgetLineItems = []) => {
return budgetLineItems
.filter((bli) => bli.status === BLI_STATUS_IN_EXECUTION)
@@ -57,7 +66,6 @@ function ChangeRequestsList({ handleReviewChangeRequest }) {
return budgetLineItems.filter((bli) => bli.status === BLI_STATUS_IN_EXECUTION).length;
};
- // Get the earliest obligate-by date from executing BLIs
const getObligateByDate = (budgetLineItems = []) => {
const executingBlis = budgetLineItems.filter(
(bli) => bli.status === BLI_STATUS_IN_EXECUTION && bli.date_needed
@@ -182,6 +190,16 @@ function ChangeRequestsList({ handleReviewChangeRequest }) {
)
)}
+
+ {totalPages > 1 && (
+
+ )}
>
) : (
There are currently no changes for review.
diff --git a/frontend/src/hooks/useChangeRequests.hooks.js b/frontend/src/hooks/useChangeRequests.hooks.js
index 3286c6a260..3e1c046d74 100644
--- a/frontend/src/hooks/useChangeRequests.hooks.js
+++ b/frontend/src/hooks/useChangeRequests.hooks.js
@@ -42,11 +42,15 @@ export const useChangeRequestsForAgreement = (agreementId) => {
*/
export const useChangeRequestTotal = () => {
const userId = useSelector((state) => state.auth?.activeUser?.id) ?? null;
- const { data: changeRequests } = useGetChangeRequestsListQuery({ userId }, { skip: !userId });
+ const { data: changeRequestsResponse } = useGetChangeRequestsListQuery(
+ // Limit and offset are hardcoded, but we're only really using the count from the response in this hook.
+ { userId, limit: 10, offset: 0 },
+ { skip: !userId }
+ );
const { data: preAwardApprovals } = useGetPendingPreAwardApprovalsQuery(undefined, { skip: !userId });
const { data: budgetRequisitions } = useGetPendingBudgetRequisitionsQuery(undefined, { skip: !userId });
- const changeRequestsCount = changeRequests?.length || 0;
+ const changeRequestsCount = changeRequestsResponse?.count || 0;
const preAwardApprovalsCount = preAwardApprovals?.length || 0;
const budgetRequisitionsCount = budgetRequisitions?.length || 0;
diff --git a/frontend/src/hooks/useChangeRequests.hooks.test.js b/frontend/src/hooks/useChangeRequests.hooks.test.js
index 80ebc5afba..8c349ee0ff 100644
--- a/frontend/src/hooks/useChangeRequests.hooks.test.js
+++ b/frontend/src/hooks/useChangeRequests.hooks.test.js
@@ -71,13 +71,18 @@ describe("useChangeRequestTotal", () => {
it("returns total count with active user id", () => {
useSelectorMock.mockImplementation((selector) => selector({ auth: { activeUser: { id: 8 } } }));
- useGetChangeRequestsListQueryMock.mockReturnValue({ data: [{ id: 1 }, { id: 2 }] });
+ useGetChangeRequestsListQueryMock.mockReturnValue({
+ data: { data: [{ id: 1 }, { id: 2 }], count: 2, limit: 10, offset: 0 }
+ });
useGetPendingPreAwardApprovalsQueryMock.mockReturnValue({ data: [] });
useGetPendingBudgetRequisitionsQueryMock.mockReturnValue({ data: [] });
const { result } = renderHook(() => useChangeRequestTotal());
- expect(useGetChangeRequestsListQueryMock).toHaveBeenCalledWith({ userId: 8 }, { skip: false });
+ expect(useGetChangeRequestsListQueryMock).toHaveBeenCalledWith(
+ { userId: 8, limit: 10, offset: 0 },
+ { skip: false }
+ );
expect(result.current).toBe(2);
});
@@ -89,7 +94,10 @@ describe("useChangeRequestTotal", () => {
const { result } = renderHook(() => useChangeRequestTotal());
- expect(useGetChangeRequestsListQueryMock).toHaveBeenCalledWith({ userId: null }, { skip: true });
+ expect(useGetChangeRequestsListQueryMock).toHaveBeenCalledWith(
+ { userId: null, limit: 10, offset: 0 },
+ { skip: true }
+ );
expect(result.current).toBe(0);
});
});
diff --git a/frontend/src/pages/agreements/list/AgreementsList.test.jsx b/frontend/src/pages/agreements/list/AgreementsList.test.jsx
index 5d683ed253..68bcd1fd54 100644
--- a/frontend/src/pages/agreements/list/AgreementsList.test.jsx
+++ b/frontend/src/pages/agreements/list/AgreementsList.test.jsx
@@ -177,7 +177,7 @@ describe("AgreementsList - Pagination", () => {
// Mock the change requests query (used by AgreementTabs)
useGetChangeRequestsListQuery.mockReturnValue({
- data: [],
+ data: { data: [], count: 0, limit: 10, offset: 0 },
error: undefined,
isLoading: false
});
@@ -594,7 +594,7 @@ describe("AgreementsList - Fiscal Year Filtering", () => {
// Mock the change requests query
useGetChangeRequestsListQuery.mockReturnValue({
- data: [],
+ data: { data: [], count: 0, limit: 10, offset: 0 },
error: undefined,
isLoading: false
});