Skip to content

Commit b429c84

Browse files
josbellclaude
andcommitted
refactor(pre-award): create shared budget lines review component
Extract duplicated budget lines review section into a reusable component to eliminate code duplication between Request and Approve pages. Changes: - Create PreAwardBudgetLinesReviewAccordion component with: - Budget lines grouped by services component - Services component metadata display - BLI review tables - Executing total accordion - Update RequestPreAwardApproval to use shared component - Fix incorrect instruction text - Remove unnecessary setSelectedBLIs prop - Update ApprovePreAwardApproval to use shared component - Add comprehensive tests (9 test cases) - Reduce duplication by ~100 lines Benefits: - Single source of truth for budget lines review UI - Consistent instructions and behavior across pages - Easier maintenance and future updates Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4acf015 commit b429c84

5 files changed

Lines changed: 284 additions & 115 deletions

File tree

frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx

Lines changed: 7 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,15 @@ import { useParams } from "react-router-dom";
33
import App from "../../../App";
44
import PageHeader from "../../../components/UI/PageHeader";
55
import AgreementMetaAccordion from "../../../components/Agreements/AgreementMetaAccordion";
6-
import AgreementBLIAccordion from "../../../components/Agreements/AgreementBLIAccordion";
76
import AgreementCANReviewAccordion from "../../../components/Agreements/AgreementCANReviewAccordion";
8-
import AgreementBLIReviewTable from "../../../components/BudgetLineItems/BLIReviewTable";
9-
import ReviewExecutingTotalAccordion from "../../../components/BudgetLineItems/ReviewExecutingTotalAccordion/ReviewExecutingTotalAccordion";
10-
import ServicesComponentAccordion from "../../../components/ServicesComponents/ServicesComponentAccordion";
117
import Accordion from "../../../components/UI/Accordion";
128
import TextArea from "../../../components/UI/Form/TextArea";
139
import SimpleAlert from "../../../components/UI/Alert/SimpleAlert";
1410
import ConfirmationModal from "../../../components/UI/Modals/ConfirmationModal";
1511
import { convertCodeForDisplay, formatDateToMonthDayYear } from "../../../helpers/utils";
16-
import {
17-
findDescription,
18-
findIfOptional,
19-
findPeriodEnd,
20-
findPeriodStart
21-
} from "../../../helpers/servicesComponent.helpers";
2212
import icons from "../../../uswds/img/sprite.svg";
2313
import useApprovePreAwardApproval from "./ApprovePreAwardApproval.hooks";
14+
import { PreAwardBudgetLinesReviewAccordion } from "./PreAwardBudgetLinesReviewAccordion";
2415

2516
/**
2617
* @component - Renders a page for Division Directors to approve/decline pre-award approval requests.
@@ -118,56 +109,14 @@ export const ApprovePreAwardApproval = () => {
118109
changeRequestType={agreement?.change_request_type}
119110
/>
120111

121-
{/* Budget Lines (Executing Status) */}
122-
<AgreementBLIAccordion
123-
title="Review Budget Lines"
124-
instructions="Please review the Services Components and Budget Lines below to ensure everything is up to date."
112+
{/* Budget Lines and Executing Total */}
113+
<PreAwardBudgetLinesReviewAccordion
125114
budgetLineItems={executingBudgetLines}
126115
agreement={agreement}
127-
afterApproval={false}
128-
setAfterApproval={() => {}}
129-
action=""
130-
>
131-
{groupedBudgetLinesByServicesComponent &&
132-
groupedBudgetLinesByServicesComponent.length > 0 &&
133-
groupedBudgetLinesByServicesComponent.map(
134-
(/** @type {any} */ group, /** @type {number} */ index) => {
135-
const budgetLineScGroupingLabel = group.serviceComponentGroupingLabel
136-
? group.serviceComponentGroupingLabel
137-
: group.servicesComponentNumber;
138-
return (
139-
<ServicesComponentAccordion
140-
key={`${group.servicesComponentNumber}-${index}`}
141-
servicesComponentNumber={group.servicesComponentNumber}
142-
serviceComponentGroupingLabel={group.serviceComponentGroupingLabel}
143-
withMetadata={true}
144-
periodStart={findPeriodStart(servicesComponents, budgetLineScGroupingLabel)}
145-
periodEnd={findPeriodEnd(servicesComponents, budgetLineScGroupingLabel)}
146-
description={findDescription(servicesComponents, budgetLineScGroupingLabel)}
147-
optional={findIfOptional(servicesComponents, budgetLineScGroupingLabel)}
148-
serviceRequirementType={agreement?.service_requirement_type}
149-
>
150-
{group.budgetLines.length > 0 ? (
151-
<AgreementBLIReviewTable
152-
readOnly={true}
153-
budgetLines={group.budgetLines}
154-
isReviewMode={true}
155-
servicesComponentNumber={group.servicesComponentNumber}
156-
action=""
157-
/>
158-
) : (
159-
<p className="text-center margin-y-7">
160-
No budget lines in this services component.
161-
</p>
162-
)}
163-
</ServicesComponentAccordion>
164-
);
165-
}
166-
)}
167-
</AgreementBLIAccordion>
168-
169-
{/* Review Executing Total */}
170-
<ReviewExecutingTotalAccordion executingTotal={executingTotal} />
116+
servicesComponents={servicesComponents}
117+
groupedBudgetLines={groupedBudgetLinesByServicesComponent}
118+
executingTotal={executingTotal}
119+
/>
171120

