Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds CAN budget form #3212

Merged
merged 13 commits into from
Dec 19, 2024
57 changes: 22 additions & 35 deletions backend/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1191,45 +1191,32 @@ paths:
available_funding:
type: string
carry_forward_funding:
type: integer
can:
type: object
type: string
cans:
type: array
properties:
can:
type: array
items:
type: object
properties:
appropriation_term:
type: integer
authorizer_id:
type: integer
arrangement_type_id:
type: integer
number:
type: string
purpose:
type: string
managing_portfolio_id:
type: integer
nickname:
type: string
appropriation_date:
type: string
description:
type: string
id:
type: integer
expiration_date:
type: string
managing_project_id:
type: integer
properties:
active_period:
type: integer
description:
type: string
display_name:
type: string
id:
type: integer
nick_name:
type: string
number:
type: string
portfolio_id:
type: integer
projects:
type: array
carry_forward_label:
type: string
example: ""
expiration_date:
type: string
example: ""
expected_funding:
type: string
in_draft_funding:
Expand All @@ -1239,9 +1226,9 @@ paths:
new_funding:
type: string
obligated_funding:
type: integer
type: string
planned_funding:
type: integer
type: string
received_funding:
type: string
total_funding:
Expand Down
54 changes: 54 additions & 0 deletions frontend/cypress/e2e/canDetail.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ afterEach(() => {

const can502Nickname = "SSRD";
const can502Description = "Social Science Research and Development";
const can504 = {
number: 504,
nickname: "G994426",
budgetAmount: "5_000_000"
};

const currentFiscalYear = getCurrentFiscalYear();

Expand Down Expand Up @@ -167,4 +172,53 @@ describe("CAN detail page", () => {
.and("contain", "$6,000,000.00")
.and("contain", "60%");
});
it("handles budget form", () => {
cy.visit(`/cans/${can504.number}/funding`);
cy.get("#fiscal-year-select").select(currentFiscalYear);
cy.get("#edit").click();
cy.get("#save-changes").should("be.disabled");
cy.get("#add-fy-budget").should("be.disabled");
cy.get("#carry-forward-card").should("contain", "0");
cy.get("[data-cy='can-budget-fy-card']").should("contain", "0");
cy.get("#budget-amount").type(can504.budgetAmount);
cy.get("#budget-amount").clear();
cy.get(".usa-error-message").should("exist").contains("This is required information");
cy.get("#budget-amount").type(can504.budgetAmount);
cy.get(".usa-error-message").should("not.exist");
cy.get("#add-fy-budget").click();
cy.get("[data-cy='can-budget-fy-card']").should("contain", "5,000,000.00");
cy.get("#save-changes").should("be.enabled");
cy.get("#save-changes").click();
cy.get(".usa-alert__body").should("contain", `The CAN ${can504.nickname} has been successfully updated.`);
cy.get("[data-cy=budget-received-card]").should("exist").and("contain", "Received $0.00 of $5,000,000.00");
cy.get("[data-cy=can-budget-fy-card]")
.should("exist")
.and("contain", "CAN Budget by FY")
.and("contain", `FY ${currentFiscalYear}`)
.and("contain", "$5,000,000.00");
});
it("handles cancelling from budget form", () => {
cy.visit(`/cans/${can504.number}/funding`);
cy.get("#fiscal-year-select").select(currentFiscalYear);
cy.get("#edit").click();
cy.get("#carry-forward-card").should("contain", "0");
cy.get("[data-cy='can-budget-fy-card']").should("contain", "5,000,000.00");
cy.get("#budget-amount").type("6_000_000");
cy.get("#add-fy-budget").click();
cy.get("[data-cy='can-budget-fy-card']").should("contain", "6,000,000.00");
cy.get("#save-changes").should("be.enabled");
cy.get("[data-cy=cancel-button]").should("be.enabled");
cy.get("[data-cy=cancel-button]").click();
cy.get(".usa-modal__heading").should(
"contain",
"Are you sure you want to cancel editing? Your changes will not be saved."
);
cy.get("[data-cy='confirm-action']").click();
cy.get("[data-cy=budget-received-card]").should("exist").and("contain", "Received $0.00 of $5,000,000.00");
cy.get("[data-cy=can-budget-fy-card]")
.should("exist")
.and("contain", "CAN Budget by FY")
.and("contain", `FY ${currentFiscalYear}`)
.and("contain", "$5,000,000.00");
});
});
26 changes: 23 additions & 3 deletions frontend/src/api/opsAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,24 @@ export const opsApi = createApi({
}),
invalidatesTags: ["Cans"]
}),
addCanFundingBudgets: builder.mutation({
query: ({ data }) => ({
url: `/can-funding-budgets/`,
method: "POST",
headers: { "Content-Type": "application/json" },
body: data
}),
invalidatesTags: ["Cans"]
}),
updateCanFundingBudget: builder.mutation({
query: ({ id, data }) => ({
url: `/can-funding-budgets/${id}`,
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: data
}),
invalidatesTags: ["Cans"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you will want to add CanFunding tag to this list. That way when you update the funding the getCanFundingSummary will re-fetch.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

}),
getCanFundingSummary: builder.query({
query: ({ ids, fiscalYear, activePeriod, transfer, portfolio, fyBudgets }) => {
const queryParams = [];
Expand Down Expand Up @@ -243,7 +261,7 @@ export const opsApi = createApi({

return `/can-funding-summary?${queryParams.join("&")}`;
},
providesTags: ["CanFunding"]
providesTags: ["CanFunding", "Cans"]
}),
getNotificationsByUserId: builder.query({
query: ({ id, auth_header }) => {
Expand Down Expand Up @@ -331,8 +349,8 @@ export const opsApi = createApi({
invalidatesTags: ["ServicesComponents", "Agreements", "BudgetLineItems", "AgreementHistory"]
}),
getChangeRequestsList: builder.query({
query: ({userId}) => ({
url: `/change-requests/${userId ? `?userId=${userId}` : ""}`,
query: ({ userId }) => ({
url: `/change-requests/${userId ? `?userId=${userId}` : ""}`
}),
providesTags: ["ChangeRequests"]
}),
Expand Down Expand Up @@ -413,6 +431,8 @@ export const {
useGetCansQuery,
useGetCanByIdQuery,
useUpdateCanMutation,
useAddCanFundingBudgetsMutation,
useUpdateCanFundingBudgetMutation,
useGetCanFundingSummaryQuery,
useGetNotificationsByUserIdQuery,
useGetNotificationsByUserIdAndAgreementIdQuery,
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/components/CANs/CANBudgetForm/CANBudgetForm.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import CurrencyInput from "../../UI/Form/CurrencyInput";
import icons from "../../../uswds/img/sprite.svg";

/**
* @typedef {Object} CANBudgetFormProps
* @property {string} budgetAmount
* @property {(arg: string) => string} cn
* @property {Object} res
* @property {number} fiscalYear
* @property {(e: React.FormEvent<HTMLFormElement>) => void} handleAddBudget
* @property {(name: string, value: string) => void} runValidate
* @property { React.Dispatch<React.SetStateAction<string>>} setBudgetAmount
*/

/**
* @component - The CAN Budget Form component.
* @param {CANBudgetFormProps} props
* @returns {JSX.Element} - The component JSX.
*/
const CANBudgetForm = ({ budgetAmount, cn, res, fiscalYear, handleAddBudget, runValidate, setBudgetAmount }) => {
const fillColor = budgetAmount ? "#005ea2" : "#757575";

return (
<form
onSubmit={(e) => {
handleAddBudget(e);
setBudgetAmount("");
}}
>
<div style={{ width: "383px" }}>
<CurrencyInput
name="budget-amount"
label={`FY ${fiscalYear} CAN Budget`}
onChange={(name, value) => {
runValidate("budget-amount", value);
}}
setEnteredAmount={setBudgetAmount}
value={budgetAmount || ""}
messages={res.getErrors("budget-amount")}
className={cn("budget-amount")}
/>
</div>
<button
id="add-fy-budget"
className="usa-button usa-button--outline margin-top-4"
disabled={!budgetAmount}
data-cy="add-fy-budget"
>
<svg
className="height-2 width-2 margin-right-05 cursor-pointer"
style={{ fill: fillColor }}
>
<use xlinkHref={`${icons}#add`}></use>
</svg>
Add FY Budget
</button>
</form>
);
};
export default CANBudgetForm;
72 changes: 72 additions & 0 deletions frontend/src/components/CANs/CANBudgetForm/CANBudgetForm.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, test, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CANBudgetForm from "./CANBudgetForm";

describe("CANBudgetForm", () => {
const defaultProps = {
budgetAmount: "",
cn: (name) => name,
res: { getErrors: () => [] },
fiscalYear: 2024,
handleAddBudget: vi.fn(),
runValidate: vi.fn(),
setBudgetAmount: vi.fn()
};

test("renders with required props", () => {
render(<CANBudgetForm {...defaultProps} />);
expect(screen.getByLabelText(/FY 2024 CAN Budget/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /add fy budget/i })).toBeInTheDocument();
});

test("button is disabled when budgetAmount is empty", () => {
render(<CANBudgetForm {...defaultProps} />);
expect(screen.getByRole("button", { name: /add fy budget/i })).toBeDisabled();
});

test("button is enabled when budgetAmount has value", () => {
render(
<CANBudgetForm
{...defaultProps}
budgetAmount="1000"
/>
);
expect(screen.getByRole("button", { name: /add fy budget/i })).toBeEnabled();
});

test("calls handleAddBudget and setBudgetAmount on form submission", async () => {
const user = userEvent.setup();
render(
<CANBudgetForm
{...defaultProps}
budgetAmount="1000"
/>
);

await user.click(screen.getByRole("button", { name: /add fy budget/i }));

expect(defaultProps.handleAddBudget).toHaveBeenCalled();
expect(defaultProps.setBudgetAmount).toHaveBeenCalledWith("");
});

test("calls runValidate when currency input changes", () => {
render(<CANBudgetForm {...defaultProps} />);

fireEvent.change(screen.getByLabelText(/FY 2024 CAN Budget/i), {
target: { value: "1000" }
});

expect(defaultProps.runValidate).toHaveBeenCalledWith("budget-amount", "1,000");
});

test("displays validation errors when present", () => {
const propsWithError = {
...defaultProps,
res: { getErrors: () => ["This is required information"] }
};

render(<CANBudgetForm {...propsWithError} />);
expect(screen.getByText("This is required information")).toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions frontend/src/components/CANs/CANBudgetForm/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {default} from "./CANBudgetForm"
11 changes: 11 additions & 0 deletions frontend/src/components/CANs/CANBudgetForm/suite.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { create, test, enforce, only } from "vest";

const suite = create((data = {}, fieldName) => {
only(fieldName);

test("budget-amount", "This is required information", () => {
enforce(data["budget-amount"]).isNotBlank();
});
});

export default suite;
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,15 @@ import classnames from "vest/classnames";
import { useUpdateCanMutation } from "../../../api/opsAPI";
import useAlert from "../../../hooks/use-alert.hooks";
import suite from "./suite.js";

/**
* @description - Custom hook for the CAN Detail Form.
* @param {number} canId
* @param {string} canNumber
* @param {string} canNickname
* @param {string} canDescription
* @param {number} portfolioId
* @param {() => void} toggleEditMode
*/
export default function useCanDetailForm(canId, canNumber, canNickname, canDescription, portfolioId, toggleEditMode) {
const [nickName, setNickName] = React.useState(canNickname);
const [description, setDescription] = React.useState(canDescription);
Expand Down Expand Up @@ -70,6 +78,7 @@ export default function useCanDetailForm(canId, canNumber, canNickname, canDescr
handleConfirm: () => {}
});
toggleEditMode();
suite.reset();
};

const runValidate = (name, value) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { NO_DATA } from "../../../constants";

/**
* @typedef {Object} CANFundingReceivedTableProps
* @property {number} totalFunding
* @property {string} totalFunding
* @property {FundingReceived[]} fundingReceived data for table
*/

Expand Down
Loading
Loading