diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3026d93278..fb9b5cd8df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: pass_filenames: false always_run: true - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.12.0-2 + rev: v3.13.1-1 hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git @@ -90,7 +90,7 @@ repos: - "config/keycloak/realms/ol-local-realm.json" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.1" + rev: "v0.15.12" hooks: - id: ruff-format - id: ruff diff --git a/RELEASE.rst b/RELEASE.rst index e7c6025532..316c73ddef 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,15 @@ Release Notes ============= +Version 0.65.1 +-------------- + +- do not promote ocw page contentfiles to resources (#3261) +- dashboard b2c series certificate display (#3256) +- flaky test test_learning_resources_serializer (#3252) +- [pre-commit.ci] pre-commit autoupdate (#2973) +- Update dependency sharp to v0.34.5 (#2707) + Version 0.65.0 (Released April 28, 2026) -------------- diff --git a/frontends/main/package.json b/frontends/main/package.json index acccf5759c..fe1573debd 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -63,7 +63,7 @@ "react-hotkeys-hook": "^5.2.1", "react-markdown": "^10.0.0", "react-slick": "^0.31.0", - "sharp": "0.34.4", + "sharp": "0.34.5", "slick-carousel": "^1.8.1", "tiny-invariant": "^1.3.3", "video.js": "^8.23.7", diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index 1ff5215871..324f84033e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -2291,5 +2291,103 @@ describe("EnrollmentDisplay", () => { expect(cards[1]).toHaveTextContent(courseA.title) expect(cards[2]).toHaveTextContent(courseB.title) }) + + test("displays certificate button when program enrollment has a certificate", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const certUuid = "test-program-cert-uuid" + const program = mitxonline.factories.programs.program({ + id: 456, + title: "Program With Certificate", + courses: [10, 11], + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: { + uuid: certUuid, + link: `/certificate/program/${certUuid}/`, + }, + }) + const courses = mitxonline.factories.courses.courses({ count: 2 }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get(mitxonline.urls.programs.programDetail(456), program) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + courses, + ) + + renderWithProviders() + + await screen.findByText("Program With Certificate") + const certButton = screen.getByRole("link", { name: "Certificate" }) + const expectedCertHref = programEnrollment.certificate?.link?.replace( + /\/$/, + "", + ) + expect(certButton).toBeInTheDocument() + expect(certButton).toHaveAttribute("href", expectedCertHref) + expect(certButton).not.toHaveAttribute("target") + }) + + test("does not display certificate button when program enrollment has no certificate", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const program = mitxonline.factories.programs.program({ + id: 457, + title: "Program Without Certificate", + courses: [12, 13], + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: null, + }) + const courses = mitxonline.factories.courses.courses({ count: 2 }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get(mitxonline.urls.programs.programDetail(457), program) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + courses, + ) + + renderWithProviders() + + await screen.findByText("Program Without Certificate") + const certButton = screen.queryByRole("link", { name: "Certificate" }) + expect(certButton).not.toBeInTheDocument() + }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index e89d7a66ed..02a06c2a29 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -13,7 +13,7 @@ import { styled, theme, } from "ol-components" -import { Alert } from "@mitodl/smoot-design" +import { Alert, ButtonLink } from "@mitodl/smoot-design" import { keepPreviousData, useQuery } from "@tanstack/react-query" import { EnrollmentStatus, @@ -43,6 +43,7 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { getIdsFromReqTree } from "@/common/mitxonline" +import { RiAwardFill } from "@remixicon/react" const Wrapper = styled.div(({ theme }) => ({ marginTop: "32px", @@ -107,6 +108,11 @@ const ShowAllContainer = styled.div(({ theme }) => ({ }, })) +export const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({ + color: theme.custom.colors.red, + width: "120px", +})) + const alphabeticalSort = (a: CourseRunEnrollmentV3, b: CourseRunEnrollmentV3) => a.run.course.title.localeCompare(b.run.course.title) @@ -550,6 +556,8 @@ const ProgramEnrollmentDisplay: React.FC = ({ programEnrollmentsById, ) + const programCertificateUrl = programEnrollment?.certificate?.link ?? null + if (isLoading) { return ( @@ -578,14 +586,26 @@ const ProgramEnrollmentDisplay: React.FC = ({ {program?.title} - - You have completed - - {" "} - {completedCount} of {totalCount} courses{" "} + + + You have completed + + {" "} + {completedCount} of {totalCount} courses{" "} + + for this program. - for this program. - + {programCertificateUrl && ( + } + href={programCertificateUrl} + > + Certificate + + )} + {requirementSections.map((section, index) => { const { completed: sectionCompleted, total: sectionTotal } = 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 82bd3becc3..3a501d41cf 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -13,6 +13,14 @@ import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { waitFor } from "@testing-library/react" import invariant from "tiny-invariant" import moment from "moment" +import NiceModal from "@ebay/nice-modal-react" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { UnenrollProgramDialog } from "./DashboardDialogs" + +jest.mock("posthog-js/react") +const mockedUseFeatureFlagEnabled = jest + .mocked(useFeatureFlagEnabled) + .mockImplementation(() => false) describe("ProgramAsCourseCard", () => { setupLocationMock() @@ -364,4 +372,168 @@ describe("ProgramAsCourseCard", () => { screen.queryByRole("dialog", { name: moduleWithRun.title }), ).not.toBeInTheDocument() }) + + test("displays certificate button when program enrollment has a certificate", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const certUuid = "test-certificate-uuid-123" + const programEnrollmentWithCert = { + ...cardData.courseProgramEnrollment, + certificate: { + uuid: certUuid, + link: `/certificate/program/${certUuid}/`, + }, + } + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const certButton = screen.getByRole("link", { name: "Certificate" }) + const expectedCertHref = programEnrollmentWithCert.certificate.link.replace( + /\/$/, + "", + ) + expect(certButton).toBeInTheDocument() + expect(certButton).toHaveAttribute("href", expectedCertHref) + expect(certButton).not.toHaveAttribute("target") + }) + + test("does not display certificate button when program enrollment has no certificate", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const programEnrollmentNoCert = { + ...cardData.courseProgramEnrollment, + certificate: null, + } + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const certButton = screen.queryByRole("link", { name: "Certificate" }) + expect(certButton).not.toBeInTheDocument() + }) + + test("shows legacy details link in context menu when product pages flag is disabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + const detailsLink = await screen.findByRole("menuitem", { + name: "View Course Details", + }) + expect(detailsLink).toHaveAttribute( + "href", + expect.stringContaining( + `/programs/${cardData.courseProgram.readable_id}`, + ), + ) + expect(detailsLink).toHaveAttribute( + "href", + expect.stringContaining("ecom-service=true"), + ) + }) + + test("shows product-page details link in context menu when product pages flag is enabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + const cardData = setupCardData({ includeProgramEnrollment: true }) + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + const detailsLink = await screen.findByRole("menuitem", { + name: "View Course Details", + }) + expect(detailsLink).toHaveAttribute( + "href", + `/courses/p/${cardData.courseProgram.readable_id}`, + ) + }) + + test("clicking Unenroll menu item opens UnenrollProgramDialog with readable_id", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + cardData.courseProgramEnrollment.enrollment_mode = "audit" + const modalShowSpy = jest.spyOn(NiceModal, "show") + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + await user.click(await screen.findByRole("menuitem", { name: "Unenroll" })) + + expect(modalShowSpy).toHaveBeenCalledWith(UnenrollProgramDialog, { + title: cardData.courseProgram.title, + enrollment: cardData.courseProgram.readable_id, + }) + modalShowSpy.mockRestore() + }) + + test("does not show Unenroll option in context menu for verified enrollment", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + cardData.courseProgramEnrollment.enrollment_mode = "verified" + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + expect( + screen.queryByRole("menuitem", { name: "Unenroll" }), + ).not.toBeInTheDocument() + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index f3a4cf8d2c..752f47cba3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -1,5 +1,13 @@ import React from "react" -import { Link, Popover, Stack, Typography, styled } from "ol-components" +import { + Link, + Popover, + SimpleMenu, + SimpleMenuItem, + Stack, + Typography, + styled, +} from "ol-components" import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, @@ -23,7 +31,16 @@ import { formatDate } from "ol-utilities" import { getIdsFromReqTree, isVerifiedEnrollmentMode, + mitxonlineLegacyUrl, } from "@/common/mitxonline" +import { ActionButton } from "@mitodl/smoot-design" +import { RiAwardFill, RiMore2Line } from "@remixicon/react" +import NiceModal from "@ebay/nice-modal-react" +import { UnenrollProgramDialog } from "./DashboardDialogs" +import { ProgramCertificateButton } from "./EnrollmentDisplay" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { programPageView } from "@/common/urls" const ProgramCardRoot = styled.div(({ theme }) => ({ display: "flex", @@ -127,6 +144,23 @@ const ProgramCardBody = styled.div({ borderRadius: "0 0 8px 8px", }) +const MenuButton = styled(ActionButton)<{ + status: EnrollmentStatus +}>(({ theme, status }) => [ + { + marginLeft: "-8px", + [theme.breakpoints.down("md")]: { + position: "absolute", + top: "0", + right: "0", + }, + }, + status !== EnrollmentStatus.Completed && + status !== EnrollmentStatus.Enrolled && { + visibility: "hidden", + }, +]) + const getTimezone = (dateString: string): string => { const tz = new Date(dateString) @@ -246,19 +280,65 @@ const getRelativeDateContent = ( } } +const getContextMenuItems = ( + title: string, + resource: ProgramAsCourse, + enrollmentMode: string | null | undefined, + additionalItems: SimpleMenuItem[] = [], + useProductPages = false, +) => { + const menuItems = [] + const detailsUrl = useProductPages + ? programPageView({ + readable_id: resource.readable_id, + display_mode: "course", + }) + : mitxonlineLegacyUrl(`/programs/${resource.readable_id}`) + + const courseMenuItems = [] + + if (detailsUrl) { + courseMenuItems.push({ + className: "dashboard-card-menu-item", + key: "view-course-details", + label: "View Course Details", + href: detailsUrl, + }) + } + + if (enrollmentMode && !isVerifiedEnrollmentMode(enrollmentMode)) { + courseMenuItems.push({ + className: "dashboard-card-menu-item", + key: "unenroll", + label: "Unenroll", + onClick: () => { + NiceModal.show(UnenrollProgramDialog, { + title, + enrollment: resource.readable_id, + }) + }, + }) + } + + menuItems.push(...courseMenuItems) + return [...menuItems, ...additionalItems] +} + +interface ProgramAsCourse { + id: number + readable_id: string + title?: string | null + start_date?: string | null + end_date?: string | null + courses?: number[] + req_tree?: V2ProgramRequirement[] +} + interface ProgramAsCourseCardProps { /** * The courselike program to display. */ - courseProgram: { - id: number - readable_id: string - title?: string | null - start_date?: string | null - end_date?: string | null - courses?: number[] - req_tree?: V2ProgramRequirement[] - } + courseProgram: ProgramAsCourse /** * child courses of the program. These correspond to nodes in the req_tree. */ @@ -289,6 +369,7 @@ interface ProgramAsCourseCardProps { enrollment_mode?: string | null } Component?: React.ElementType + contextMenuItems?: SimpleMenuItem[] className?: string } @@ -313,8 +394,12 @@ const ProgramAsCourseCard: React.FC = ({ courseProgramEnrollment, ancestorProgramEnrollment, Component, + contextMenuItems = [], className, }) => { + const useProductPages = useFeatureFlagEnabled( + FeatureFlags.MitxOnlineProductPages, + ) const moduleRequirementSection = courseProgram?.req_tree?.find( (node) => node.data.node_type === "operator", ) @@ -384,6 +469,35 @@ const ProgramAsCourseCard: React.FC = ({ ancestorProgramEnrollment?.enrollment_mode, ].some(isVerifiedEnrollmentMode) + const programCertificateUrl = + courseProgramEnrollment?.certificate?.link ?? null + + // Build context menu + const menuItems = getContextMenuItems( + courseProgram.title ?? "", + courseProgram, + courseProgramEnrollment?.enrollment_mode, + contextMenuItems, + useProductPages ?? false, + ) + + const contextMenu = ( +