From 8e65060cb78308845c72099780d67df401a6539d Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 18 Oct 2024 15:18:51 -0400 Subject: [PATCH] CRDCDH-1740 Init refactor of History Dialog --- src/components/HistoryDialog/index.test.tsx | 39 +++ src/components/HistoryDialog/index.tsx | 364 ++++++++++++-------- src/components/StatusBar/StatusBar.test.tsx | 29 +- src/graphql/getSubmission.ts | 1 + src/types/Globals.d.ts | 26 +- 5 files changed, 303 insertions(+), 156 deletions(-) diff --git a/src/components/HistoryDialog/index.test.tsx b/src/components/HistoryDialog/index.test.tsx index 3d9eb7bf..75434f14 100644 --- a/src/components/HistoryDialog/index.test.tsx +++ b/src/components/HistoryDialog/index.test.tsx @@ -153,6 +153,45 @@ describe("Implementation Requirements", () => { ).toHaveAttribute("title", "2024-09-05T14:45:00Z"); }); + it("should render the name for each history item if provided", () => { + const history: HistoryBase[] = [ + { + status: "uploaded", + dateTime: "2024-09-25T14:45:00Z", + userID: "test", + userName: "Test User", + }, + { + status: "downloaded", + dateTime: "2024-09-11T14:45:00Z", + userID: "test", + userName: "Another User", + }, + ]; + + const { getByTestId } = render(); + + expect( + within(getByTestId("history-item-0")).getByTestId("history-item-0-name") + ).toHaveTextContent("Test User"); + + expect( + within(getByTestId("history-item-1")).getByTestId("history-item-1-name") + ).toHaveTextContent("Another User"); + }); + + it("should not render the name for each history item if not provided", () => { + const history: HistoryBase[] = [ + { status: "uploaded", dateTime: "2024-09-25T14:45:00Z", userID: "test" }, + { status: "downloaded", dateTime: "2024-09-11T14:45:00Z", userID: "test" }, + ]; + + const { queryByTestId } = render(); + + expect(queryByTestId("history-item-0-name")).not.toBeInTheDocument(); + expect(queryByTestId("history-item-1-name")).not.toBeInTheDocument(); + }); + it("should fallback to #FFF if getTextColor is not a function", () => { const history: HistoryBase[] = [ { status: "uploaded", dateTime: "2024-09-05T14:45:00Z", userID: "test", reviewComment: "" }, diff --git a/src/components/HistoryDialog/index.tsx b/src/components/HistoryDialog/index.tsx index 26162357..07ab4696 100644 --- a/src/components/HistoryDialog/index.tsx +++ b/src/components/HistoryDialog/index.tsx @@ -1,17 +1,15 @@ -import { CSSProperties, useMemo } from "react"; +import { CSSProperties, memo, useCallback, useMemo } from "react"; import { - Avatar, Button, Dialog, DialogActions, DialogContent, DialogProps, DialogTitle, - Stack, Typography, + Grid, + styled, } from "@mui/material"; -import { Timeline, TimelineItem, TimelineSeparator, TimelineDot, TimelineContent } from "@mui/lab"; -import { styled } from "@mui/material/styles"; import { FormatDate, SortHistory } from "../../utils"; const StyledDialog = styled(Dialog)({ @@ -32,7 +30,6 @@ const StyledDialogTitle = styled(DialogTitle)({ const StyledPreTitle = styled("p")({ color: "#D5DAE7", fontSize: "13px", - fontFamily: "Nunito Sans", lineHeight: "27px", letterSpacing: "0.5px", textTransform: "uppercase", @@ -51,109 +48,22 @@ const StyledTitle = styled("p")({ const StyledDialogContent = styled(DialogContent)({ marginTop: "20px", marginBottom: "22px", + overflowY: "visible", }); -const StyledTimelineItem = styled(TimelineItem)({ - alignItems: "center", - "&::before": { - flex: "0", - padding: "0", - paddingLeft: "55px", - }, - // Add vertical separator line between timeline items - "&:not(:last-of-type)::after": { - content: '" "', - height: "1px", - left: "0", - right: "0", - bottom: "0", - position: "absolute", - background: "#375F9A", - }, - // Add vertical lines between timeline item dots (top) - "&:not(:first-of-type) .MuiTimelineSeparator-root::before": { - content: '" "', - width: "6px", - background: "#fff", - top: "0", - bottom: "50%", - right: "50%", - position: "absolute", - transform: "translateX(50%)", - zIndex: "1", - }, - // Add vertical lines between timeline item dots (bottom) - "&:not(:last-of-type) .MuiTimelineSeparator-root::after": { - content: '" "', - width: "6px", - background: "#fff", - top: "50%", - bottom: "0", - right: "50%", - position: "absolute", - transform: "translateX(50%)", - zIndex: "1", - }, -}); - -const StyledTimelineSeparator = styled(TimelineSeparator)({ - position: "relative", - minHeight: "70px", -}); - -const StyledTimelineDot = styled(TimelineDot)({ - background: "#fff", - borderWidth: "4px", - margin: "0", - zIndex: "2", - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", -}); - -const StyledTimelineVerticalLine = styled("span")({ - width: "60px", - height: "2px", - background: "#fff", +const StyledIcon = styled("div")({ position: "absolute", top: "50%", - left: "50%", - "&::after": { - content: '" "', - width: "6px", - height: "6px", - borderRadius: "50%", - background: "#fff", - top: "50%", - bottom: "0", - right: "0", - position: "absolute", - transform: "translateY(-50%)", - zIndex: "1", + transform: "translateY(-50%)", + lineHeight: "0", + "& img": { + WebkitUserDrag: "none", }, }); -const StyledTimelineContent = styled(TimelineContent)({ - marginLeft: "60px", - paddingRight: 0, - color: "#fff", -}); - -const StyledTypography = styled(Typography)<{ color: CSSProperties["color"] }>(({ color }) => ({ - lineHeight: "2.5", - minWidth: "100px", - textAlign: "left", - color, -})); - -const StyledAvatar = styled(Avatar)({ - background: "transparent", - marginRight: "8px", -}); - const StyledCloseButton = styled(Button)({ minWidth: "137px", + fontSize: "16px", fontWeight: "700", borderRadius: "8px", textTransform: "none", @@ -167,6 +77,106 @@ const StyledCloseButton = styled(Button)({ }, }); +const StyledGridHeader = styled(Grid)({ + borderBottom: "0.5px solid #375F9A", + paddingBottom: "8px", + marginBottom: "4px", +}); + +const StyledHistoryHeader = styled(Typography)({ + fontFamily: "Public Sans", + fontWeight: "300", + fontSize: "8px", + textAlign: "center", + textTransform: "uppercase", + color: "#9FB3D1", + userSelect: "none", +}); + +const StyledGridEventItem = styled(Grid)({ + padding: "20px 0", + borderBottom: "0.5px solid #375F9A", + alignItems: "center", +}); + +const StyledHistoryItem = styled(Typography)<{ + color: CSSProperties["color"]; + textAlign?: CSSProperties["textAlign"]; +}>(({ textAlign = "center", color }) => ({ + fontFamily: "Public Sans", + fontWeight: "400", + fontSize: "13px", + letterSpacing: "0.0025em", + userSelect: "none", + textAlign, + color, +})); + +const VerticalDot = styled("div")({ + position: "absolute", + top: "50%", + left: "0px", + transform: "translateY(-50%)", + content: '""', + width: "16px", + height: "16px", + borderRadius: "50%", + background: "white", +}); + +const TopConnector = styled("div")({ + content: '""', + position: "absolute", + left: "5px", + bottom: "0", + width: "6px", + height: "30px", // TODO: Rows can be different heights and this should be dynamic + background: "white", +}); + +const BottomConnector = styled("div")({ + content: '""', + position: "absolute", + left: "5px", + top: "0", + width: "6px", + height: "30px", // TODO: Rows can be different heights and this should be dynamic + background: "white", +}); + +const HorizontalLine = styled("div")({ + // Primary horizontal line + position: "absolute", + top: "50%", + left: "0px", + transform: "translateY(-50%)", + content: '""', + width: "68px", + height: "1px", + background: "white", + // End dot adornment + "&::after": { + content: '""', + position: "absolute", + top: "50%", + right: "0px", + transform: "translateY(-50%)", + width: "5px", + height: "5px", + borderRadius: "50%", + background: "white", + }, +}); + +type EventItem = { + color: string; + icon: string | null; + status: string; + date: string; + nameColor: string; + name: string | null; +}; + export type IconType = Record; type Props = { @@ -179,9 +189,9 @@ type Props = { } & DialogProps; /** - * Status Bar History Section + * A generic history dialog component that displays a list of history transitions. * - * @returns {JSX.Element} + * @returns {JSX.Element} The history dialog component */ const HistoryDialog = ({ preTitle, @@ -192,8 +202,42 @@ const HistoryDialog = ({ open, onClose, ...rest -}: Props) => { - const sortedHistory = useMemo(() => SortHistory(history), [history]); +}: Props): JSX.Element => { + const getColor = useCallback( + (status: T) => { + if (typeof getTextColor === "function") { + return getTextColor(status); + } + + return "#FFF"; + }, + [getTextColor] + ); + + const events = useMemo(() => { + const result: EventItem[] = []; + const sorted = SortHistory(history); + + sorted.forEach((item, index) => { + const { status, dateTime, ...others } = item; + + result.push({ + color: getColor(status), + icon: index === 0 && iconMap[status] ? iconMap[status] : null, + status: status || "", + date: dateTime, + name: "userName" in others ? others.userName : null, + nameColor: index === 0 ? getColor(status) : "#97B5CE", + }); + }); + + return result; + }, [history, iconMap, getColor]); + + const eventHasNames: boolean = useMemo( + () => events.some((event) => event.name !== null), + [events] + ); return ( ({ {title} - - {sortedHistory?.map(({ status, dateTime }, index) => ( - - - - - - - - - {FormatDate(dateTime, "M/D/YYYY", "N/A")} - - - {status?.toString()?.toUpperCase()} - - {index === 0 && iconMap && iconMap[status] && ( - - {`${status} - - )} - - - - ))} - + + + + Status + + + Date + + {eventHasNames && ( + + User + + )} + {/* TODO: fine tune spacing when no name column is shown */} + + + {events?.map(({ status, date, color, name, nameColor, icon }, index) => ( + + +
+ {index !== 0 && } + + + {index !== events.length - 1 && } +
+
+ + + {status?.toUpperCase()} + + + + + {FormatDate(date, "M/D/YYYY", "N/A")} + + + {eventHasNames && ( + + {name} + + )} + {/* TODO: fine tune spacing when no name column is shown */} + + {icon && ( + + {`${status} + + )} + +
+ ))}
Close @@ -264,4 +335,5 @@ const HistoryDialog = ({ ); }; -export default HistoryDialog; +// TODO: type this +export default memo(HistoryDialog); diff --git a/src/components/StatusBar/StatusBar.test.tsx b/src/components/StatusBar/StatusBar.test.tsx index c503c72a..7f3e0ec5 100644 --- a/src/components/StatusBar/StatusBar.test.tsx +++ b/src/components/StatusBar/StatusBar.test.tsx @@ -1,6 +1,6 @@ import { FC, useMemo } from "react"; import { BrowserRouter } from "react-router-dom"; -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, waitFor, within } from "@testing-library/react"; import { axe } from "jest-axe"; import { ContextState, Context as FormCtx, Status as FormStatus } from "../Contexts/FormContext"; import StatusBar from "./StatusBar"; @@ -351,13 +351,26 @@ describe("StatusBar > History Modal Tests", () => { fireEvent.click(getByText("Full History")); - const elements = getByTestId("status-bar-history-dialog").querySelectorAll("li"); - expect(elements[0]).toHaveTextContent(/Rejected/i); - expect(elements[0]).toHaveTextContent("11/24/2023"); - expect(elements[1]).toHaveTextContent(/In Progress/i); - expect(elements[1]).toHaveTextContent("11/22/2023"); - expect(elements[2]).toHaveTextContent(/New/i); - expect(elements[2]).toHaveTextContent("11/20/2023"); + expect( + within(getByTestId("history-item-0")).getByTestId("history-item-0-status") + ).toHaveTextContent(/Rejected/i); + expect( + within(getByTestId("history-item-0")).getByTestId("history-item-0-date") + ).toHaveTextContent("11/24/2023"); + + expect( + within(getByTestId("history-item-1")).getByTestId("history-item-1-status") + ).toHaveTextContent(/In Progress/i); + expect( + within(getByTestId("history-item-1")).getByTestId("history-item-1-date") + ).toHaveTextContent("11/22/2023"); + + expect( + within(getByTestId("history-item-2")).getByTestId("history-item-2-status") + ).toHaveTextContent(/New/i); + expect( + within(getByTestId("history-item-2")).getByTestId("history-item-2-date") + ).toHaveTextContent("11/20/2023"); }); it("renders only the most recent event with an icon", () => { diff --git a/src/graphql/getSubmission.ts b/src/graphql/getSubmission.ts index b7b07e27..e3bd41aa 100644 --- a/src/graphql/getSubmission.ts +++ b/src/graphql/getSubmission.ts @@ -51,6 +51,7 @@ export const query = gql` reviewComment dateTime userID + userName } conciergeName conciergeEmail diff --git a/src/types/Globals.d.ts b/src/types/Globals.d.ts index c8af3693..50265ee6 100644 --- a/src/types/Globals.d.ts +++ b/src/types/Globals.d.ts @@ -47,10 +47,32 @@ type FormGroupCheckboxOption = { type SelectOption = { label: string; value: string | number }; type HistoryBase = { + /** + * The transitioned status of the history event. + */ status: T; - reviewComment?: string; - dateTime: string; // YYYY-MM-DDTHH:MM:SSZ format + /** + * The ISO 8601 date and time the history event occurred. + * + * @note This is in the format of `YYYY-MM-DDTHH:MM:SSZ`. + */ + dateTime: string; + /** + * The ID of the user who initiated the history event. + */ userID: string; + /** + * The name of the user who initiated the history event. + * + * @note This is not populated in all events. + */ + userName?: string; + /** + * The comment associated with the history event. + * + * @note This is not populated in all events. + */ + reviewComment?: string; }; declare module "*.pdf";