Skip to content

Commit

Permalink
Merge pull request #3345 from HHS/OPS-2680/3342_can_history
Browse files Browse the repository at this point in the history
feat: adds can history query and renders to can details page
  • Loading branch information
fpigeonjr authored Jan 27, 2025
2 parents 79403cc + f9cc1a1 commit 099076f
Show file tree
Hide file tree
Showing 11 changed files with 276 additions and 33 deletions.
19 changes: 19 additions & 0 deletions frontend/cypress/e2e/canDetail.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ describe("CAN detail page", () => {
cy.get("p").should("contain", can502Nickname);
cy.get("dd").should("contain", can502Description);
});
it("handles history", () => {
cy.visit("/cans/500/");
checkCANHistory();
});
});

describe("CAN spending page", () => {
it("shows the CAN Spending page", () => {
cy.visit("/cans/504/spending");
cy.get("#fiscal-year-select").select("2043");
Expand Down Expand Up @@ -132,6 +139,10 @@ describe("CAN detail page", () => {
cy.get("li").should("have.class", "usa-pagination__item").contains("1").click();
cy.get("button").should("have.class", "usa-current").contains("1");
});
});

// TODO: Add tests to check for history logs for each budget and funding change after backend is implemented
describe("CAN funding page", () => {
it("shows the CAN Funding page", () => {
cy.visit("/cans/504/funding");
cy.get("#fiscal-year-select").select("2024");
Expand Down Expand Up @@ -337,3 +348,11 @@ describe("CAN detail page", () => {
cy.get("#carry-forward-card").should("not.exist");
});
});

const checkCANHistory = () => {
cy.get("h3").should("have.text", "History");
cy.get('[data-cy="can-history-container"]').should("exist");
cy.get('[data-cy="can-history-container"]').scrollIntoView();
cy.get('[data-cy="can-history-list"]').should("exist");
cy.get('[data-cy="can-history-list"] > :nth-child(1) > .flex-justify > [data-cy="log-item-title"]').should("exist");
};
14 changes: 14 additions & 0 deletions frontend/src/api/opsAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,19 @@ export const opsApi = createApi({
},
providesTags: ["Cans", "CanFunding"]
}),
getCanHistory: builder.query({
query: ({ canId, offset, limit }) => {
const queryParams = [];
if (limit) {
queryParams.push(`limit=${limit}`);
}
if (offset) {
queryParams.push(`offset=${offset}`);
}
return `/can-history/?can_id=${canId}&${queryParams.join("&")}`;
},
providesTags: ["Cans"]
}),
getNotificationsByUserId: builder.query({
query: ({ id, auth_header }) => {
if (!id) {
Expand Down Expand Up @@ -462,6 +475,7 @@ export const {
useUpdateCanFundingReceivedMutation,
useDeleteCanFundingReceivedMutation,
useGetCanFundingSummaryQuery,
useGetCanHistoryQuery,
useGetNotificationsByUserIdQuery,
useGetNotificationsByUserIdAndAgreementIdQuery,
useDismissNotificationMutation,
Expand Down
24 changes: 19 additions & 5 deletions frontend/src/components/CANs/CANDetailView/CANDetailView.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import TermTag from "../../UI/Term/TermTag";
import Term from "../../UI/Term";
import Tag from "../../UI/Tag";
import Term from "../../UI/Term";
import TermTag from "../../UI/Term/TermTag";
import CanHistoryPanel from "../CANHistoryPanel";
/**
* @typedef {Object} CANDetailViewProps
* @property {string} description
Expand All @@ -10,16 +11,29 @@ import Tag from "../../UI/Tag";
* @property {import("../../Users/UserTypes").SafeUser[]} teamLeaders
* @property {string} divisionDirectorFullName
* @property {string} divisionName
* @property {number} canId
*/
/**
* This component needs to wrapped in a <dl> element.
* @component - Renders a term with a tag.
* @param {CANDetailViewProps} props - The properties passed to the component.
* @returns {JSX.Element} - The rendered component.
*/
const CANDetailView = ({ description, number, nickname, portfolioName, teamLeaders, divisionDirectorFullName, divisionName}) => {
const CANDetailView = ({
canId,
description,
number,
nickname,
portfolioName,
teamLeaders,
divisionDirectorFullName,
divisionName
}) => {
return (
<div className="grid-row font-12px">
<div
className="grid-row font-12px"
style={{ columnGap: "82px" }}
>
{/* // NOTE: Left Column */}
<div
className="grid-col"
Expand All @@ -33,7 +47,7 @@ const CANDetailView = ({ description, number, nickname, portfolioName, teamLeade
</dl>
<section data-cy="history">
<h3 className="text-base-dark margin-top-3 text-normal font-12px">History</h3>
<p>Not yet implemented</p>
<CanHistoryPanel canId={canId} />
</section>
</div>
{/* // NOTE: Right Column */}
Expand Down
34 changes: 24 additions & 10 deletions frontend/src/components/CANs/CANDetailView/CANDetailView.test.jsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import CANDetailView from "./CANDetailView";
import { Provider } from "react-redux";
import store from "../../../store";

const mockProps = {
canId: 500,
description: "Test CAN Description",
number: "CAN-123",
nickname: "Test Nickname",
portfolioName: "Test Portfolio",
teamLeaders: [
{ id: 1, full_name: "John Doe" },
{ id: 2, full_name: "Jane Smith" }
{ id: 1, full_name: "John Doe", email: "[email protected]" },
{ id: 2, full_name: "Jane Smith", email: "[email protected]" }
],
divisionDirectorFullName: "Director Name",
divisionName: "Test Division"
divisionName: "Test Division",
canHistoryItems: [
{
id: 1,
can_id: 500,
ops_event_id: 1,
history_title: "Test History Title",
history_message: "Test History Message",
timestamp: "2021-01-01T00:00:00Z",
history_type: "Test History Type"
}
]
};

describe("CANDetailView", () => {
it("renders all CAN details correctly", () => {
render(
<dl>
<Provider store={store}>
<CANDetailView {...mockProps} />
</dl>
</Provider>
);

// Check for basic text content
Expand All @@ -40,23 +54,23 @@ describe("CANDetailView", () => {

it("renders history section", () => {
render(
<dl>
<Provider store={store}>
<CANDetailView {...mockProps} />
</dl>
</Provider>
);

expect(screen.getByText("History")).toBeInTheDocument();
expect(screen.getByText("Not yet implemented")).toBeInTheDocument();
// TODO: Add more specific tests for history section
});

it("renders without team leaders", () => {
render(
<dl>
<Provider store={store}>
<CANDetailView
{...mockProps}
teamLeaders={[]}
/>
</dl>
</Provider>
);

// Verify other content still renders
Expand Down
99 changes: 99 additions & 0 deletions frontend/src/components/CANs/CANHistoryPanel/CANHistoryPanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import InfiniteScroll from "../../Agreements/AgreementDetails/InfiniteScroll";
import { useGetCanHistoryQuery } from "../../../api/opsAPI";
import LogItem from "../../UI/LogItem";

/**
* @typedef {Object} CanHistoryPanelProps
* @property {number} canId
*/

/**
* @param {CanHistoryPanelProps} props
*/

const CanHistoryPanel = ({ canId }) => {
const [offset, setOffset] = useState(0);
const [stopped, setStopped] = useState(false);
/**
* @type {CanHistoryItem[]}
*/
const initialHistory = [];
/**
* @typedef {import('../../CANs/CANTypes').CanHistoryItem} CanHistoryItem
* @type {[CanHistoryItem[], React.Dispatch<React.SetStateAction<CanHistoryItem[]>>]}
*/
const [cantHistory, setCanHistory] = useState(initialHistory);

const {
data: canHistoryItems,
isError,
isLoading,
isFetching
} = useGetCanHistoryQuery({
canId,
limit: 5,
offset: offset
});

useEffect(() => {
if (canHistoryItems && canHistoryItems.length > 0) {
setCanHistory([...cantHistory, ...canHistoryItems]);
}
if (!isLoading && canHistoryItems && canHistoryItems.length === 0) {
setStopped(true);
}
if (isError) {
setStopped(true);
}
}, [canHistoryItems]);

const fetchMoreData = () => {
if (stopped) return;
if (!isFetching) {
setOffset(offset + 5);
}
return Promise.resolve();
};

return (
<>
{cantHistory.length > 0 ? (
<div
className="overflow-y-scroll force-show-scrollbars"
style={{ height: "15rem" }}
data-cy="can-history-container"
role="region"
aria-live="polite"
aria-label="CAN History"
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={0}
>
<ul
className="usa-list--unstyled"
data-cy="can-history-list"
>
{cantHistory.map((item) => (
<LogItem
key={item.id}
title={item.history_title}
createdOn={item.timestamp}
message={item.history_message}
/>
))}
</ul>
{!stopped && (
<InfiniteScroll
fetchMoreData={fetchMoreData}
isLoading={isFetching}
/>
)}
</div>
) : (
<p>No History</p>
)}
</>
);
};

export default CanHistoryPanel;
1 change: 1 addition & 0 deletions frontend/src/components/CANs/CANHistoryPanel/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./CANHistoryPanel";
10 changes: 10 additions & 0 deletions frontend/src/components/CANs/CANTypes.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,13 @@ export type FundingSummaryCAN = {
carry_forward_label: string;
expiration_date: string;
};

export type CanHistoryItem = {
id: number;
can_id: number;
ops_event_id: number;
history_title: string;
history_message: string;
timestamp: string;
history_type: string;
}
20 changes: 17 additions & 3 deletions frontend/src/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,16 @@ export const convertCodeForDisplay = (listName, code) => {
return codeMap[code] ? codeMap[code] : code;
};

/**
* Converts a date to a relative time string (e.g., "2 hours ago", "about a minute ago")
* @param {(Date|string)} dateParam - The date to convert. Can be a Date object or an ISO string
* @returns {string|null} A human-readable string representing relative time,
* formatted date string for dates older than 24 hours,
* or null if no date provided
* @example
* timeAgo("2023-05-20T15:00:00") // returns "about a minute ago"
* timeAgo(new Date()) // returns "now"
*/
export const timeAgo = (dateParam) => {
if (!dateParam) {
return null;
Expand All @@ -238,8 +248,9 @@ export const timeAgo = (dateParam) => {

const date = typeof dateParam === "object" ? dateParam : new Date(dateParam);
const today = new Date();
const seconds = Math.round((today - date) / 1000);
const seconds = Math.round((today.getTime() - date.getTime()) / 1000);
const minutes = Math.round(seconds / 60);
const hours = Math.round(minutes / 60);

if (seconds < 5) {
return "now";
Expand All @@ -249,11 +260,14 @@ export const timeAgo = (dateParam) => {
return "about a minute ago";
} else if (minutes < 60) {
return `${minutes} minutes ago`;
} else if (hours === 1) {
return `${hours} hour ago`;
} else if (hours < 24) {
return `${hours} hours ago`;
}

return new Date(date).toLocaleString("en-US", {
dateStyle: "long",
timeStyle: "short"
dateStyle: "long"
});
};

Expand Down
Loading

0 comments on commit 099076f

Please sign in to comment.