172121
{/* CAN Impact */}
173122
<AgreementCANReviewAccordion
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import AgreementBLIAccordion from "../../../../components/Agreements/AgreementBLIAccordion";
2+
import AgreementBLIReviewTable from "../../../../components/BudgetLineItems/BLIReviewTable";
3+
import ServicesComponentAccordion from "../../../../components/ServicesComponents/ServicesComponentAccordion";
4+
import ReviewExecutingTotalAccordion from "../../../../components/BudgetLineItems/ReviewExecutingTotalAccordion/ReviewExecutingTotalAccordion";
5+
import {
6+
findDescription,
7+
findIfOptional,
8+
findPeriodEnd,
9+
findPeriodStart
10+
} from "../../../../helpers/servicesComponent.helpers";
11+
12+
/**
13+
* @typedef {Object} PreAwardBudgetLinesReviewAccordionProps
14+
* @property {any[]} budgetLineItems - Budget line items to review
15+
* @property {any} agreement - The agreement object
16+
* @property {any[]} servicesComponents - Services components for the agreement
17+
* @property {any[]} groupedBudgetLines - Budget lines grouped by services component
18+
* @property {number} executingTotal - Total of executing budget lines
19+
*/
20+
21+
/**
22+
* Shared component for displaying budget lines review section in pre-award approval pages.
23+
* Used by both RequestPreAwardApproval and ApprovePreAwardApproval pages.
24+
*
25+
* @component
26+
* @param {PreAwardBudgetLinesReviewAccordionProps} props
27+
* @returns {React.ReactElement}
28+
*/
29+
export const PreAwardBudgetLinesReviewAccordion = ({
30+
budgetLineItems,
31+
agreement,
32+
servicesComponents,
33+
groupedBudgetLines,
34+
executingTotal
35+
}) => {
36+
return (
37+
<>
38+
{/* Budget Lines Review */}
39+
<AgreementBLIAccordion
40+
title="Review Budget Lines"
41+
instructions="Please review the Services Components and Budget Lines below to ensure everything is up to date."
42+
budgetLineItems={budgetLineItems}
43+
agreement={agreement}
44+
afterApproval={false}
45+
setAfterApproval={() => {}}
46+
action=""
47+
>
48+
{groupedBudgetLines &&
49+
groupedBudgetLines.length > 0 &&
50+
groupedBudgetLines.map((/** @type {any} */ group, /** @type {number} */ index) => {
51+
const budgetLineScGroupingLabel = group.serviceComponentGroupingLabel
52+
? group.serviceComponentGroupingLabel
53+
: group.servicesComponentNumber;
54+
return (
55+
<ServicesComponentAccordion
56+
key={`${group.servicesComponentNumber}-${index}`}
57+
servicesComponentNumber={group.servicesComponentNumber}
58+
serviceComponentGroupingLabel={group.serviceComponentGroupingLabel}
59+
withMetadata={true}
60+
periodStart={findPeriodStart(servicesComponents, budgetLineScGroupingLabel)}
61+
periodEnd={findPeriodEnd(servicesComponents, budgetLineScGroupingLabel)}
62+
description={findDescription(servicesComponents, budgetLineScGroupingLabel)}
63+
optional={findIfOptional(servicesComponents, budgetLineScGroupingLabel)}
64+
serviceRequirementType={agreement?.service_requirement_type}
65+
>
66+
{group.budgetLines.length > 0 ? (
67+
<AgreementBLIReviewTable
68+
readOnly={true}
69+
budgetLines={group.budgetLines}
70+
isReviewMode={true}
71+
servicesComponentNumber={group.servicesComponentNumber}
72+
action=""
73+
/>
74+
) : (
75+
<p className="text-center margin-y-7">
76+
No budget lines in this services component.
77+
</p>
78+
)}
79+
</ServicesComponentAccordion>
80+
);
81+
})}
82+
</AgreementBLIAccordion>
83+
84+
{/* Review Executing Total */}
85+
<ReviewExecutingTotalAccordion executingTotal={executingTotal} />
86+
</>
87+
);
88+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, it, expect, vi } from "vitest";
3+
import { PreAwardBudgetLinesReviewAccordion } from "./PreAwardBudgetLinesReviewAccordion";
4+
5+
// Mock child components
6+
vi.mock("../../../../components/Agreements/AgreementBLIAccordion", () => ({
7+
default: ({ title, instructions, children }) => (
8+
<div data-testid="agreement-bli-accordion">
9+
<h2>{title}</h2>
10+
<p>{instructions}</p>
11+
{children}
12+
</div>
13+
)
14+
}));
15+
16+
vi.mock("../../../../components/BudgetLineItems/BLIReviewTable", () => ({
17+
default: ({ budgetLines }) => (
18+
<div data-testid="bli-review-table">
19+
{budgetLines.map((bli) => (
20+
<div key={bli.id}>{bli.id}</div>
21+
))}
22+
</div>
23+
)
24+
}));
25+
26+
vi.mock("../../../../components/ServicesComponents/ServicesComponentAccordion", () => ({
27+
default: ({ servicesComponentNumber, children }) => (
28+
<div data-testid="services-component-accordion">
29+
<h3>Service Component {servicesComponentNumber}</h3>
30+
{children}
31+
</div>
32+
)
33+
}));
34+
35+
vi.mock("../../../../components/BudgetLineItems/ReviewExecutingTotalAccordion/ReviewExecutingTotalAccordion", () => ({
36+
default: ({ executingTotal }) => (
37+
<div data-testid="review-executing-total-accordion">Executing Total: ${executingTotal}</div>
38+
)
39+
}));
40+
41+
vi.mock("../../../../helpers/servicesComponent.helpers", () => ({
42+
findDescription: vi.fn(),
43+
findIfOptional: vi.fn(),
44+
findPeriodEnd: vi.fn(),
45+
findPeriodStart: vi.fn()
46+
}));
47+
48+
describe("PreAwardBudgetLinesReviewAccordion", () => {
49+
const mockAgreement = {
50+
id: 1,
51+
name: "Test Agreement",
52+
service_requirement_type: "SEVERABLE"
53+
};
54+
55+
const mockBudgetLineItems = [
56+
{ id: 1, status: "IN_EXECUTION" },
57+
{ id: 2, status: "IN_EXECUTION" }
58+
];
59+
60+
const mockServicesComponents = [
61+
{ id: 1, number: "SC-1" },
62+
{ id: 2, number: "SC-2" }
63+
];
64+
65+
const mockGroupedBudgetLines = [
66+
{
67+
servicesComponentNumber: "SC-1",
68+
serviceComponentGroupingLabel: null,
69+
budgetLines: [{ id: 1, status: "IN_EXECUTION" }]
70+
},
71+
{
72+
servicesComponentNumber: "SC-2",
73+
serviceComponentGroupingLabel: null,
74+
budgetLines: [{ id: 2, status: "IN_EXECUTION" }]
75+
}
76+
];
77+
78+
const defaultProps = {
79+
budgetLineItems: mockBudgetLineItems,
80+
agreement: mockAgreement,
81+
servicesComponents: mockServicesComponents,
82+
groupedBudgetLines: mockGroupedBudgetLines,
83+
executingTotal: 100000
84+
};
85+
86+
it("renders the budget lines accordion with correct title", () => {
87+
render(<PreAwardBudgetLinesReviewAccordion {...defaultProps} />);
88+
89+
expect(screen.getByText("Review Budget Lines")).toBeInTheDocument();
90+
});
91+
92+
it("displays standard instructions text", () => {
93+
render(<PreAwardBudgetLinesReviewAccordion {...defaultProps} />);
94+
95+
expect(
96+
screen.getByText(
97+
"Please review the Services Components and Budget Lines below to ensure everything is up to date."
98+
)
99+
).toBeInTheDocument();
100+
});
101+
102+
it("renders services component accordions for grouped budget lines", () => {
103+
render(<PreAwardBudgetLinesReviewAccordion {...defaultProps} />);
104+
105+
const serviceComponents = screen.getAllByTestId("services-component-accordion");
106+
expect(serviceComponents).toHaveLength(2);
107+
});
108+
109+
it("renders BLI review tables for each service component", () => {
110+
render(<PreAwardBudgetLinesReviewAccordion {...defaultProps} />);
111+
112+
const reviewTables = screen.getAllByTestId("bli-review-table");
113+
expect(reviewTables).toHaveLength(2);
114+
});
115+
116+
it("displays message when service component has no budget lines", () => {
117+
const propsWithEmptyGroup = {
118+
...defaultProps,
119+
groupedBudgetLines: [
120+
{
121+
servicesComponentNumber: "SC-1",
122+
serviceComponentGroupingLabel: null,
123+
budgetLines: []
124+
}
125+
]
126+
};
127+
128+
render(<PreAwardBudgetLinesReviewAccordion {...propsWithEmptyGroup} />);
129+
130+
expect(screen.getByText("No budget lines in this services component.")).toBeInTheDocument();
131+
});
132+
133+
it("renders the executing total accordion", () => {
134+
render(<PreAwardBudgetLinesReviewAccordion {...defaultProps} />);
135+
136+
expect(screen.getByTestId("review-executing-total-accordion")).toBeInTheDocument();
137+
expect(screen.getByText(/Executing Total: \$100000/)).toBeInTheDocument();
138+
});
139+
140+
it("handles empty grouped budget lines array", () => {
141+
const propsWithEmptyGroups = {
142+
...defaultProps,
143+
groupedBudgetLines: []
144+
};
145+
146+
render(<PreAwardBudgetLinesReviewAccordion {...propsWithEmptyGroups} />);
147+
148+
expect(screen.getByTestId("agreement-bli-accordion")).toBeInTheDocument();
149+
expect(screen.queryByTestId("services-component-accordion")).not.toBeInTheDocument();
150+
});
151+
152+
it("handles null grouped budget lines", () => {
153+
const propsWithNullGroups = {
154+
...defaultProps,
155+
groupedBudgetLines: null
156+
};
157+
158+
render(<PreAwardBudgetLinesReviewAccordion {...propsWithNullGroups} />);
159+
160+
expect(screen.getByTestId("agreement-bli-accordion")).toBeInTheDocument();
161+
expect(screen.queryByTestId("services-component-accordion")).not.toBeInTheDocument();
162+
});
163+
164+
it("uses service component grouping label when available", () => {
165+
const propsWithLabel = {
166+
...defaultProps,
167+
groupedBudgetLines: [
168+
{
169+
servicesComponentNumber: "SC-1",
170+
serviceComponentGroupingLabel: "Custom Label",
171+
budgetLines: [{ id: 1, status: "IN_EXECUTION" }]
172+
}
173+
]
174+
};
175+
176+
render(<PreAwardBudgetLinesReviewAccordion {...propsWithLabel} />);
177+
178+
const serviceComponents = screen.getAllByTestId("services-component-accordion");
179+
expect(serviceComponents).toHaveLength(1);
180+
});
181+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PreAwardBudgetLinesReviewAccordion } from "./PreAwardBudgetLinesReviewAccordion";

0 commit comments

Comments
 (0)