diff --git a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx index 56e3b73679..9b321fa855 100644 --- a/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/ContractContent.test.tsx @@ -334,16 +334,14 @@ describe("ContractContent", () => { expect(completedCard).toBeDefined() expect(enrolledCard).toBeDefined() - // Check enrollment status indicators - const completedIndicator = within(completedCard!).getByTestId( - "enrollment-status", - ) - const enrolledIndicator = within(enrolledCard!).getByTestId( - "enrollment-status", - ) - - expect(completedIndicator).toHaveTextContent(/^Completed$/) - expect(enrolledIndicator).toHaveTextContent(/^Enrolled$/) + // Check progress badges (the enrollment-status icon is reserved for + // compact program module rows; contract cards convey status via the + // ProgressBadge next to the card type label instead) + const completedBadge = within(completedCard!).getByTestId("progress-badge") + const enrolledBadge = within(enrolledCard!).getByTestId("progress-badge") + + expect(completedBadge).toHaveTextContent(/^Completed$/) + expect(enrolledBadge).toHaveTextContent(/^In Progress$/) }) test("Renders program collections", async () => { @@ -1271,15 +1269,17 @@ describe("ContractContent", () => { ).findAllByTestId("enrollment-card-desktop") expect(cards.length).toBe(3) - // First card should show enrolled status - const cardStatus0 = within(cards[0]).getByTestId("enrollment-status") + // Progress badges (the enrollment-status icon is reserved for compact + // program module rows; contract cards convey status via the + // ProgressBadge next to the card type label instead) + const cardStatus0 = within(cards[0]).getByTestId("progress-badge") expect(cardStatus0).toHaveTextContent(/^Completed$/) - const cardStatus1 = within(cards[1]).getByTestId("enrollment-status") - expect(cardStatus1).toHaveTextContent(/^Enrolled$/) + const cardStatus1 = within(cards[1]).getByTestId("progress-badge") + expect(cardStatus1).toHaveTextContent(/^In Progress$/) - const cardStatus2 = within(cards[2]).getByTestId("enrollment-status") - expect(cardStatus2).toHaveTextContent(/^Not Enrolled$/) + const cardStatus2 = within(cards[2]).getByTestId("progress-badge") + expect(cardStatus2).toHaveTextContent(/^Not Started$/) }) test("shows the not found screen if the organization is not found by orgSlug", async () => { diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CardShared.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CardShared.tsx index 49f7a3dbb7..f41ad23adf 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CardShared.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CardShared.tsx @@ -64,18 +64,21 @@ const CardRoot = styled.div<{ ]) const CardTypeText = styled(Typography)(({ theme }) => ({ - ...theme.typography.subtitle4, + ...theme.typography.body3, color: theme.custom.colors.silverGrayDark, })) const TitleHeading = styled.h3(({ theme }) => ({ margin: 0, + ...theme.typography.subtitle2, [theme.breakpoints.down("md")]: { maxWidth: "calc(100% - 16px)", }, })) -const TitleLink = styled(Link)() +const TitleLink = styled(Link)(({ theme }) => ({ + ...theme.typography.subtitle2, +})) const TitleText = styled.h3<{ clickable?: boolean }>( ({ theme, clickable }) => ({ @@ -89,41 +92,29 @@ const TitleText = styled.h3<{ clickable?: boolean }>( }), ) -const SubtitleLinkRoot = styled.div<{ layout?: "default" | "compact" }>( - ({ theme, layout = "default" }) => ({ - display: "flex", - alignItems: "center", - gap: "8px", - flex: 1, - color: - layout === "compact" - ? theme.custom.colors.silverGrayDark - : theme.custom.colors.darkGray2, - ...theme.typography.subtitle3, - }), -) +const SubtitleLinkRoot = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + flex: 1, + color: theme.custom.colors.red, + ...theme.typography.body3, +})) -const SubtitleLink = styled(NextLink)<{ layout?: "default" | "compact" }>( - ({ theme, layout = "default" }) => ({ - ...theme.typography.subtitle3, - color: - layout === "compact" - ? theme.custom.colors.silverGrayDark - : theme.custom.colors.mitRed, - display: "flex", - alignItems: "center", - gap: "4px", - ":hover": { - textDecoration: "underline", - }, - }), -) +const SubtitleLink = styled(NextLink)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.red, + display: "flex", + alignItems: "center", + gap: "2px", + ":hover": { + textDecoration: "underline", + }, +})) const MenuButton = styled(ActionButton)<{ status: EnrollmentStatus }>(({ theme, status }) => [ { - marginLeft: "-8px", [theme.breakpoints.down("md")]: { position: "absolute", top: "0", @@ -170,6 +161,14 @@ const Separator = styled.span(({ theme }) => ({ backgroundColor: theme.custom.colors.silverGrayLight, })) +const Ellipse = styled.span(({ theme }) => ({ + display: "inline-block", + width: "4px", + height: "4px", + borderRadius: "50%", + backgroundColor: theme.custom.colors.silverGrayLight, +})) + const DateText = styled(Typography)(({ theme }) => ({ ...theme.typography.subtitle3, color: theme.custom.colors.silverGrayDark, @@ -190,7 +189,7 @@ const UpgradedBannerRoot = styled.div(({ theme }) => ({ alignItems: "center", gap: "4px", color: theme.custom.colors.silverGrayDark, - ...theme.typography.subtitle3, + ...theme.typography.body3, })) const UpgradedBanner: React.FC<{ className?: string }> = ({ className }) => ( @@ -212,7 +211,7 @@ const DatePopoverContent = styled.div({ const DatePopoverTrigger = styled("button")<{ $upcoming: boolean }>( ({ theme, $upcoming }) => ({ - ...theme.typography.body2, + ...theme.typography.subtitle3, color: $upcoming ? theme.custom.colors.red : theme.custom.colors.silverGrayDark, @@ -332,6 +331,7 @@ export { CoursewareButton, CoursewareButtonLink, Separator, + Ellipse, DateText, CourseDateText, CourseDateSummary, diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CoursewareCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CoursewareCard.tsx index 48e87572ce..e4e0dbc0e5 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CoursewareCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/CoursewareCard.tsx @@ -42,6 +42,8 @@ type CourseDisplayProps = { layout?: "default" | "compact" headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6" onUpgradeError?: (error: string) => void + /** Display "Module" instead of "Course" as the card type label. */ + isModule?: boolean } /** A course row from a program/contract dashboard (full entry available). */ @@ -91,7 +93,7 @@ const CoursewareCard: React.FC = (props) => { ) } - const { layout, headingLevel, onUpgradeError } = props + const { layout, headingLevel, onUpgradeError, isModule } = props if (props.kind === "enrollment") { return ( @@ -101,6 +103,7 @@ const CoursewareCard: React.FC = (props) => { layout={layout} headingLevel={headingLevel} onUpgradeError={onUpgradeError} + isModule={isModule} Component={Component} className={className} /> @@ -120,6 +123,7 @@ const CoursewareCard: React.FC = (props) => { layout={layout} headingLevel={headingLevel} onUpgradeError={onUpgradeError} + isModule={isModule} Component={Component} className={className} /> @@ -131,6 +135,7 @@ const CoursewareCard: React.FC = (props) => { ancestorContext={entry.ancestorContext} layout={layout} headingLevel={headingLevel} + isModule={isModule} Component={Component} className={className} /> diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx index d0730f0a24..6225ea3fd1 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.test.tsx @@ -244,77 +244,6 @@ describe.each([ ).not.toBeInTheDocument() }) - // --------------------------------------------------------------------------- - // Enrollment status indicator - // --------------------------------------------------------------------------- - - test.each([ - { - enrollmentData: { - grades: [], - certificate: null, - b2b_contract_id: faker.number.int(), // B2B so showNotComplete=true - }, - expectedLabel: "Enrolled", - }, - { - enrollmentData: { - grades: [mitxonline.factories.enrollment.grade({ passed: true })], - certificate: { - uuid: faker.string.uuid(), - link: faker.internet.url(), - }, - b2b_contract_id: null, - }, - expectedLabel: "Completed", - }, - { - enrollmentData: { - grades: [mitxonline.factories.enrollment.grade({ passed: true })], - certificate: null, - b2b_contract_id: faker.number.int(), // B2B so indicator is visible - }, - expectedLabel: "Completed", - }, - ])( - "Enrollment status indicator shows '$expectedLabel'", - ({ enrollmentData, expectedLabel }) => { - setupUserApis() - const enrollment = - mitxonline.factories.enrollment.courseEnrollment(enrollmentData) - renderWithProviders() - expect( - within(getCard()).getByTestId("enrollment-status"), - ).toHaveTextContent(expectedLabel) - }, - ) - - test("showNotComplete: status indicator visible for enrolled (not completed) B2B enrollment", () => { - setupUserApis() - const enrollment = mitxonline.factories.enrollment.courseEnrollment({ - grades: [], - certificate: null, - b2b_contract_id: faker.number.int(), - }) - renderWithProviders() - expect( - within(getCard()).getByTestId("enrollment-status"), - ).toBeInTheDocument() - }) - - test("showNotComplete: status indicator hidden for enrolled (not completed) non-B2B enrollment", () => { - setupUserApis() - const enrollment = mitxonline.factories.enrollment.courseEnrollment({ - grades: [], - certificate: null, - b2b_contract_id: null, - }) - renderWithProviders() - expect( - within(getCard()).queryByTestId("enrollment-status"), - ).not.toBeInTheDocument() - }) - // --------------------------------------------------------------------------- // Upgrade banner // --------------------------------------------------------------------------- @@ -584,7 +513,7 @@ describe.each([ ) }) - test("Does not show 'Certificate track' when verified enrollment has a certificate", () => { + test("Shows 'Certificate track' alongside 'View Certificate' when verified enrollment has a certificate", () => { setupUserApis() const certUuid = faker.string.uuid() const enrollment = mitxonline.factories.enrollment.courseEnrollment({ @@ -593,9 +522,9 @@ describe.each([ run: currentRunDates, }) renderWithProviders() - expect( - within(getCard()).queryByTestId("upgraded-banner"), - ).not.toBeInTheDocument() + expect(within(getCard()).getByTestId("upgraded-banner")).toHaveTextContent( + "Certificate track", + ) expect( within(getCard()).getByRole("link", { name: /View Certificate/ }), ).toBeInTheDocument() @@ -808,3 +737,172 @@ describe("EnrolledCourseCard — multiple enrollment runs", () => { expect(screen.getAllByText("Course runs (5)").length).toBeGreaterThan(0) }) }) + +// The enrollment status icon only renders for compact program module rows +// (i.e. within ProgramAsCourseCard), and even then only for non-B2B +// enrollments. Every other card conveys progress via the ProgressBadge shown +// next to the card type label instead. +describe("EnrolledCourseCard enrollment status icon (module rows)", () => { + setupLocationMock() + + const getDesktopCard = () => screen.getByTestId("enrollment-card-desktop") + + test.each([ + { + enrollmentData: { grades: [], certificate: null }, + expectedLabel: "Enrolled", + }, + { + enrollmentData: { + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + certificate: { + uuid: faker.string.uuid(), + link: faker.internet.url(), + }, + }, + expectedLabel: "Completed", + }, + ])( + "Module row shows '$expectedLabel' status icon", + ({ enrollmentData, expectedLabel }) => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + ...enrollmentData, + b2b_contract_id: null, + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).getByTestId("enrollment-status"), + ).toHaveTextContent(expectedLabel) + }, + ) + + test("hidden for B2B module rows", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + grades: [], + certificate: null, + b2b_contract_id: faker.number.int(), + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).queryByTestId("enrollment-status"), + ).not.toBeInTheDocument() + }) + + test("hidden for non-module cards", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + }) + renderWithProviders() + expect( + within(getDesktopCard()).queryByTestId("enrollment-status"), + ).not.toBeInTheDocument() + }) +}) + +describe("EnrolledCourseCard card type label", () => { + setupLocationMock() + + const getDesktopCard = () => screen.getByTestId("enrollment-card-desktop") + + test("shows 'Course' for a non-B2B enrollment", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + }) + renderWithProviders() + expect(within(getDesktopCard()).getByText("Course")).toBeInTheDocument() + expect( + within(getDesktopCard()).queryByText("Module"), + ).not.toBeInTheDocument() + }) + + test("shows 'Module' for a B2B enrollment", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: faker.number.int(), + }) + renderWithProviders() + expect(within(getDesktopCard()).getByText("Module")).toBeInTheDocument() + expect( + within(getDesktopCard()).queryByText("Course"), + ).not.toBeInTheDocument() + }) + + test("shows enrollment status indicator instead of 'Module' text when isModule is set (compact layout)", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + grades: [], + certificate: null, + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).getByTestId("enrollment-status"), + ).toBeInTheDocument() + expect( + within(getDesktopCard()).queryByText("Module"), + ).not.toBeInTheDocument() + }) +}) + +describe("EnrolledCourseCard progress badge", () => { + setupLocationMock() + + const getDesktopCard = () => screen.getByTestId("enrollment-card-desktop") + + test.each([ + { + enrollmentData: { grades: [], certificate: null }, + expectedLabel: "In Progress", + }, + { + enrollmentData: { + grades: [mitxonline.factories.enrollment.grade({ passed: true })], + certificate: { + uuid: faker.string.uuid(), + link: faker.internet.url(), + }, + }, + expectedLabel: "Completed", + }, + ])( + "shows '$expectedLabel' next to the card type label", + ({ enrollmentData, expectedLabel }) => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + ...enrollmentData, + b2b_contract_id: null, + }) + renderWithProviders() + expect( + within(getDesktopCard()).getByTestId("progress-badge"), + ).toHaveTextContent(expectedLabel) + }, + ) + + test("hidden on compact module rows, where the status icon takes over", () => { + setupUserApis() + const enrollment = mitxonline.factories.enrollment.courseEnrollment({ + b2b_contract_id: null, + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).queryByTestId("progress-badge"), + ).not.toBeInTheDocument() + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx index caf7a535e3..f24f92ce6e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrolledCourseCard.tsx @@ -8,6 +8,7 @@ import { CoursewareButtonLink, MenuButton, Separator, + Ellipse, SubtitleLink, SubtitleLinkRoot, TitleHeading, @@ -28,7 +29,7 @@ import { RiArrowUpCircleLine, RiAwardLine, RiMore2Line } from "@remixicon/react" import { useReplaceBasketItem } from "@/common/mitxonline/useReplaceBasketItem" import { isInPast, calendarDaysUntil, NoSSR } from "ol-utilities" import { SiblingRunsAccordion } from "./SiblingRunsAccordion" -import { EnrollmentStatusIndicator } from "./EnrollmentStatusIndicator" +import { EnrollmentStatusIcon } from "./EnrollmentStatus" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { Button, ButtonLink } from "@mitodl/smoot-design" @@ -37,18 +38,34 @@ import NiceModal from "@ebay/nice-modal-react" import { EmailSettingsDialog, UnenrollDialog } from "./DashboardDialogs" import { getReceiptMenuItem } from "./receiptMenuItem" import { CourseRunEnrollmentV3 } from "@mitodl/mitxonline-api-axios/v2" +import { ProgressBadge } from "./ProgressBadge" const formatUpgradeTime = (daysFloat: number) => { if (daysFloat < 0) return "" const days = Math.floor(daysFloat) if (days > 1) { - return ` · ${days} days remaining` + return `${days} days remaining` } else if (days === 1) { - return ` · ${days} day remaining` + return `${days} day remaining` } - return " · Less than a day remaining" + return "Less than a day remaining" } +const RedEllipse = styled(Ellipse)(({ theme }) => ({ + marginLeft: "6px", + marginRight: "6px", + backgroundColor: theme.custom.colors.red, +})) + +const EnrolledTitleLink = styled(TitleLink)<{ + enrollmentstatus: EnrollmentStatus +}>(({ enrollmentstatus, theme }) => ({ + color: + enrollmentstatus === EnrollmentStatus.Completed + ? theme.custom.colors.silverGrayDark + : theme.custom.colors.black, +})) + const UpgradeBanner: React.FC< { canUpgrade: boolean @@ -56,7 +73,6 @@ const UpgradeBanner: React.FC< certificateUpgradePrice?: string | null productId?: number | null onError?: (error: Error) => void - layout?: "default" | "compact" } & React.HTMLAttributes > = ({ canUpgrade, @@ -64,7 +80,6 @@ const UpgradeBanner: React.FC< certificateUpgradePrice, productId, onError, - layout = "default", ...others }) => { const replaceBasketItem = useReplaceBasketItem() @@ -95,16 +110,19 @@ const UpgradeBanner: React.FC< : null return ( - - + + {`Upgrade for certificate - ${formattedPrice}`} {calendarDays !== null && ( - - {/* This uses local time. */} - {formatUpgradeTime(calendarDays)} - + <> + + + {/* This uses local time. */} + {formatUpgradeTime(calendarDays)} + + )} ) @@ -152,6 +170,7 @@ type EnrolledCourseCardProps = { layout?: "default" | "compact" headingLevel?: "h2" | "h3" | "h4" | "h5" | "h6" onUpgradeError?: (error: string) => void + isModule?: boolean Component?: React.ElementType className?: string } @@ -162,15 +181,22 @@ export const EnrolledCourseCard = ({ layout = "default", headingLevel, onUpgradeError, + isModule, Component, className, }: EnrolledCourseCardProps) => { const course = enrollment.run.course const run = enrollment.run + const isCompact = layout === "compact" const isContractPageResource = Boolean(enrollment.b2b_contract_id) + const cardTypeLabelText = + isModule || isContractPageResource ? "Module" : "Course" + const cardTypeLabel = + !isModule && !isCompact ? ( + {cardTypeLabelText} + ) : null const mitxOnlineUser = useQuery(mitxUserQueries.me()) const isStaff = mitxOnlineUser.data?.is_staff - const isCompact = layout === "compact" const title = isCompact ? course.title : run?.title || course.title const coursewareUrl = run?.courseware_url const certificateLink = getCertificateLink( @@ -204,19 +230,21 @@ export const EnrolledCourseCard = ({ }) const upgradedAndIncomplete = !isContractPageResource && isVerifiedEnrollmentMode(enrollmentMode) - const certStatus = certificateLink ? ( - - - {isCompact ? "Certificate" : "View Certificate"} - - ) : showUpgradeBanner ? ( + const certButton = certificateLink ? ( + <> + + + {isCompact ? "Certificate" : "View Certificate"} + + + ) : null + const certStatus = showUpgradeBanner ? ( { onUpgradeError?.( "There was a problem adding the certificate to your cart.", @@ -232,12 +260,12 @@ export const EnrolledCourseCard = ({ certStatus, ].filter(Boolean) - const endDateAndCertSection = + const endDateAndUpgradeSection = metaSegments.length > 0 ? ( - + {metaSegments.map((segment, i) => ( - {i > 0 && } + {i > 0 && } {segment} ))} @@ -247,14 +275,19 @@ export const EnrolledCourseCard = ({ {coursewareUrl ? ( - + {title} - + ) : ( {title} )} - {isCompact ? null : endDateAndCertSection} + {endDateAndUpgradeSection} ) // Determine if button should be disabled @@ -312,19 +345,20 @@ export const EnrolledCourseCard = ({ ) const buttonSection = isCompact ? ( - - {endDateAndCertSection} - {endDateAndCertSection && } + + {certButton && ( + <> + {certButton} + + + )} {ctaButton} ) : ( <> - + {certButton} {ctaButton} ) @@ -386,7 +420,18 @@ export const EnrolledCourseCard = ({ /> ) + const progressBadgeSection = + isModule && isCompact ? null : ( + + + + {cardTypeLabel} + + ) + const hasMultipleRuns = (siblingEnrollments?.length ?? 0) > 0 + const showEnrollmentStatusIcon = + !isContractPageResource && isModule && isCompact return ( <> @@ -398,8 +443,18 @@ export const EnrolledCourseCard = ({ as={Component} > - - Course + {showEnrollmentStatusIcon && ( + + + + )} + + {progressBadgeSection} {titleSection} @@ -420,8 +475,13 @@ export const EnrolledCourseCard = ({ className={className} layout={layout} > - - Course + {showEnrollmentStatusIcon && ( + + + + )} + + {progressBadgeSection} {titleSection} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatus.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatus.tsx new file mode 100644 index 0000000000..b5c577bf82 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatus.tsx @@ -0,0 +1,69 @@ +import React from "react" +import { styled } from "ol-components" +import { VisuallyHidden } from "@mitodl/smoot-design" +import { EnrollmentStatus } from "./helpers" +import { + RiCheckboxBlankCircleFill, + RiCheckboxCircleFill, +} from "@remixicon/react" + +const CompleteIcon = styled(RiCheckboxCircleFill)(({ theme }) => ({ + color: theme.custom.colors.red, + width: "16px", + height: "16px", +})) + +const InProgressIcon = styled(RiCheckboxBlankCircleFill)(({ theme }) => ({ + color: theme.custom.colors.red, + width: "16px", + height: "16px", +})) + +const Ring = styled.div(({ theme }) => ({ + width: "16px", + height: "16px", + borderRadius: "50%", + border: `1px solid ${theme.custom.colors.silverGrayLight}`, + padding: "2px", + display: "flex", + justifyContent: "center", + alignItems: "center", + img: { + width: "100%", + height: "100%", + }, +})) + +type EnrollmentStatusIconProps = { + status: EnrollmentStatus +} +const EnrollmentStatusIcon: React.FC = ({ + status, +}) => { + if (status === EnrollmentStatus.Completed) { + return ( + + + Completed + + ) + } + + if (status === EnrollmentStatus.Enrolled) { + return ( + + + Enrolled + + ) + } + + return ( + + Not Enrolled + + ) +} + +export { EnrollmentStatusIcon } +export type { EnrollmentStatusIconProps } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatusIndicator.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatusIndicator.tsx deleted file mode 100644 index 0010a3efc5..0000000000 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentStatusIndicator.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react" -import Image from "next/image" -import { styled } from "ol-components" -import { VisuallyHidden } from "@mitodl/smoot-design" -import CourseComplete from "@/public/images/icons/course-complete.svg" -import CourseInProgress from "@/public/images/icons/course-in-progress.svg" -import CourseUnenrolled from "@/public/images/icons/course-unenrolled.svg" -import { EnrollmentStatus } from "./helpers" - -const CompletedImage = styled(Image)({ - width: "16px", - height: "16px", -}) -const Ring = styled.div(({ theme }) => ({ - width: "16px", - height: "16px", - borderRadius: "50%", - border: `1px solid ${theme.custom.colors.silverGrayLight}`, - padding: "2px", - display: "flex", - justifyContent: "center", - alignItems: "center", - img: { - width: "100%", - height: "100%", - }, -})) - -type EnrollmentStatusIndicatorProps = { - status: EnrollmentStatus - showNotComplete?: boolean -} -const EnrollmentStatusIndicator: React.FC = ({ - status, - showNotComplete, -}) => { - if (status === EnrollmentStatus.Completed) { - return ( - - - Completed - - ) - } - if (!showNotComplete) return - - if (status === EnrollmentStatus.Enrolled) { - return ( - - - Enrolled - - ) - } - - return ( - - - Not Enrolled - - ) -} - -export { EnrollmentStatusIndicator } -export type { EnrollmentStatusIndicatorProps } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index e58f516311..29e0042ce1 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -122,6 +122,104 @@ describe("ProgramAsCourseCard", () => { ).toBeGreaterThan(0) }) + test("module rows show an enrollment status indicator instead of a 'Module' label", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) + // One status indicator per module row (desktop card only), replacing the type label + expect(screen.getAllByTestId("enrollment-status")).toHaveLength(2) + expect(screen.queryByText("Module")).not.toBeInTheDocument() + // The outer program-as-course card still shows its own 'Course' type label + // next to a progress badge; only the per-module rows omit it. + expect(screen.getByText("Course")).toBeInTheDocument() + }) + + test("progress badge reflects program enrollment status ('In Progress')", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const programEnrollmentWithoutCert = { + ...cardData.courseProgramEnrollment, + certificate: null, + } + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) + expect(screen.getByTestId("progress-badge")).toHaveTextContent( + "In Progress", + ) + }) + + test("progress badge reflects program enrollment status ('Not Started')", async () => { + const cardData = setupCardData() + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) + expect(screen.getByTestId("progress-badge")).toHaveTextContent( + "Not Started", + ) + }) + + test("progress badge reflects program enrollment status ('Completed')", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const programEnrollmentWithCert = { + ...cardData.courseProgramEnrollment, + certificate: { + uuid: "test-certificate-uuid-456", + link: "/certificate/test-certificate-uuid-456/", + }, + } + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { + name: cardData.courseProgram.title, + level: 3, + }) + expect(screen.getByTestId("progress-badge")).toHaveTextContent("Completed") + }) + test("renders when user is not enrolled in the ProgramAsCourse", async () => { const cardData = setupCardData() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index 8c508d667e..1f4ec07bf6 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -1,5 +1,11 @@ import React from "react" -import { SimpleMenu, SimpleMenuItem, Typography, styled } from "ol-components" +import { + SimpleMenu, + SimpleMenuItem, + Stack, + Typography, + styled, +} from "ol-components" import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, @@ -16,7 +22,12 @@ import { } from "./helpers" import { ProgressBadge } from "./ProgressBadge" import { CoursewareCard } from "./CoursewareCard" -import { CourseDateSummary, UpgradedBanner } from "./CardShared" +import { + CardTypeText, + CourseDateSummary, + Separator, + UpgradedBanner, +} from "./CardShared" import { getCertificateLink, buildCourseEntry, @@ -326,6 +337,14 @@ const ProgramAsCourseCard: React.FC = ({ /> ) + const progressBadgeSection = ( + + + + Course + + ) + return ( = ({ - + {progressBadgeSection} = ({ {courseProgram?.title} - <> + {programCertificateUrl ? ( = ({ ) : null} {contextMenu} - + @@ -391,6 +410,7 @@ const ProgramAsCourseCard: React.FC = ({ entry={entry} layout="compact" headingLevel="h4" + isModule onUpgradeError={onUpgradeError} /> ) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx index 52904aba2a..f27e93506e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.test.tsx @@ -101,7 +101,7 @@ describe.each([ ) }) - test("does not show 'Certificate track' when verified enrollment has a certificate", () => { + test("shows 'Certificate track' alongside 'View Certificate' when verified enrollment has a certificate", () => { const programEnrollment = mitxonline.factories.enrollment.programEnrollmentV3({ enrollment_mode: "verified", @@ -110,9 +110,12 @@ describe.each([ renderWithProviders( , ) + expect(within(getCard()).getByTestId("upgraded-banner")).toHaveTextContent( + "Certificate track", + ) expect( - within(getCard()).queryByTestId("upgraded-banner"), - ).not.toBeInTheDocument() + within(getCard()).getByRole("link", { name: /View Certificate/ }), + ).toBeInTheDocument() }) test("does not show 'Certificate track' for audit enrollment", () => { @@ -276,3 +279,34 @@ describe.each([ windowOpenSpy.mockRestore() }) }) + +// The progress badge only renders on the desktop card. +describe("ProgramEnrollmentCard progress badge", () => { + const getDesktopCard = () => screen.getByTestId("enrollment-card-desktop") + + test("shows 'In Progress' next to the 'Program' type label when no certificate is present", () => { + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + certificate: null, + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).getByTestId("progress-badge"), + ).toHaveTextContent("In Progress") + }) + + test("shows 'Completed' next to the 'Program' type label when a certificate is present", () => { + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + certificate: { uuid: "test-uuid", link: "/certificate/test-uuid/" }, + }) + renderWithProviders( + , + ) + expect( + within(getDesktopCard()).getByTestId("progress-badge"), + ).toHaveTextContent("Completed") + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx index 883ee28a7a..cf75d31833 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramEnrollmentCard.tsx @@ -8,6 +8,7 @@ import { CardRoot, CardTypeText, MenuButton, + Separator, SubtitleLink, TitleHeading, TitleLink, @@ -26,6 +27,7 @@ import { UnenrollProgramDialog } from "./DashboardDialogs" import { getReceiptMenuItem } from "./receiptMenuItem" import { SimpleMenu, Stack } from "ol-components" import { EnrollmentStatus } from "./helpers" +import { ProgressBadge } from "./ProgressBadge" type ProgramEnrollmentCardProps = { programEnrollment: V3UserProgramEnrollment @@ -65,25 +67,26 @@ export const ProgramEnrollmentCard = ({ ) : ( {title} )} - {certificateLink ? ( + {upgradedAndIncomplete ? : null} + + ) + const buttonSection = ( + <> + {certificateLink && ( View Certificate - ) : upgradedAndIncomplete ? ( - - ) : null} - - ) - const buttonSection = ( - - View - + )} + + View + + ) const detailsUrl = programPageView({ readable_id: readableId, @@ -142,6 +145,14 @@ export const ProgramEnrollmentCard = ({ /> ) + const progressBadgeSection = ( + + + + Program + + ) + return ( <> - - Program + + {progressBadgeSection} {titleSection} diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgressBadge.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgressBadge.tsx index 9ac1604b21..79a20a81ed 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgressBadge.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgressBadge.tsx @@ -1,6 +1,7 @@ import React from "react" import { styled, Typography } from "ol-components" import { EnrollmentStatus } from "./helpers" +import { RiCheckLine } from "@remixicon/react" const BadgeContainer = styled("div")<{ status: EnrollmentStatus @@ -17,6 +18,7 @@ const BadgeContainer = styled("div")<{ return { display: "flex", + width: "100px", padding: "4px 8px", borderRadius: "4px", justifyContent: "center", @@ -40,8 +42,11 @@ const ProgressBadge: React.FC = ({ enrollmentStatus }) => { : "Not Started" return ( - + {label} + {enrollmentStatus === EnrollmentStatus.Completed && ( + ) } diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx index 927a59a098..6a7bdd708d 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/SiblingRunsAccordion.tsx @@ -19,7 +19,7 @@ import { RiTimeLine, } from "@remixicon/react" import { isInPast, formatDate } from "ol-utilities" -import { EnrollmentStatusIndicator } from "./EnrollmentStatusIndicator" +import { EnrollmentStatusIcon } from "./EnrollmentStatus" import NextLink from "next/link" import { CourseRunEnrollmentV3 } from "@mitodl/mitxonline-api-axios/v2" @@ -234,10 +234,7 @@ const SiblingRunsAccordion: React.FC = ({ ) : isExpired ? (