diff --git a/backend/data_tools/data/can_data.json5 b/backend/data_tools/data/can_data.json5
index b6a4de71d5..151852a73a 100644
--- a/backend/data_tools/data/can_data.json5
+++ b/backend/data_tools/data/can_data.json5
@@ -5,87 +5,104 @@
fiscal_year: 2023,
fund_code: "AAXXXX20231DAD",
funding_source: "OPRE",
+ method_of_transfer: "DIRECT",
},
{
// 2
fiscal_year: 2021,
fund_code: "BBXXXX20215DAD",
+ method_of_transfer: "COST_SHARE",
},
{
// 3
fiscal_year: 2022,
- fund_code: "CCXXXX20225DAD"
+ fund_code: "CCXXXX20225DAD",
+ method_of_transfer: "IDDA",
},
{
// 4
fiscal_year: 2021,
fund_code: "DDXXXX20215DAD",
+ method_of_transfer: "IAA",
},
{
// 5
fiscal_year: 2021,
fund_code: "EEXXXX20215DAD",
+ method_of_transfer: "IDDA",
},
{
// 6
fiscal_year: 2021,
- fund_code: "FFXXXX20215DAD"
+ fund_code: "FFXXXX20215DAD",
+ method_of_transfer: "DIRECT",
},
{
// 7
fiscal_year: 2023,
- fund_code: "GGXXXX20231DAD"
+ fund_code: "GGXXXX20231DAD",
+ method_of_transfer: "DIRECT",
},
{
// 8
fiscal_year: 2023,
- fund_code: "HHXXXX20231DAD"
+ fund_code: "HHXXXX20231DAD",
+ method_of_transfer: "IDDA",
},
{
// 9
fiscal_year: 2023,
- fund_code: "IIXXXX20231DAD"
+ fund_code: "IIXXXX20231DAD",
+ method_of_transfer: "IAA",
},
{
// 10
fiscal_year: 2023,
- fund_code: "JJXXXX20231DAD"
+ fund_code: "JJXXXX20231DAD",
+ method_of_transfer: "DIRECT",
},
{
// 11
fiscal_year: 2023,
- fund_code: "KKXXXX20235DAD"
+ fund_code: "KKXXXX20235DAD",
+ method_of_transfer: "IDDA",
},
{
// 12
fiscal_year: 2022,
- fund_code: "LLXXXX20225DAD"
+ fund_code: "LLXXXX20225DAD",
+ method_of_transfer: "IAA",
},
{
// 13
fiscal_year: 2023,
- fund_code: "MMXXXX20235DAD"
+ fund_code: "MMXXXX20235DAD",
+ method_of_transfer: "DIRECT",
},
{
// 14
fiscal_year: 2023,
- fund_code: "NNXXXX20231DAD"
+ fund_code: "NNXXXX20231DAD",
+ method_of_transfer: "IDDA",
},
{
// 15
fiscal_year: 2023,
- fund_code: "OOXXXX20235DAD"
+ fund_code: "OOXXXX20235DAD",
+ method_of_transfer: "DIRECT",
},
{
// 16
fiscal_year: 2023,
- fund_code: "PPXXXX20235DAD"
+ fund_code: "PPXXXX20235DAD",
+ method_of_transfer: "DIRECT",
},
{
// 17
fiscal_year: 2023,
- fund_code: "QQXXXX20235DAD"
- }
+ fund_code: "QQXXXX20235DAD",
+ method_of_transfer: "IDDA",
+ },
],
can: [
{
@@ -94,7 +111,7 @@
description: "Healthy Marriages Responsible Fatherhood - OPRE",
nick_name: "HMRF-OPRE",
portfolio_id: 6,
- funding_details_id: 1
+ funding_details_id: 1,
},
{
// 501
@@ -102,7 +119,7 @@
description: "Incoming Interagency Agreements",
nick_name: "IAA-Incoming",
portfolio_id: 1,
- funding_details_id: 2
+ funding_details_id: 2,
},
{
// 502
@@ -110,7 +127,7 @@
description: "Social Science Research and Development",
nick_name: "SSRD",
portfolio_id: 8,
- funding_details_id: 3
+ funding_details_id: 3,
},
{
// 503
@@ -118,7 +135,7 @@
description: "Child Development Research Fellowship Grant Program",
nick_name: "ASPE SRCD-IDDA",
portfolio_id: 1,
- funding_details_id: 4
+ funding_details_id: 4,
},
{
// 504
@@ -126,7 +143,7 @@
description: "Head Start Research",
nick_name: "HS",
portfolio_id: 2,
- funding_details_id: 5
+ funding_details_id: 5,
},
{
// 505
@@ -134,7 +151,7 @@
description: "Kinship Navigation",
nick_name: "Kin-Nav",
portfolio_id: 6,
- funding_details_id: 6
+ funding_details_id: 6,
},
{
// 506
@@ -142,7 +159,7 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 7
+ funding_details_id: 7,
},
{
// 507
@@ -150,7 +167,7 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 8
+ funding_details_id: 8,
},
{
// 508
@@ -158,7 +175,7 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 9
+ funding_details_id: 9,
},
{
// 509
@@ -166,7 +183,7 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 10
+ funding_details_id: 10,
},
{
// 510
@@ -174,7 +191,7 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 11
+ funding_details_id: 11,
},
{
// 511
@@ -182,21 +199,21 @@
description: "Healthy Marriages Responsible Fatherhood - OFA",
nick_name: "HMRF-OFA",
portfolio_id: 6,
- funding_details_id: 12
+ funding_details_id: 12,
},
{
// 512
number: "G99XXX8",
description: "Example CAN",
- nick_name: "",
+ nick_name: "Next Generation Leadership Program",
portfolio_id: 3,
funding_details_id: 13,
projects: [
{
- "tablename": "project",
- "id": 1000
- }
- ]
+ tablename: "project",
+ id: 1000,
+ },
+ ],
},
{
// 513
@@ -204,7 +221,7 @@
description: "MIHOPE Check-in 2023",
nick_name: "MIHOPE 23",
portfolio_id: 3,
- funding_details_id: 14
+ funding_details_id: 14,
},
{
// 514
@@ -212,7 +229,7 @@
description: "MIHOPE Check-in 2024",
nick_name: "MIHOPE 24",
portfolio_id: 3,
- funding_details_id: 15
+ funding_details_id: 15,
},
{
// 515
@@ -220,7 +237,7 @@
description: "MOHOPE Long-Term",
nick_name: "MIHOPE LT",
portfolio_id: 3,
- funding_details_id: 16
+ funding_details_id: 16,
},
{
// 516
@@ -228,8 +245,8 @@
description: "Shared CAN",
nick_name: "SHARED",
portfolio_id: 3,
- funding_details_id: 17
- }
+ funding_details_id: 17,
+ },
],
can_funding_budget: [
{
@@ -386,7 +403,7 @@
fiscal_year: 2023,
can_id: 516,
budget: 500000.0,
- }
+ },
],
can_funding_received: [
{
@@ -518,6 +535,6 @@
fiscal_year: 2023,
can_id: 515,
funding: 1000000.0,
- }
+ },
],
}
diff --git a/backend/ops_api/ops/schemas/cans.py b/backend/ops_api/ops/schemas/cans.py
index 216a5e96ba..2ed35ca7cb 100644
--- a/backend/ops_api/ops/schemas/cans.py
+++ b/backend/ops_api/ops/schemas/cans.py
@@ -1,6 +1,7 @@
from marshmallow import Schema, fields
from models import CANMethodOfTransfer, PortfolioStatus
+
from ops_api.ops.schemas.budget_line_items import BudgetLineItemResponseSchema
from ops_api.ops.schemas.projects import ProjectSchema
from ops_api.ops.schemas.users import SafeUserSchema
diff --git a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py
index f3b02cff32..2ab354bc56 100644
--- a/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py
+++ b/backend/ops_api/tests/ops/funding_summary/test_can_funding_summary.py
@@ -53,7 +53,7 @@ def test_get_can_funding_summary_no_fiscal_year(loaded_db, test_can) -> None:
"funding_partner": None,
"funding_source": "OPRE",
"id": 1,
- "method_of_transfer": None,
+ "method_of_transfer": "DIRECT",
"sub_allowance": None,
"updated_by": None,
"updated_by_user": None,
@@ -141,7 +141,7 @@ def test_get_can_funding_summary_with_fiscal_year(loaded_db, test_can) -> None:
"funding_partner": None,
"funding_source": "OPRE",
"id": 1,
- "method_of_transfer": None,
+ "method_of_transfer": "DIRECT",
"sub_allowance": None,
"updated_by": None,
"updated_by_user": None,
diff --git a/docs/developers/frontend/types.md b/docs/developers/frontend/types.md
new file mode 100644
index 0000000000..34925ba505
--- /dev/null
+++ b/docs/developers/frontend/types.md
@@ -0,0 +1,118 @@
+# Process for Creating and Using Types
+
+This document describes the process for creating and using types in OPS based on TypeScript best practices and conventions in a JS Doc format.
+
+## Creating a Type Definition
+
+1. **Create a new file co-located with the component directory**. The file should be named after the type it defines that type names should be in PascalCase. For example, a type definition for a user object should be named `UserTypes.d.ts`.
+
+```markdown
+src/components/Users
+├── UsersEmailComboBox
+├── UserInfo
+├── IserInfoForm
+└── UserTypes.d.ts
+```
+
+The `.d.ts` extension is used to indicate that the file contains type definitions and does not contain any executable code.
+
+2. **Define the type**. The type definition should be exported and named. The type should be defined using the `type` keyword.
+
+```typescript
+export type SafeUser = {
+ email: string;
+ full_name: string;
+ id: number;
+};
+```
+
+
+## Using a Type Definition
+
+1. **Import the type**. Import the type in the component file where it is used. Since we are using JS Doc, the type should be imported using the `@typedef` tag and imported as a module.
+
+```jsx
+// src/components/Users/UserInfo.jsx
+
+/**
+ * Renders the User information.
+ * @component
+ * @typedef {import("../UserTypes").SafeUser} User
+ * @param {Object} props - The component props.
+ * @param {User} props.user - The user object.
+ * @param {Boolean} props.isEditable - Whether the user information is editable.
+ * @returns {JSX.Element} - The rendered component.
+ */
+const UserInfo = ({ user, isEditable }) => {
+...
+```
+
+2. Another example of using a type definition with a list or array:
+
+Let's say you have a CANs component ytree like this:
+
+```markdown
+src/components/CANs
+├── CANBudgetSummary
+├── CANTable
+├── CanTypes.d.ts
+```
+
+The `CanTypes.d.ts` file would look like this:
+
+```typescript
+import { BudgetLine } from "../BudgetLineItems/BudgetLineTypes";
+import { Portfolio } from "../Portfolios/PortfolioTypes";
+import { Project } from "../Projects/ProjectTypes";
+
+export type CAN = {
+ active_period: number;
+ budget_line_items: BudgetLine[];
+ created_by: number | null;
+ created_by_user: number | null;
+ created_on: Date;
+ description: string;
+ display_name: string;
+ funding_budgets: CANFundingBudget[];
+ funding_details: CANFundingDetails;
+ funding_details_id: number;
+ funding_received: CANFundingReceived[];
+ id: number;
+ nick_name: string;
+ number: string;
+ portfolio: Portfolio;
+ portfolio_id: number;
+ projects: Project[];
+ updated_by: number | null;
+ updated_by_user: number | null;
+ updated_on: Date;
+};
+
+...
+```
+
+And then you would import the `CAN` type in the `CANTable` component like this:
+
+```jsx
+// src/components/CANs/CANTable.jsx
+
+/**
+ * CANTable component of CanList
+ * @component
+ * @typedef {import("../CANTypes").CAN} CAN
+ * @param {Object} props
+ * @param {CAN[]} props.cans - Array of CANs
+ * @returns {JSX.Element}
+ */
+const CANTable = ({ cans }) => {
+ const CANS_PER_PAGE = 10;
+...
+```
+
+## Resources
+
+- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/intro.html)
+- [JSDocs TypeDefinition](https://jsdoc.app/tags-typedef)
+- [JSDocs Types](https://jsdoc.app/tags-type)
+- [ThePrimeTimeagen inspiration TY Short](https://www.youtube.com/shorts/tj5VW2xJsqU)
+```
diff --git a/frontend/cypress/e2e/canList.cy.js b/frontend/cypress/e2e/canList.cy.js
index 2d65d72e1c..0f16e1df20 100644
--- a/frontend/cypress/e2e/canList.cy.js
+++ b/frontend/cypress/e2e/canList.cy.js
@@ -3,7 +3,7 @@ import { terminalLog, testLogin } from "./utils";
beforeEach(() => {
testLogin("admin");
- cy.visit("/cans");
+ cy.visit("/cans").wait(1000);
});
afterEach(() => {
@@ -11,18 +11,48 @@ afterEach(() => {
cy.checkA11y(null, null, terminalLog);
});
-it("loads", () => {
- // beforeEach has ran...
- cy.get("h1").should("have.text", "CANs");
- cy.get('a[href="/cans/502"]').should("exist");
-});
+describe("CAN List", () => {
+ it("loads", () => {
+ // beforeEach has ran...
+ cy.get("h1").should("have.text", "CANs");
+ cy.get('a[href="/cans/502"]').should("exist");
+ });
+
+ it("clicking on a CAN takes you to the detail page", () => {
+ // beforeEach has ran...
+ const canNumber = "G99PHS9";
+
+ cy.contains(canNumber).click();
+
+ cy.url().should("include", "/cans/502");
+ cy.get("h1").should("contain", canNumber);
+ });
-it("clicking on a CAN takes you to the detail page", () => {
- // beforeEach has ran...
- const canNumber = "G99PHS9";
+ it("pagination on the bli table works as expected", () => {
+ cy.get("ul").should("have.class", "usa-pagination__list");
+ cy.get("li").should("have.class", "usa-pagination__item").contains("1");
+ cy.get("button").should("have.class", "usa-current").contains("1");
+ cy.get("li").should("have.class", "usa-pagination__item").contains("2");
+ cy.get("li").should("have.class", "usa-pagination__item").contains("Next");
+ cy.get("tbody").find("tr").should("have.length", 10);
+ cy.get("li")
+ .should("have.class", "usa-pagination__item")
+ .contains("Previous")
+ .find("svg")
+ .should("have.attr", "aria-hidden", "true");
- cy.contains(canNumber).click();
+ // go to the second page
+ cy.get("li").should("have.class", "usa-pagination__item").contains("2").click();
+ cy.get("button").should("have.class", "usa-current").contains("2");
+ cy.get("li").should("have.class", "usa-pagination__item").contains("Previous");
+ cy.get("li")
+ .should("have.class", "usa-pagination__item")
+ .contains("Next")
+ .find("svg")
+ .should("have.attr", "aria-hidden", "true");
- cy.url().should("include", "/cans/502");
- cy.get("h1").should("contain", canNumber);
+ // go back to the first page
+ cy.get("li").should("have.class", "usa-pagination__item").contains("1").click();
+ cy.get("button").should("have.class", "usa-current").contains("1");
+ });
});
diff --git a/frontend/cypress/e2e/loginPage.cy.js b/frontend/cypress/e2e/loginPage.cy.js
index a358f2e4a8..7224293618 100644
--- a/frontend/cypress/e2e/loginPage.cy.js
+++ b/frontend/cypress/e2e/loginPage.cy.js
@@ -6,7 +6,6 @@ it("has expected state on initial load", () => {
cy.visit("/login");
cy.fixture("initial-state").then((initState) => {
const currentFY = getCurrentFiscalYear();
- initState.canDetail.selectedFiscalYear.value = currentFY;
initState.portfolio.selectedFiscalYear.value = currentFY;
initState.researchProjectFunding.selectedFiscalYear.value = currentFY;
diff --git a/frontend/cypress/fixtures/initial-state.json b/frontend/cypress/fixtures/initial-state.json
index ceb1e8a769..e1373fd96e 100644
--- a/frontend/cypress/fixtures/initial-state.json
+++ b/frontend/cypress/fixtures/initial-state.json
@@ -1,15 +1,4 @@
{
- "canList": {
- "cans": []
- },
- "canDetail": {
- "can": {},
- "canFiscalYearObj": {},
- "pendingFunds": "--",
- "selectedFiscalYear": {
- "value": null
- }
- },
"portfolioList": {
"portfolios": []
},
diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json
index ed3d5d2eaa..c33186023e 100644
--- a/frontend/jsconfig.json
+++ b/frontend/jsconfig.json
@@ -17,7 +17,7 @@
"skipLibCheck": true,
"strict": true,
"target": "ESNext",
- "types": ["vite/client", "vite-plugin-svgr/client", "vite-plugin-babel-macros/client", "vitest/globals"]
+ "types": ["vite/client", "vite-plugin-svgr/client", "vite-plugin-babel-macros", "vitest/globals"]
},
"display": "Recommended",
"include": ["src", "src/setupTests.js"]
diff --git a/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBLIRow.jsx b/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBLIRow.jsx
index d764c1a4d4..e9434cc4a3 100644
--- a/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBLIRow.jsx
+++ b/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBLIRow.jsx
@@ -24,8 +24,9 @@ import ChangeIcons from "../ChangeIcons";
/**
* BLIRow component that represents a single row in the Budget Lines table.
* @component
+ * @typedef {import("../../BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
* @param {Object} props - The props for the BLIRow component.
- * @param {Object} props.budgetLine - The budget line object.
+ * @param {BudgetLine} props.budgetLine - The budget line object.
* @param {boolean} [props.canUserEditBudgetLines] - Whether the user can edit budget lines.
* @param {Function} [props.handleSetBudgetLineForEditing] - The function to set the budget line for editing.
* @param {Function} [props.handleDeleteBudgetLine] - The function to delete the budget line.
@@ -113,7 +114,6 @@ const AllBLIRow = ({
prefix={"$"}
decimalScale={getDecimalScale(budgetLineTotalPlusFees)}
fixedDecimalScale={true}
- renderText={(value) => value}
/>
value}
/>
@@ -206,7 +205,6 @@ const AllBLIRow = ({
prefix={"$"}
decimalScale={getDecimalScale(feeTotal)}
fixedDecimalScale={true}
- renderText={(value) => value}
/>
diff --git a/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBudgetLinesTable.jsx b/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBudgetLinesTable.jsx
index d78502be8f..2d534a62ed 100644
--- a/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBudgetLinesTable.jsx
+++ b/frontend/src/components/BudgetLineItems/AllBudgetLinesTable/AllBudgetLinesTable.jsx
@@ -12,8 +12,9 @@ import ConfirmationModal from "../../UI/Modals/ConfirmationModal";
/**
* TableRow component that represents a single row in the budget lines table.
* @component
+ * @typedef {import("../../BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
* @param {Object} props - The props for the TableRow component.
- * @param {Object[]} props.budgetLines - The budget line data for the row.
+ * @param {BudgetLine[]} props.budgetLines - The budget line data for the row.
* @returns {JSX.Element} The TableRow component.
*/
const AllBudgetLinesTable = ({ budgetLines }) => {
diff --git a/frontend/src/components/BudgetLineItems/BLIDiffTable/BLIDiffTable.jsx b/frontend/src/components/BudgetLineItems/BLIDiffTable/BLIDiffTable.jsx
index 71595b8b7d..87ecd9fc78 100644
--- a/frontend/src/components/BudgetLineItems/BLIDiffTable/BLIDiffTable.jsx
+++ b/frontend/src/components/BudgetLineItems/BLIDiffTable/BLIDiffTable.jsx
@@ -6,11 +6,12 @@ import "./BLIDiffTable.scss";
/**
* A table component that displays budget lines.
+ * @typedef {import("../../BudgetLineItems/BudgetLineTypes").BudgetLine} BudgetLine
* @param {Object} props - The component props.
- * @param {Array} [props.budgetLines] - An array of budget lines to display. - optional
+ * @param {BudgetLine[]} [props.budgetLines=[]] - The budget lines to display.
* @param {string} props.changeType - The type of change request.
- * @param {string} [props.statusChangeTo=""] - The status change to. - optional
- * @returns {JSX.Element} - The rendered table component.
+ * @param {string} [props.statusChangeTo=""] - The status change to.
+ * @returns {JSX.Element} The rendered table component.
*/
const BLIDiffTable = ({ budgetLines = [], changeType, statusChangeTo = "" }) => {
const sortedBudgetLines = budgetLines
diff --git a/frontend/src/components/BudgetLineItems/BudgetLineTypes.d.ts b/frontend/src/components/BudgetLineItems/BudgetLineTypes.d.ts
new file mode 100644
index 0000000000..79e0f5102f
--- /dev/null
+++ b/frontend/src/components/BudgetLineItems/BudgetLineTypes.d.ts
@@ -0,0 +1,25 @@
+import { SimpleCAN } from "../CANs/CANTypes";
+import { ChangeRequest } from "../ChangeRequests/ChangeRequestsTypes";
+import { SafeUser } from "../Users/UserTypes";
+
+export type BudgetLine = {
+ agreement_id: number;
+ amount: number;
+ can: SimpleCAN;
+ can_id: number;
+ change_requests_in_review: ChangeRequest[];
+ comments: string;
+ created_by: number;
+ created_on: Date;
+ date_needed: Date;
+ fiscal_year: number;
+ id: number;
+ in_review: boolean;
+ line_description: string;
+ portfolio_id: number;
+ proc_shop_fee_percentage: number;
+ services_component_id: number | null;
+ status: string;
+ team_members: SafeUser[];
+ updated_on: Date;
+};
diff --git a/frontend/src/components/CANs/CANTable/CANTable.jsx b/frontend/src/components/CANs/CANTable/CANTable.jsx
new file mode 100644
index 0000000000..5ac792fffa
--- /dev/null
+++ b/frontend/src/components/CANs/CANTable/CANTable.jsx
@@ -0,0 +1,124 @@
+import _ from "lodash";
+import PropTypes from "prop-types";
+import React from "react";
+import PaginationNav from "../../UI/PaginationNav";
+import Tooltip from "../../UI/USWDS/Tooltip";
+import CANTableRow from "./CANTableRow";
+import styles from "./style.module.css";
+/**
+ * CANTable component of CanList
+ * @component
+ * @typedef {import("../CANTypes").CAN} CAN
+ * @param {Object} props
+ * @param {CAN[]} props.cans - Array of CANs
+ * @returns {JSX.Element}
+ */
+const CANTable = ({ cans }) => {
+ const CANS_PER_PAGE = 10;
+ const [currentPage, setCurrentPage] = React.useState(1);
+ let cansPerPage = _.cloneDeep(cans);
+ cansPerPage = cansPerPage.slice((currentPage - 1) * CANS_PER_PAGE, currentPage * CANS_PER_PAGE);
+
+ if (cans.length === 0) {
+ return
No CANs found
;
+ }
+
+ return (
+ <>
+
+
+
+ {cansPerPage.map((can) => (
+
+ ))}
+
+
+ {cans.length > 0 && (
+
+ )}
+ >
+ );
+};
+
+const TableHead = () => {
+ const availbleTooltip =
+ "$ Available is the remaining amount of the total budget that is available to plan from (Total FY Budget minus budget lines in Planned, Executing or Obligated Status)";
+ return (
+
+
+
+ CAN
+
+
+ Portfolio
+
+
+ FY
+
+
+ Active Period
+
+
+ Obligate By
+
+
+ Transfer
+
+
+ FY Budget
+
+
+
+ $ Available
+
+
+
+
+ );
+};
+
+CANTable.propTypes = {
+ cans: PropTypes.array.isRequired
+};
+
+export default CANTable;
diff --git a/frontend/src/components/CANs/CANTable/CANTable.test.jsx b/frontend/src/components/CANs/CANTable/CANTable.test.jsx
new file mode 100644
index 0000000000..ce39e3b8fb
--- /dev/null
+++ b/frontend/src/components/CANs/CANTable/CANTable.test.jsx
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import CANTable from "./CANTable";
+import { cans } from "../../../tests/data";
+import { useGetCansQuery, useGetCanFundingSummaryQuery } from "../../../api/opsAPI";
+import { MemoryRouter } from "react-router-dom";
+
+// Mock the PaginationNav component
+vi.mock("../../UI/PaginationNav", () => ({
+ default: () =>
+}));
+
+// Mock the Tooltip component
+vi.mock("../../UI/USWDS/Tooltip", () => ({
+ default: ({ children }) =>
{children}
+}));
+vi.mock("../../../api/opsAPI");
+
+describe("CANTable", () => {
+ useGetCansQuery.mockReturnValue({
+ data: cans
+ });
+ useGetCanFundingSummaryQuery.mockReturnValue({
+ data: {
+ fundingSummary: {
+ available_funding: 1000
+ }
+ }
+ });
+ it("renders the table with correct headers", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("CAN")).toBeInTheDocument();
+ expect(screen.getByText("Portfolio")).toBeInTheDocument();
+ expect(screen.getByText("FY")).toBeInTheDocument();
+ expect(screen.getByText("Active Period")).toBeInTheDocument();
+ expect(screen.getByText("Obligate By")).toBeInTheDocument();
+ expect(screen.getByText("Transfer")).toBeInTheDocument();
+ expect(screen.getByText("FY Budget")).toBeInTheDocument();
+ expect(screen.getByText("$ Available")).toBeInTheDocument();
+ });
+
+ it("renders the correct number of rows", () => {
+ render(
+
+
+
+ );
+
+ const rows = screen.getAllByRole("row");
+ // +1 for the header row
+ expect(rows.length).toBe(cans.length + 1);
+ });
+
+ it('displays "No CANs found" when cans array is empty', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("No CANs found")).toBeInTheDocument();
+ });
+
+ it("renders PaginationNav when there are CANs", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("pagination-nav")).toBeInTheDocument();
+ });
+
+ it("does not render PaginationNav when there are no CANs", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("pagination-nav")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/CANs/CANTable/CANTableRow.jsx b/frontend/src/components/CANs/CANTable/CANTableRow.jsx
new file mode 100644
index 0000000000..fc9a2080cc
--- /dev/null
+++ b/frontend/src/components/CANs/CANTable/CANTableRow.jsx
@@ -0,0 +1,107 @@
+import PropTypes from "prop-types";
+import CurrencyFormat from "react-currency-format";
+import { Link } from "react-router-dom";
+import { useGetCanFundingSummaryQuery } from "../../../api/opsAPI";
+import { getDecimalScale } from "../../../helpers/currencyFormat.helpers";
+import { convertCodeForDisplay } from "../../../helpers/utils";
+import Tooltip from "../../UI/USWDS/Tooltip";
+
+/**
+ * CanTableRow component of CANTable
+ * @component
+ * @param {Object} props
+ * @param {string} props.name - CAN name
+ * @param {string} props.nickname - CAN nickname
+ * @param {string} props.portfolio - Portfolio abbreviation
+ * @param {number} props.fiscalYear - Fiscal Year
+ * @param {number} props.activePeriod - Active Period in years
+ * @param {string} props.obligateBy - Obligate By Date
+ * @param {string} props.transfer - Method of Transfer
+ * @param {number} props.fyBudget - Fiscal Year Budget
+ * @param {number} props.canId - CAN ID
+ * @returns {JSX.Element}
+ */
+const CANTableRow = ({
+ name,
+ nickname,
+ portfolio,
+ fiscalYear,
+ activePeriod,
+ obligateBy,
+ transfer,
+ fyBudget,
+ canId
+}) => {
+ const { data: fundingSummary, isError, isLoading } = useGetCanFundingSummaryQuery(canId);
+ const availableFunds = fundingSummary?.available_funding ?? 0;
+
+ if (isLoading)
+ return (
+
+
+ );
+
+ expect(screen.getByText("Error:")).toBeInTheDocument();
+ });
+
+ it("renders correct active period text for single year", () => {
+ render(
+
+