From 614cc2dca1402690954418efaaa13a41b059d386 Mon Sep 17 00:00:00 2001 From: Rocio De Santiago Date: Fri, 2 Aug 2024 17:16:48 -0500 Subject: [PATCH] add unit test for TemplateCard --- .../accordions/TemplateCardAccordion.test.tsx | 83 ++++++++++ .../accordions/TemplateCardAccordion.tsx | 55 +++++++ .../ui-src/src/components/app/AppRoutes.tsx | 3 +- .../components/cards/TemplateCard.test.tsx | 66 ++++++++ .../src/components/cards/TemplateCard.tsx | 149 +++++++++++++++++ services/ui-src/src/components/index.ts | 5 + .../ui-src/src/components/layout/HomePage.tsx | 54 ++++++ .../src/components/tables/Table.test.tsx | 27 +++ .../ui-src/src/components/tables/Table.tsx | 155 ++++++++++++++++++ services/ui-src/src/constants.ts | 2 + services/ui-src/src/types/other.ts | 7 + services/ui-src/src/utils/index.ts | 1 + services/ui-src/src/verbiage/pages/home.ts | 43 +++++ 13 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 services/ui-src/src/components/accordions/TemplateCardAccordion.test.tsx create mode 100644 services/ui-src/src/components/accordions/TemplateCardAccordion.tsx create mode 100644 services/ui-src/src/components/cards/TemplateCard.test.tsx create mode 100644 services/ui-src/src/components/cards/TemplateCard.tsx create mode 100644 services/ui-src/src/components/layout/HomePage.tsx create mode 100644 services/ui-src/src/components/tables/Table.test.tsx create mode 100644 services/ui-src/src/components/tables/Table.tsx create mode 100644 services/ui-src/src/verbiage/pages/home.ts diff --git a/services/ui-src/src/components/accordions/TemplateCardAccordion.test.tsx b/services/ui-src/src/components/accordions/TemplateCardAccordion.test.tsx new file mode 100644 index 00000000..b4f81b65 --- /dev/null +++ b/services/ui-src/src/components/accordions/TemplateCardAccordion.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +// utils +import { RouterWrappedComponent } from "utils/testing/setupJest"; +// components +import { TemplateCardAccordion } from "components"; +import verbiage from "verbiage/pages/home"; +import { AnyObject } from "types"; +import { testA11y } from "utils/testing/commonTests"; + +const accordionComponent = (mockProps?: AnyObject) => { + const props = { + verbiage: verbiage.cards.WP.accordion, + ...mockProps, + }; + return ( + + + + ); +}; + +const accordionContent = verbiage.cards.WP.accordion.text[0].content; +const accordionButtonLabel = verbiage.cards.WP.accordion.buttonLabel; + +describe("", () => { + test("Accordion is visible", () => { + render(accordionComponent()); + expect(screen.getByText(accordionButtonLabel)).toBeVisible(); + }); + + test("Accordion default closed state only shows the question", () => { + render(accordionComponent()); + expect(screen.getByText(accordionButtonLabel)).toBeVisible(); + expect(screen.getByText(accordionContent)).not.toBeVisible(); + }); + + test("Accordion should show answer on click", async () => { + render(accordionComponent()); + const accordionQuestion = screen.getByText(accordionButtonLabel); + expect(accordionQuestion).toBeVisible(); + expect(screen.getByText(accordionContent)).not.toBeVisible(); + await userEvent.click(accordionQuestion); + expect(accordionQuestion).toBeVisible(); + expect(screen.getByText(accordionContent)).toBeVisible(); + }); + + test("Accordion should render a list when given one", async () => { + const mockProps = { + verbiage: { + buttonLabel: "expand", + list: ["item one", "item two", "item three"], + }, + }; + + render(accordionComponent(mockProps)); + const button = screen.getByText("expand"); + await userEvent.click(button); + + expect(screen.getByText("item one")).toBeVisible(); + expect(screen.getByText("item two")).toBeVisible(); + expect(screen.getByText("item three")).toBeVisible(); + }); + + test("Accordion should render a table when given one", async () => { + const mockProps = { + verbiage: { + buttonLabel: "expand", + table: { + headRow: ["mock column header"], + }, + }, + }; + + render(accordionComponent(mockProps)); + const button = screen.getByText("expand"); + await userEvent.click(button); + + expect(screen.getByText("mock column header")).toBeVisible(); + }); + + testA11y(accordionComponent()); +}); diff --git a/services/ui-src/src/components/accordions/TemplateCardAccordion.tsx b/services/ui-src/src/components/accordions/TemplateCardAccordion.tsx new file mode 100644 index 00000000..857a8cdc --- /dev/null +++ b/services/ui-src/src/components/accordions/TemplateCardAccordion.tsx @@ -0,0 +1,55 @@ +// components +import { Accordion, ListItem, UnorderedList } from "@chakra-ui/react"; +import { AccordionItem, Table } from "components"; +// utils +import { AnyObject } from "types"; +import { parseCustomHtml } from "utils"; + +export const TemplateCardAccordion = ({ verbiage, ...props }: Props) => ( + + + {parseCustomHtml(verbiage.text)} + {verbiage.table && ( + + )} + {verbiage.list && ( + + {verbiage.list.map((listItem: string, index: number) => ( + {listItem} + ))} + + )} + + +); + +interface Props { + verbiage: AnyObject; + [key: string]: any; +} + +const sx = { + root: { + marginTop: "2rem", + }, + text: { + p: { + marginBottom: "1rem", + }, + }, + table: { + "tr td:last-of-type": { + fontWeight: "semibold", + }, + }, + list: { + paddingLeft: "1rem", + "li:last-of-type": { + fontWeight: "bold", + }, + }, +}; diff --git a/services/ui-src/src/components/app/AppRoutes.tsx b/services/ui-src/src/components/app/AppRoutes.tsx index 2cb554b6..de936c09 100644 --- a/services/ui-src/src/components/app/AppRoutes.tsx +++ b/services/ui-src/src/components/app/AppRoutes.tsx @@ -1,11 +1,12 @@ import { Route, Routes } from "react-router-dom"; -import { HelpPage } from "components"; +import { HelpPage, HomePage } from "components"; export const AppRoutes = () => { return (
{/* General Routes */} + } /> } />
diff --git a/services/ui-src/src/components/cards/TemplateCard.test.tsx b/services/ui-src/src/components/cards/TemplateCard.test.tsx new file mode 100644 index 00000000..2c02ce7d --- /dev/null +++ b/services/ui-src/src/components/cards/TemplateCard.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +// components +import { TemplateCard } from "components"; +// utils +import { mockUseStore, RouterWrappedComponent } from "utils/testing/setupJest"; +import { useStore } from "utils"; +// verbiage +import verbiage from "verbiage/pages/home"; +import { testA11y } from "utils/testing/commonTests"; + +jest.mock("utils/other/useBreakpoint", () => ({ + useBreakpoint: jest.fn(() => ({ + isDesktop: true, + })), +})); + +jest.mock("utils/state/useStore"); + +const mockedUseStore = useStore as jest.MockedFunction; + +const mockUseNavigate = jest.fn(); + +jest.mock("react-router-dom", () => ({ + useNavigate: () => mockUseNavigate, +})); + +const wpTemplateVerbiage = verbiage.cards.QM; + +const wpTemplateCardComponent = ( + + + +); + +describe("", () => { + describe("Renders", () => { + beforeEach(() => { + render(wpTemplateCardComponent); + }); + + test("QM TemplateCard is visible", () => { + expect(screen.getByText(wpTemplateVerbiage.title)).toBeVisible(); + }); + + test("QM TemplateCard image is visible on desktop", () => { + const imageAltText = "Spreadsheet icon"; + expect(screen.getByAltText(imageAltText)).toBeVisible(); + }); + + test("QM TemplateCard link is visible on desktop", () => { + const templateCardLink = wpTemplateVerbiage.link.text; + expect(screen.getByText(templateCardLink)).toBeVisible(); + }); + + test("QM TemplateCard navigates to next route on link click", async () => { + mockedUseStore.mockReturnValue(mockUseStore); + const templateCardLink = screen.getByText(wpTemplateVerbiage.link.text)!; + await userEvent.click(templateCardLink); + const expectedRoute = wpTemplateVerbiage.link.route; + await expect(mockUseNavigate).toHaveBeenCalledWith(expectedRoute); + }); + }); + + testA11y(wpTemplateCardComponent); +}); diff --git a/services/ui-src/src/components/cards/TemplateCard.tsx b/services/ui-src/src/components/cards/TemplateCard.tsx new file mode 100644 index 00000000..89e5ac61 --- /dev/null +++ b/services/ui-src/src/components/cards/TemplateCard.tsx @@ -0,0 +1,149 @@ +// components +import { Button, Flex, Heading, Image, Text, Link } from "@chakra-ui/react"; +import { Card, TemplateCardAccordion } from "components"; +// utils +import { useNavigate } from "react-router-dom"; +import { useBreakpoint, getSignedTemplateUrl } from "utils"; +import { AnyObject } from "types"; +// assets +import downloadIcon from "assets/icons/icon_download.png"; +import nextIcon from "assets/icons/icon_next_white.png"; +import spreadsheetIcon from "assets/icons/icon_spreadsheet.png"; + +const downloadTemplate = async (templateName: string) => { + const signedUrl = await getSignedTemplateUrl(templateName); + const link = document.createElement("a"); + link.setAttribute("target", "_blank"); + link.setAttribute("href", signedUrl); + link.click(); + link.remove(); +}; + +export const TemplateCard = ({ + templateName, + verbiage, + cardprops, + isHidden, + ...props +}: Props) => { + const { isDesktop } = useBreakpoint(); + const navigate = useNavigate(); + + return ( + + + {isDesktop && ( + + )} + + {verbiage.title} + + {verbiage.body.available} + {/* + {verbiage.linkText} + */} + {verbiage.midText} + + {verbiage.linkText2} + + {/* {verbiage.postLinkText} */} + + + {verbiage.downloadText && ( + + )} + {!isHidden && ( + + )} + + + Notes on when it's due + + + + ); +}; + +interface Props { + verbiage: AnyObject; + isHidden?: boolean; + [key: string]: any; +} + +const sx = { + root: { + flexDirection: "row", + }, + spreadsheetIcon: { + marginRight: "2rem", + boxSize: "5.5rem", + }, + cardContentFlex: { + width: "100%", + flexDirection: "column", + }, + cardTitleText: { + marginBottom: "0.5rem", + fontSize: "lg", + fontWeight: "bold", + lineHeight: "1.5", + }, + actionsFlex: { + flexFlow: "wrap", + gridGap: "1rem", + justifyContent: "space-between", + margin: "1rem 0 0 1rem", + ".mobile &": { + flexDirection: "column", + }, + }, + templateDownloadButton: { + justifyContent: "start", + marginRight: "1rem", + padding: "0", + span: { + marginLeft: "0rem", + marginRight: "0.5rem", + }, + ".mobile &": { + marginRight: "0", + }, + }, + formLink: { + justifyContent: "start", + span: { + marginLeft: "0.5rem", + marginRight: "-0.25rem", + }, + }, + textMargin: { + paddingTop: "1rem", + }, +}; diff --git a/services/ui-src/src/components/index.ts b/services/ui-src/src/components/index.ts index 18c73cb5..3e0c8d4a 100644 --- a/services/ui-src/src/components/index.ts +++ b/services/ui-src/src/components/index.ts @@ -1,6 +1,7 @@ // accordions export { AccordionItem } from "./accordions/AccordionItem"; export { FaqAccordion } from "./accordions/FaqAccordion"; +export { TemplateCardAccordion } from "./accordions/TemplateCardAccordion"; // alerts export { Alert } from "./alerts/Alert"; export { ErrorAlert } from "./alerts/ErrorAlert"; @@ -8,6 +9,7 @@ export { ErrorAlert } from "./alerts/ErrorAlert"; export { App } from "./app/App"; export { Error } from "./app/Error"; // layout +export { HomePage } from "./layout/HomePage"; export { Header } from "./layout/Header"; export { PageTemplate } from "./layout/PageTemplate"; export { Footer } from "./layout/Footer"; @@ -16,6 +18,7 @@ export { Timeout } from "./layout/Timeout"; // cards export { Card } from "./cards/Card"; export { EmailCard } from "./cards/EmailCard"; +export { TemplateCard } from "./cards/TemplateCard"; // logins export { LoginCognito } from "./logins/LoginCognito"; export { LoginIDM } from "./logins/LoginIDM"; @@ -26,3 +29,5 @@ export { Menu } from "./menus/Menu"; export { MenuOption } from "./menus/MenuOption"; // Redirects export { PostLogoutRedirect } from "./PostLogoutRedirect/index"; +// tables +export { Table } from "./tables/Table"; diff --git a/services/ui-src/src/components/layout/HomePage.tsx b/services/ui-src/src/components/layout/HomePage.tsx new file mode 100644 index 00000000..5aa5075f --- /dev/null +++ b/services/ui-src/src/components/layout/HomePage.tsx @@ -0,0 +1,54 @@ +// components +import { Box, Heading, Link, Text } from "@chakra-ui/react"; +import { PageTemplate, TemplateCard } from "components"; +// utils +import verbiage from "verbiage/pages/home"; + +export const HomePage = () => { + const { intro, cards } = verbiage; + + return ( + + <> + + + {intro.header} + + + {intro.body.preLinkText} + + {intro.body.linkText} + + {intro.body.postLinkText} + + + + + + + ); +}; + +const sx = { + layout: { + ".contentFlex": { + marginTop: "3.5rem", + }, + }, + introTextBox: { + width: "100%", + marginBottom: "2.25rem", + }, + headerText: { + marginBottom: "1rem", + fontSize: "2rem", + fontWeight: "normal", + }, + card: { + marginBottom: "2rem", + }, +}; diff --git a/services/ui-src/src/components/tables/Table.test.tsx b/services/ui-src/src/components/tables/Table.test.tsx new file mode 100644 index 00000000..4a9af6f4 --- /dev/null +++ b/services/ui-src/src/components/tables/Table.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from "@testing-library/react"; +// utils +import { RouterWrappedComponent } from "utils/testing/setupJest"; +//components +import { Table } from "components"; +import { testA11y } from "utils/testing/commonTests"; + +const tableContent = { + caption: "mock caption", + headRow: ["mock header 1", "mock header 2", "mock header 3"], + bodyRows: [], +}; + +const tableComponent = ( + +
+ +); + +describe("
", () => { + test("Table is visible", () => { + render(tableComponent); + expect(screen.getByRole("table")).toBeVisible(); + }); + + testA11y(tableComponent); +}); diff --git a/services/ui-src/src/components/tables/Table.tsx b/services/ui-src/src/components/tables/Table.tsx new file mode 100644 index 00000000..db1bc71d --- /dev/null +++ b/services/ui-src/src/components/tables/Table.tsx @@ -0,0 +1,155 @@ +import { ReactNode } from "react"; +// components +import { + Table as TableRoot, + TableCaption, + Tbody, + Td, + Tfoot, + Th, + Thead, + Tr, + VisuallyHidden, +} from "@chakra-ui/react"; +// utils +import { sanitizeAndParseHtml } from "utils"; +// types +import { AnyObject, TableContentShape } from "types"; +import { notAnsweredText } from "../../constants"; + +export const Table = ({ + content, + variant, + border, + sxOverride, + ariaOverride, + children, + ...props +}: Props) => { + return ( + + + {content.caption} + + {content.headRow && ( + + {/* Head Row */} + + {content.headRow.map((headerCell: string, index: number) => ( + + ))} + + + )} + + {/* if children prop is passed, just render the children */} + {children && children} + {/* if content prop is passed, parse and render rows and cells */} + {content.bodyRows && + content.bodyRows!.map((row: string[], index: number) => ( + + {row.map((cell: string, rowIndex: number) => ( + + ))} + + ))} + + + {content.footRow && + content.footRow?.map((row: string[], index: number) => { + return ( + + {row.map((headerCell: string, rowIndex: number) => { + return ( + + ); + })} + + ); + })} + + + ); +}; + +interface Props { + content: TableContentShape; + variant?: string; + border?: boolean; + sxOverride?: AnyObject; + ariaOverride?: TableContentShape; + children?: ReactNode; + [key: string]: any; +} + +const sx = { + root: { + width: "100%", + }, + captionBox: { + margin: 0, + padding: 0, + height: 0, + }, + tableHeader: { + padding: "0.75rem 0.5rem", + fontSize: "sm", + fontWeight: "semibold", + borderColor: "palette.gray_lighter", + textTransform: "none", + letterSpacing: "normal", + ".mobile &": { + fontSize: "xs", + }, + }, + tableCell: { + padding: "0.75rem 0.5rem", + borderStyle: "none", + fontWeight: "normal", + ".mobile &": { + fontSize: "xs", + }, + }, + tableCellBorder: { + padding: "0.75rem 0.5rem", + borderBottom: "1px solid", + borderColor: "palette.gray_lighter", + fontWeight: "normal", + ".mobile &": { + fontSize: "xs", + }, + }, + ".two-column &": {}, // TODO: add additional styling for two-column dynamic field tables if needed + notAnswered: { + color: "palette.error_darker", + }, +}; diff --git a/services/ui-src/src/constants.ts b/services/ui-src/src/constants.ts index d9662dc4..a518605d 100644 --- a/services/ui-src/src/constants.ts +++ b/services/ui-src/src/constants.ts @@ -1,6 +1,8 @@ // HOST DOMAIN export const PRODUCTION_HOST_DOMAIN = "mdcthcbs.cms.gov"; +export const notAnsweredText = "Not answered"; + // STATES export enum States { AL = "Alabama", diff --git a/services/ui-src/src/types/other.ts b/services/ui-src/src/types/other.ts index 74369107..7725dc01 100644 --- a/services/ui-src/src/types/other.ts +++ b/services/ui-src/src/types/other.ts @@ -15,6 +15,13 @@ export interface DateShape { day: number; } +export interface TableContentShape { + caption?: string; + headRow?: string[]; + bodyRows?: string[][]; + footRow?: string[][]; +} + export interface TimeShape { hour: number; minute: number; diff --git a/services/ui-src/src/utils/index.ts b/services/ui-src/src/utils/index.ts index f3488ebe..b4752841 100644 --- a/services/ui-src/src/utils/index.ts +++ b/services/ui-src/src/utils/index.ts @@ -1,5 +1,6 @@ // api export * from "./api/providers/ApiProvider"; +export * from "./api/requestMethods/getTemplateUrl"; // auth export * from "./auth/UserProvider"; export * from "./auth/authLifecycle"; diff --git a/services/ui-src/src/verbiage/pages/home.ts b/services/ui-src/src/verbiage/pages/home.ts new file mode 100644 index 00000000..cf02f3e9 --- /dev/null +++ b/services/ui-src/src/verbiage/pages/home.ts @@ -0,0 +1,43 @@ +export default { + intro: { + header: "Home and Community Based Services (HCBS) Portal", + body: { + preLinkText: + "Get started by completing the Home and Community-Based Services (HCBS) for your state or territory. Learn more about this ", + linkText: "new data collection tool", + linkLocation: + "https://www.medicaid.gov/medicaid/long-term-services-supports/money-follows-person/index.html", + postLinkText: " from CMS.", + }, + }, + cards: { + QM: { + title: "HCBS Quality Measures", + body: { + available: "The HCBS is ... ", + }, + linkText: "6071(a)(1) of the Deficit Reduction Act (DRA)", + linkLocation: + "https://www.govinfo.gov/content/pkg/PLAW-109publ171/pdf/PLAW-109publ171.pdf", + postLinkText: + ' as "increasing the use of home and community-based, rather than institutional, long-term care services."', + downloadText: "User Guide and Help File", + link: { + text: "Enter HCBS QM online", + route: "wp", + }, + accordion: { + buttonLabel: "When are the HCBS Quality Measures due?", + text: [ + { + content: + "The HCBS Quality Measures will be created and submitted ...", + }, + { + content: "The HCBS Quality Measures deadlines are TBD ...", + }, + ], + }, + }, + }, +};
+ {sanitizeAndParseHtml(headerCell)} +
+ {sanitizeAndParseHtml(cell)} +
+ {sanitizeAndParseHtml(headerCell)} +