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 });