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 history query and renders to can details page #3345

Merged
merged 11 commits into from
Jan 27, 2025
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
Loading