diff --git a/env/backend.env b/env/backend.env index 30254af126..193f4ca2cf 100644 --- a/env/backend.env +++ b/env/backend.env @@ -68,3 +68,4 @@ USERINFO_URL=http://kc.ol.local:8066/realms/ol-local/protocol/openid-connect/use # Disable all celery tasks by default in local dev CELERY_BEAT_DISABLED=True +SESSION_COOKIE_DOMAIN="odl.local" diff --git a/frontends/api/src/mitxonline/hooks/enrollment/index.ts b/frontends/api/src/mitxonline/hooks/enrollment/index.ts index e0cc4855a2..93e5bb28fc 100644 --- a/frontends/api/src/mitxonline/hooks/enrollment/index.ts +++ b/frontends/api/src/mitxonline/hooks/enrollment/index.ts @@ -4,9 +4,10 @@ import { b2bApi, courseRunEnrollmentsApi } from "../../clients" import { B2bApiB2bEnrollCreateRequest, EnrollmentsApiEnrollmentsPartialUpdateRequest, + CourseRunEnrollmentRequest, } from "@mitodl/mitxonline-api-axios/v2" -const useCreateEnrollment = () => { +const useCreateB2bEnrollment = () => { const queryClient = useQueryClient() return useMutation({ mutationFn: (opts: B2bApiB2bEnrollCreateRequest) => @@ -19,6 +20,22 @@ const useCreateEnrollment = () => { }) } +const useCreateEnrollment = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (opts: CourseRunEnrollmentRequest) => { + return courseRunEnrollmentsApi.enrollmentsCreate({ + CourseRunEnrollmentRequest: opts, + }) + }, + onSettled: () => { + queryClient.invalidateQueries({ + queryKey: enrollmentKeys.courseRunEnrollmentsList(), + }) + }, + }) +} + const useUpdateEnrollment = () => { const queryClient = useQueryClient() return useMutation({ @@ -48,6 +65,7 @@ const useDestroyEnrollment = () => { export { enrollmentQueries, enrollmentKeys, + useCreateB2bEnrollment, useCreateEnrollment, useUpdateEnrollment, useDestroyEnrollment, diff --git a/frontends/api/src/mitxonline/test-utils/factories/courses.ts b/frontends/api/src/mitxonline/test-utils/factories/courses.ts index 4c51e2d7f2..64aee743b1 100644 --- a/frontends/api/src/mitxonline/test-utils/factories/courses.ts +++ b/frontends/api/src/mitxonline/test-utils/factories/courses.ts @@ -8,6 +8,7 @@ import type { } from "@mitodl/mitxonline-api-axios/v2" import { faker } from "@faker-js/faker/locale/en" import { UniqueEnforcer } from "enforce-unique" +import { has } from "lodash" const uniqueCourseId = new UniqueEnforcer() const uniqueCourseRunId = new UniqueEnforcer() @@ -122,7 +123,9 @@ const course: PartialFactory = ( Array.from({ length: faker.number.int({ min: 1, max: 3 }) }).map(() => courseRun(), ) - const nextRunId = overrides.next_run_id ?? faker.helpers.arrayElement(runs).id + const nextRunId = has(overrides, "next_run_id") + ? (overrides.next_run_id ?? null) + : faker.helpers.arrayElement(runs).id const defaults: CourseWithCourseRunsSerializerV2 = { id: uniqueCourseId.enforce(() => faker.number.int()), title: faker.lorem.words(3), diff --git a/frontends/api/src/mitxonline/test-utils/urls.ts b/frontends/api/src/mitxonline/test-utils/urls.ts index 6cd42e74e0..671eef5f0f 100644 --- a/frontends/api/src/mitxonline/test-utils/urls.ts +++ b/frontends/api/src/mitxonline/test-utils/urls.ts @@ -18,9 +18,9 @@ const countries = { } const enrollment = { - enrollmentsList: () => `${API_BASE_URL}/api/v1/enrollments/`, courseEnrollment: (id?: number) => `${API_BASE_URL}/api/v1/enrollments/${id ? `${id}/` : ""}`, + enrollmentsListV1: () => `${API_BASE_URL}/api/v1/enrollments/`, enrollmentsListV2: () => `${API_BASE_URL}/api/v2/enrollments/`, } diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts index adeb42c2a5..46a8ba54fd 100644 --- a/frontends/jest-shared-setup.ts +++ b/frontends/jest-shared-setup.ts @@ -4,14 +4,19 @@ import "cross-fetch/polyfill" import { resetAllWhenMocks } from "jest-when" import * as matchers from "jest-extended" import { mockRouter } from "ol-test-utilities/mocks/nextNavigation" +import { setDefaultTimezone } from "ol-test-utilities" expect.extend(matchers) +setDefaultTimezone("UTC") + // env vars process.env.NEXT_PUBLIC_MITOL_API_BASE_URL = - "http://api.test.learn.odl.local:8063" + "http://api.test.learn.odl.local:8065" process.env.NEXT_PUBLIC_MITX_ONLINE_BASE_URL = - "http://api.test.mitxonline.odl.local:8053" + "http://api.test.learn.odl.local:8065/mitxonline" +process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL = + "http://mitxonline.odl.local:8065" process.env.NEXT_PUBLIC_ORIGIN = "http://test.learn.odl.local:8062" process.env.NEXT_PUBLIC_EMBEDLY_KEY = "fake-embedly-key" diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx index ca79d429a2..e2595e913a 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.test.tsx @@ -3,6 +3,7 @@ import { renderWithProviders, screen, setMockResponse, + setupLocationMock, user, within, } from "@/test-utils" @@ -62,23 +63,7 @@ describe.each([ ])("DashboardCard $display", ({ testId }) => { const getCard = () => screen.getByTestId(testId) - const originalLocation = window.location - - beforeAll(() => { - Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: { ...originalLocation, assign: jest.fn() }, - }) - }) - - afterAll(() => { - Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: originalLocation, - }) - }) + setupLocationMock() test("It shows course title and links to marketingUrl if titleAction is marketing and enrolled", async () => { setupUserApis() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx index 3c3c3f229a..c59a1f3cad 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx @@ -37,7 +37,7 @@ import { UnenrollDialog, } from "./DashboardDialogs" import NiceModal from "@ebay/nice-modal-react" -import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" +import { useCreateB2bEnrollment } from "api/mitxonline-hooks/enrollment" import { mitxUserQueries } from "api/mitxonline-hooks/user" import { useQuery } from "@tanstack/react-query" import { programView } from "@/common/urls" @@ -177,7 +177,7 @@ const getDefaultContextMenuItems = ( const useOneClickEnroll = () => { const mitxOnlineUser = useQuery(mitxUserQueries.me()) - const createEnrollment = useCreateEnrollment() + const createEnrollment = useCreateB2bEnrollment() const userCountry = mitxOnlineUser.data?.legal_address?.country const userYearOfBirth = mitxOnlineUser.data?.user_profile?.year_of_birth const showJustInTimeDialog = !userCountry || !userYearOfBirth diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx index c271223cc6..d915724553 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.test.tsx @@ -3,6 +3,7 @@ import { renderWithProviders, screen, setMockResponse, + setupLocationMock, user, within, } from "@/test-utils" @@ -157,23 +158,7 @@ describe("JustInTimeDialog", () => { } } - const originalLocation = window.location - - beforeAll(() => { - Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: { ...originalLocation, assign: jest.fn() }, - }) - }) - - afterAll(() => { - Object.defineProperty(window, "location", { - configurable: true, - enumerable: true, - value: originalLocation, - }) - }) + setupLocationMock() type SetupJitOptions = { userOverrides?: PartialDeep diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx index 8a10b4aa9e..4edd6615ac 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardDialogs.tsx @@ -15,7 +15,7 @@ import { useQuery } from "@tanstack/react-query" import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react" import { useFormik } from "formik" import { - useCreateEnrollment, + useCreateB2bEnrollment, useDestroyEnrollment, useUpdateEnrollment, } from "api/mitxonline-hooks/enrollment" @@ -196,7 +196,7 @@ const JustInTimeDialogInner: React.FC<{ href: string; readableId: string }> = ({ }) => { const { data: countries } = useQuery(mitxUserQueries.countries()) const updateUser = useUpdateUserMutation() - const createEnrollment = useCreateEnrollment() + const createEnrollment = useCreateB2bEnrollment() const user = useQuery(mitxUserQueries.me()) const modal = NiceModal.useModal() diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts index daf01f035e..0b7ac92779 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/test-utils.ts @@ -290,7 +290,6 @@ function setupOrgDashboardMocks( ) // Empty defaults - setMockResponse.get(mitxonline.urls.enrollment.enrollmentsList(), []) setMockResponse.get(mitxonline.urls.programEnrollments.enrollmentsList(), []) setMockResponse.get( mitxonline.urls.programEnrollments.enrollmentsListV2(), diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx index 62591b658b..13c2055bca 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationContent.test.tsx @@ -29,7 +29,6 @@ const makeGrade = factories.enrollment.grade describe("OrganizationContent", () => { beforeEach(() => { - setMockResponse.get(urls.enrollment.enrollmentsList(), []) setMockResponse.get(urls.enrollment.enrollmentsListV2(), []) setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) setMockResponse.get(urls.programEnrollments.enrollmentsListV2(), []) @@ -145,7 +144,6 @@ describe("OrganizationContent", () => { }), ] // Override the default empty enrollments for this test - setMockResponse.get(urls.enrollment.enrollmentsList(), enrollments) setMockResponse.get(urls.enrollment.enrollmentsListV2(), enrollments) renderWithProviders() @@ -170,11 +168,11 @@ describe("OrganizationContent", () => { // Check based on the actual enrollment status, not array position if (course.enrollment?.status === EnrollmentStatus.Enrolled) { - expect(indicator).toHaveTextContent("Enrolled") + expect(indicator).toHaveTextContent(/^Enrolled$/) } else if (course.enrollment?.status === EnrollmentStatus.Completed) { - expect(indicator).toHaveTextContent("Completed") + expect(indicator).toHaveTextContent(/^Completed$/) } else { - expect(indicator).toHaveTextContent("Not Enrolled") + expect(indicator).toHaveTextContent(/^Not Enrolled$/) } }) }) @@ -754,9 +752,25 @@ describe("OrganizationContent", () => { )?.id, course: { id: courses[0].id, title: courses[0].title }, }, - grades: [], // No grades = enrolled but not completed + b2b_contract_id: contracts[0].id, + b2b_organization_id: contracts[0].organization, + certificate: { uuid: faker.string.uuid(), link: faker.internet.url() }, + }), + factories.enrollment.courseEnrollment({ + run: { + id: courses[1].courseruns.find( + (r) => r.b2b_contract === contractIds[0], + )?.id, + course: { id: courses[1].id, title: courses[1].title }, + }, + b2b_contract_id: contracts[0].id, + b2b_organization_id: contracts[0].organization, + certificate: null, + grades: [], }), ] + // Override enrollments for this test + setMockResponse.get(urls.enrollment.enrollmentsListV2(), enrollments) const program = factories.programs.program({ courses: courses.map((c) => c.id), @@ -771,24 +785,22 @@ describe("OrganizationContent", () => { contracts, ) - // Override enrollments for this test - setMockResponse.get(urls.enrollment.enrollmentsList(), enrollments) - renderWithProviders() const cards = await within( await screen.findByTestId("org-program-root"), ).findAllByTestId("enrollment-card-desktop") + expect(cards.length).toBe(3) // First card should show enrolled status - const firstCardStatus = within(cards[0]).getByTestId("enrollment-status") - expect(firstCardStatus).toHaveTextContent("Enrolled") + const cardStatus0 = within(cards[0]).getByTestId("enrollment-status") + expect(cardStatus0).toHaveTextContent(/^Completed$/) - // Remaining cards should show not enrolled - for (let i = 1; i < cards.length; i++) { - const cardStatus = within(cards[i]).getByTestId("enrollment-status") - expect(cardStatus).toHaveTextContent("Not Enrolled") - } + const cardStatus1 = within(cards[1]).getByTestId("enrollment-status") + expect(cardStatus1).toHaveTextContent(/^Enrolled$/) + + const cardStatus2 = within(cards[2]).getByTestId("enrollment-status") + expect(cardStatus2).toHaveTextContent(/^Not Enrolled$/) }) test("shows the not found screen if the organization is not found by orgSlug", async () => { @@ -987,7 +999,6 @@ describe("OrganizationContent", () => { contracts, ) - setMockResponse.get(urls.enrollment.enrollmentsList(), [enrollment]) setMockResponse.get(urls.enrollment.enrollmentsListV2(), [enrollment]) renderWithProviders() diff --git a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx index 2d8547be52..b335835edc 100644 --- a/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/OrganizationRedirect.test.tsx @@ -20,7 +20,6 @@ describe("OrganizationRedirect", () => { beforeEach(() => { mockReplace.mockClear() localStorage.clear() - setMockResponse.get(urls.enrollment.enrollmentsList(), []) setMockResponse.get(urls.programEnrollments.enrollmentsList(), []) setMockResponse.get(urls.contracts.contractsList(), []) }) diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.test.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.test.tsx new file mode 100644 index 0000000000..d810c1a43d --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.test.tsx @@ -0,0 +1,81 @@ +import React from "react" +import { + renderWithProviders, + setMockResponse, + screen, + user, +} from "@/test-utils" +import CourseEnrollmentButton from "./CourseEnrollmentButton" +import { urls, factories } from "api/test-utils" +import { factories as mitxFactories } from "api/mitxonline-test-utils" + +const makeCourse = mitxFactories.courses.course +const makeRun = mitxFactories.courses.courseRun +const makeUser = factories.user.user + +describe("CourseEnrollmentButton", () => { + const ENROLL = "Enroll for Free" + const ACCESS_MATERIALS = "Access Course Materials" + + test.each([ + { isArchived: true, expectedText: ACCESS_MATERIALS }, + { isArchived: false, expectedText: ENROLL }, + ])( + "Shows correct button text for isArchived=$isArchived", + async ({ isArchived, expectedText }) => { + const run = makeRun({ is_archived: isArchived, is_enrollable: true }) + const course = makeCourse({ next_run_id: run.id, courseruns: [run] }) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: true }), + ) + + renderWithProviders() + + const button = await screen.findByRole("button", { name: expectedText }) + expect(button).toBeInTheDocument() + }, + ) + + test("Shows signup popover for anonymous users", async () => { + const run = makeRun({ is_archived: false }) + const course = makeCourse({ + next_run_id: run.id, + courseruns: [run], + }) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + renderWithProviders() + + const enrollButton = await screen.findByRole("button", { + name: ENROLL, + }) + + await user.click(enrollButton) + + screen.getByTestId("signup-popover") + }) + + test("Returns null if no next run available", async () => { + const course = makeCourse({ + next_run_id: null, + courseruns: [], + }) + + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + const { view } = renderWithProviders( + , + ) + + expect(view.container).toBeEmptyDOMElement() + }) +}) diff --git a/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx new file mode 100644 index 0000000000..f0e3675b37 --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx @@ -0,0 +1,50 @@ +import React from "react" +import { styled } from "ol-components" +import { useQuery } from "@tanstack/react-query" +import { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" +import { Button } from "@mitodl/smoot-design" +import CourseEnrollmentDialog from "@/page-components/EnrollmentDialogs/CourseEnrollmentDialog" +import NiceModal from "@ebay/nice-modal-react" +import { userQueries } from "api/hooks/user" +import { SignupPopover } from "@/page-components/SignupPopover/SignupPopover" + +const WideButton = styled(Button)({ + width: "100%", +}) + +type CourseEnrollmentButtonProps = { + course: CourseWithCourseRunsSerializerV2 +} +const CourseEnrollmentButton: React.FC = ({ + course, +}) => { + const [anchor, setAnchor] = React.useState(null) + const me = useQuery(userQueries.me()) + const nextRunId = course.next_run_id + const nextRun = course.courseruns.find((run) => run.id === nextRunId) + + if (!nextRun) { + return null + } + + const handleClick: React.MouseEventHandler = (e) => { + if (me.isLoading) { + return + } else if (me.data?.is_authenticated) { + NiceModal.show(CourseEnrollmentDialog, { course }) + } else { + setAnchor(e.currentTarget) + } + } + + return ( + <> + + {nextRun.is_archived ? "Access Course Materials" : "Enroll for Free"} + + setAnchor(null)} /> + + ) +} + +export default CourseEnrollmentButton diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx index 59d31ebc91..172c1c3443 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.test.tsx @@ -4,7 +4,11 @@ import type { CoursePageItem, CourseWithCourseRunsSerializerV2, } from "@mitodl/mitxonline-api-axios/v2" -import { setMockResponse } from "api/test-utils" +import { + setMockResponse, + urls as learnUrls, + factories as learnFactories, +} from "api/test-utils" import { renderWithProviders, waitFor, screen, within } from "@/test-utils" import CoursePage from "./CoursePage" import { HeadingIds } from "./util" @@ -44,6 +48,11 @@ const setupApis = ({ setMockResponse.get(urls.pages.coursePages(course.readable_id), { items: [page], }) + + setMockResponse.get( + learnUrls.userMe.get(), + learnFactories.user.user({ is_authenticated: false }), + ) } describe("CoursePage", () => { diff --git a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx index aa7db0c4e7..8b926cbb2a 100644 --- a/frontends/main/src/app-pages/ProductPages/CoursePage.tsx +++ b/frontends/main/src/app-pages/ProductPages/CoursePage.tsx @@ -23,6 +23,7 @@ import ProductPageTemplate, { import { CoursePageItem } from "@mitodl/mitxonline-api-axios/v2" import { DEFAULT_RESOURCE_IMG } from "ol-utilities" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import CourseEnrollmentButton from "./CourseEnrollmentButton" type CoursePageProps = { readableId: string @@ -106,7 +107,12 @@ const CoursePage: React.FC = ({ readableId }) => { title={page.title} shortDescription={page.course_details.page.description} imageSrc={imageSrc} - sidebarSummary={} + sidebarSummary={ + } + /> + } navLinks={navLinks} > diff --git a/frontends/main/src/app-pages/ProductPages/CourseSignupButton.tsx b/frontends/main/src/app-pages/ProductPages/CourseSignupButton.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx index 63d66b77d5..a53ed4b5d7 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.test.tsx @@ -72,21 +72,20 @@ describe("CourseSummary", () => { }, ) - test.each([ - { - overrides: { is_archived: true }, - expectLabel: "Access Course Materials", - }, - { overrides: { is_archived: false }, expectLabel: "Enroll Now" }, - ])("Renders expected enrollment button", ({ overrides, expectLabel }) => { - const run = makeRun(overrides) + test("Renders enrollButton prop when provided", () => { + const run = makeRun() const course = makeCourse({ next_run_id: run.id, courseruns: shuffle([run, makeRun()]), }) - renderWithProviders() + const enrollButton = + renderWithProviders( + , + ) const summary = screen.getByRole("region", { name: "Course summary" }) - const button = within(summary).getByRole("button", { name: expectLabel }) + const button = within(summary).getByRole("button", { + name: "Test Enroll Button", + }) expect(button).toBeInTheDocument() }) }) @@ -286,7 +285,12 @@ describe("Course Price Row", () => { }) test("Offers certificate upgrade if not archived and has product", () => { - const run = makeRun({ is_archived: false, products: [makeProduct()] }) + const run = makeRun({ + is_archived: false, + products: [makeProduct()], + is_enrollable: true, + is_upgradable: true, + }) const course = makeCourse({ next_run_id: run.id, courseruns: shuffle([run, makeRun()]), diff --git a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx index a6eaf5dfca..e1616b1d20 100644 --- a/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProductSummary.tsx @@ -1,5 +1,5 @@ import React, { HTMLAttributes } from "react" -import { Alert, Button, styled, VisuallyHidden } from "@mitodl/smoot-design" +import { Alert, styled, VisuallyHidden } from "@mitodl/smoot-design" import { Dialog, Link, Skeleton, Stack, Typography } from "ol-components" import { RiCalendarLine, @@ -17,6 +17,7 @@ import { } from "@mitodl/mitxonline-api-axios/v2" import { HeadingIds, parseReqTree } from "./util" import { LearningResource } from "api" +import { getCertificatePrice } from "@/common/mitxonline" const ResponsiveLink = styled(Link)(({ theme }) => ({ [theme.breakpoints.down("sm")]: { @@ -117,16 +118,6 @@ const getEndDate = (run: CourseRunV2) => { ) } -const getCertificatePrice = (run: CourseRunV2) => { - const product = run.products[0] - if (!product || run.is_archived) return null - const amount = product.price - return Number(amount).toLocaleString("en-US", { - style: "currency", - currency: "USD", - }) -} - const getUpgradeDeadline = (run: CourseRunV2) => { if (run.is_archived) return null return run.upgrade_deadline ? ( @@ -353,10 +344,6 @@ const SidebarSummaryRoot = styled.section(({ theme }) => ({ }, })) -const WideButton = styled(Button)({ - width: "100%", -}) - enum TestIds { DatesRow = "dates-row", PaceRow = "pace-row", @@ -383,7 +370,8 @@ const ArchivedAlert: React.FC = () => { const CourseSummary: React.FC<{ course: CourseWithCourseRunsSerializerV2 -}> = ({ course }) => { + enrollButton?: React.ReactNode +}> = ({ course, enrollButton }) => { const nextRunId = course.next_run_id const nextRun = course.courseruns.find((run) => run.id === nextRunId) return ( @@ -394,15 +382,7 @@ const CourseSummary: React.FC<{ {nextRun ? ( <> - { - alert("Enroll flow not yet implemented") - }} - variant="primary" - size="large" - > - {nextRun.is_archived ? "Access Course Materials" : "Enroll Now"} - + {enrollButton} {nextRun.is_archived ? : null} = ({ const ProgramSummary: React.FC<{ program: V2Program programResource: LearningResource | null -}> = ({ program, programResource }) => { + enrollButton?: React.ReactNode +}> = ({ program, programResource, enrollButton }) => { return (

Program summary

+ {enrollButton} false) + +const makeProgram = mitxFactories.programs.program +const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV2 +const makeUser = factories.user.user + +describe("ProgramEnrollmentButton", () => { + const ENROLLED = "Enrolled" + const ENROLL = "Enroll for Free" + + beforeEach(() => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + }) + + test("Shows loading state while enrollments and user loading", async () => { + const program = makeProgram() + const enrollmentResponse = Promise.withResolvers() + const userResponse = Promise.withResolvers() + + setMockResponse.get( + mitxUrls.programEnrollments.enrollmentsListV2(), + enrollmentResponse.promise, + ) + setMockResponse.get(urls.userMe.get(), userResponse.promise) + + renderWithProviders() + + await Promise.resolve() // tick forward + screen.getByRole("progressbar", { name: "Loading" }) + expect(screen.queryByText(ENROLL)).toBeNull() + // resolve + enrollmentResponse.resolve([]) + await enrollmentResponse.promise + screen.getByRole("progressbar", { name: "Loading" }) + expect(screen.queryByText(ENROLL)).toBeNull() + + userResponse.resolve(makeUser({ is_authenticated: false })) + await screen.findByRole("button", { name: ENROLL }) + expect(screen.queryByRole("progressbar", { name: "Loading" })).toBeNull() + }) + + test("Shows 'Enrolled' button without link when feature flag is off", async () => { + const program = makeProgram() + const enrollments = [ + makeProgramEnrollment(), + makeProgramEnrollment({ program: { id: program.id } }), + makeProgramEnrollment(), + ] + const user = makeUser({ is_authenticated: true }) + + setMockResponse.get( + mitxUrls.programEnrollments.enrollmentsListV2(), + enrollments, + ) + setMockResponse.get(urls.userMe.get(), user) + + renderWithProviders() + + const enrolledButton = await screen.findByText(ENROLLED) + // When feature flag is off, button should not have href + expect(enrolledButton.closest("a")).toHaveAttribute("href", "") + }) + + test("Shows 'Enrolled' button with dashboard link when feature flag is on", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + + const program = makeProgram() + const enrollments = [ + makeProgramEnrollment(), + makeProgramEnrollment({ program: { id: program.id } }), + makeProgramEnrollment(), + ] + const user = makeUser({ is_authenticated: true }) + + setMockResponse.get( + mitxUrls.programEnrollments.enrollmentsListV2(), + enrollments, + ) + setMockResponse.get(urls.userMe.get(), user) + + renderWithProviders() + + const enrolledLink = await screen.findByRole("link", { name: ENROLLED }) + expect(enrolledLink).toHaveAttribute("href", programView(program.id)) + }) + + test("Shows 'Enroll' + enrollment dialog for unenrolled users", async () => { + const program = makeProgram() + const enrollments = [ + makeProgramEnrollment(), + makeProgramEnrollment(), + makeProgramEnrollment(), + ] + + setMockResponse.get( + mitxUrls.programEnrollments.enrollmentsListV2(), + enrollments, + ) + setMockResponse.get(urls.userMe.get(), makeUser({ is_authenticated: true })) + setMockResponse.get( + expect.stringContaining(mitxUrls.courses.coursesList()), + { count: 0, results: [] }, + ) // for the dialog + + renderWithProviders() + + const enrollButton = await screen.findByRole("button", { name: ENROLL }) + await user.click(enrollButton) + + await screen.findByRole("dialog", { name: program.title }) + }) + + test("Shows signup popover for anonymous users", async () => { + const program = makeProgram() + + setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV2(), [], { + code: 403, + }) + setMockResponse.get( + urls.userMe.get(), + makeUser({ is_authenticated: false }), + ) + + renderWithProviders() + + const enrollButton = await screen.findByRole("button", { + name: ENROLL, + }) + + await user.click(enrollButton) + + screen.getByTestId("signup-popover") + }) +}) diff --git a/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx new file mode 100644 index 0000000000..74ac785f34 --- /dev/null +++ b/frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx @@ -0,0 +1,82 @@ +import React from "react" +import { styled, LoadingSpinner } from "ol-components" +import { enrollmentQueries } from "api/mitxonline-hooks/enrollment" +import { useQuery } from "@tanstack/react-query" +import { V2Program } from "@mitodl/mitxonline-api-axios/v2" +import { RiCheckLine } from "@remixicon/react" +import { Button, ButtonLink } from "@mitodl/smoot-design" +import ProgramEnrollmentDialog from "@/page-components/EnrollmentDialogs/ProgramEnrollmentDialog" +import NiceModal from "@ebay/nice-modal-react" +import { userQueries } from "api/hooks/user" +import { SignupPopover } from "@/page-components/SignupPopover/SignupPopover" +import { programView } from "@/common/urls" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" + +const WideButton = styled(Button)({ + width: "100%", +}) + +const WideButtonLink = styled(ButtonLink)(({ href }) => [ + { + width: "100%", + }, + !href && { + pointerEvents: "none", + cursor: "default", + }, +]) + +type ProgramEnrollmentButtonProps = { + program: V2Program +} +const ProgramEnrollmentButton: React.FC = ({ + program, +}) => { + const [anchor, setAnchor] = React.useState(null) + const me = useQuery(userQueries.me()) + const enrollments = useQuery({ + ...enrollmentQueries.programEnrollmentsList(), + throwOnError: false, + }) + const programDashboardEnabled = useFeatureFlagEnabled( + FeatureFlags.EnrollmentDashboard, + ) + const enrollment = + program && enrollments.data?.find((e) => e.program.id === program.id) + + const handleClick: React.MouseEventHandler = (e) => { + if (enrollments.isLoading || me.isLoading) { + return + } else if (me.data?.is_authenticated) { + NiceModal.show(ProgramEnrollmentDialog, { program }) + } else { + setAnchor(e.currentTarget) + } + } + const isLoading = enrollments.isLoading || me.isLoading + if (enrollment) { + const href = programDashboardEnabled ? programView(program.id) : undefined + + return ( + + Enrolled + + ) + } + return ( + <> + + {isLoading ? ( + + ) : ( + "Enroll for Free" + )} + + setAnchor(null)} /> + + ) +} + +export default ProgramEnrollmentButton diff --git a/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx b/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx index f60ff40753..2a08759525 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramPage.test.tsx @@ -19,10 +19,11 @@ import { useFeatureFlagEnabled } from "posthog-js/react" import invariant from "tiny-invariant" import { ResourceTypeEnum } from "api" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" -import { RequirementTreeBuilder } from "../../../../api/src/mitxonline/test-utils/factories/requirements" import { faker } from "@faker-js/faker/locale/en" import type { ResourceCardProps } from "@/page-components/ResourceCard/ResourceCard" +const RequirementTreeBuilder = factories.requirements.RequirementTreeBuilder + jest.mock("posthog-js/react") const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) jest.mock("@/common/useFeatureFlagsLoaded") @@ -160,6 +161,8 @@ const setupApis = ({ learnUrls.userMe.get(), learnFactories.user.user({ is_authenticated: false }), ) + + setMockResponse.get(urls.programEnrollments.enrollmentsListV2(), []) } describe("ProgramPage", () => { diff --git a/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx b/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx index 43f263217e..55e7cd257a 100644 --- a/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx +++ b/frontends/main/src/app-pages/ProductPages/ProgramPage.tsx @@ -28,6 +28,7 @@ import { ResourceTypeEnum } from "api" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" import dynamic from "next/dynamic" import type { Breakpoint } from "@mui/system" +import ProgramEnrollmentButton from "./ProgramEnrollmentButton" const LearningResourceDrawer = dynamic( () => @@ -256,6 +257,7 @@ const ProgramPage: React.FC = ({ readableId }) => { resource_type: [ResourceTypeEnum.Program], }), ) + const page = pages.data?.items[0] const program = programs.data?.results?.[0] const programResource = programResources.data?.results?.[0] @@ -295,7 +297,11 @@ const ProgramPage: React.FC = ({ readableId }) => { } imageSrc={imageSrc} sidebarSummary={ - + } + program={program} + programResource={programResource} + /> } navLinks={navLinks} > diff --git a/frontends/main/src/app/getQueryClient.ts b/frontends/main/src/app/getQueryClient.ts index 25c70588d9..57016f4618 100644 --- a/frontends/main/src/app/getQueryClient.ts +++ b/frontends/main/src/app/getQueryClient.ts @@ -87,7 +87,16 @@ const getServerQueryClient = cache(() => { return queryClient }) -const makeBrowserQueryClient = (): QueryClient => { +type BrowserClientConfig = { + maxRetries: number +} +const DEFAULT_BROWSER_CLIENT_CONFIG: BrowserClientConfig = { + maxRetries: MAX_RETRIES, +} +const makeBrowserQueryClient = ( + config: BrowserClientConfig = DEFAULT_BROWSER_CLIENT_CONFIG, +): QueryClient => { + const { maxRetries } = config return new QueryClient({ defaultOptions: { queries: { @@ -120,7 +129,7 @@ const makeBrowserQueryClient = (): QueryClient => { * Includes statuses undefined and 0 as we want to retry on network errors. */ if (isNetworkError || !NO_RETRY_CODES.includes(status)) { - return failureCount < MAX_RETRIES + return failureCount < maxRetries } return false }, diff --git a/frontends/main/src/common/mitxonline/index.ts b/frontends/main/src/common/mitxonline/index.ts new file mode 100644 index 0000000000..190c195fdd --- /dev/null +++ b/frontends/main/src/common/mitxonline/index.ts @@ -0,0 +1,41 @@ +import { + CourseRunV2, + ProductFlexibilePrice, +} from "@mitodl/mitxonline-api-axios/v2" + +const upgradeRunUrl = (product: ProductFlexibilePrice): string => { + try { + const url = new URL( + "/cart/add", + process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL, + ) + url.searchParams.append("product_id", String(product.id)) + return url.toString() + } catch (err) { + console.error("Error constructing upgrade URL:", err) + return "" + } +} + +const canUpgrade = (run: CourseRunV2): boolean => { + // Prefer to handle this on backend + // See https://github.com/mitodl/hq/issues/9450 + return ( + run.is_enrollable && + !run.is_archived && + run.is_upgradable && + Boolean(run.products?.length) + ) +} + +const getCertificatePrice = (run: CourseRunV2) => { + if (!canUpgrade(run)) return null + const product = run.products[0] + const amount = product.price + return Number(amount).toLocaleString("en-US", { + style: "currency", + currency: "USD", + }) +} + +export { getCertificatePrice, canUpgrade, upgradeRunUrl } diff --git a/frontends/main/src/common/urls.test.ts b/frontends/main/src/common/urls.test.ts index 5e1b514f77..37cb543bd1 100644 --- a/frontends/main/src/common/urls.test.ts +++ b/frontends/main/src/common/urls.test.ts @@ -6,7 +6,7 @@ test.each([ { loginNext: { pathname: "/", searchParams: null }, expected: [ - "http://api.test.learn.odl.local:8063/login", + "http://api.test.learn.odl.local:8065/login", "?next=http%3A%2F%2Ftest.learn.odl.local%3A8062%2F", ].join(""), }, @@ -16,7 +16,7 @@ test.each([ searchParams: null, }, expected: [ - "http://api.test.learn.odl.local:8063/login", + "http://api.test.learn.odl.local:8065/login", "?next=http%3A%2F%2Ftest.learn.odl.local%3A8062%2Fcourses%2Fcourse-v1%3AedX%2BDemoX%2BDemo_Course", ].join(""), }, diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx new file mode 100644 index 0000000000..693ae53c44 --- /dev/null +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.test.tsx @@ -0,0 +1,391 @@ +import React from "react" +import { act } from "@testing-library/react" +import { + screen, + waitFor, + renderWithProviders, + user, + setupLocationMock, +} from "@/test-utils" +import { makeRequest, setMockResponse } from "api/test-utils" +import { + urls as mitxUrls, + factories as mitxFactories, +} from "api/mitxonline-test-utils" +import type { CourseWithCourseRunsSerializerV2 } from "@mitodl/mitxonline-api-axios/v2" +import NiceModal from "@ebay/nice-modal-react" +import CourseEnrollmentDialog from "./CourseEnrollmentDialog" +import { upgradeRunUrl } from "@/common/mitxonline" +import { faker } from "@faker-js/faker/locale/en" +import invariant from "tiny-invariant" +import { DASHBOARD_HOME } from "@/common/urls" + +const makeCourseRun = mitxFactories.courses.courseRun +const makeProduct = mitxFactories.courses.product +const makeCourse = mitxFactories.courses.course + +const enrollableRun: typeof makeCourseRun = (overrides) => + makeCourseRun({ + is_enrollable: true, + enrollment_start: faker.date.past().toISOString(), + enrollment_end: faker.date.future().toISOString(), + ...overrides, + }) + +const upgradeableRun: typeof makeCourseRun = (overrides) => + makeCourseRun({ + is_upgradable: true, + is_enrollable: true, + is_archived: false, + products: [mitxFactories.courses.product()], + ...overrides, + }) + +describe("CourseEnrollmentDialog", () => { + const openDialog = async (course: CourseWithCourseRunsSerializerV2) => { + await act(async () => { + NiceModal.show(CourseEnrollmentDialog, { course }) + }) + return await screen.findByRole("dialog") + } + + setupLocationMock() + + describe("Course run dropdown", () => { + test("Shows one entry for each enrollable course run", async () => { + const run1 = enrollableRun() + const run2 = enrollableRun() + const run3 = enrollableRun() + const course = makeCourse({ courseruns: [run1, run2, run3] }) + + renderWithProviders(null) + await openDialog(course) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + const options = await screen.findAllByRole("option") + // Should have 4 options: 1 "Please Select" + 3 course runs + expect(options).toHaveLength(4) + + // Verify the actual run options (excluding "Please Select") + const runOptions = options.slice(1) + expect(runOptions).toHaveLength(3) + }) + + test("Does NOT include non-enrollable course runs in dropdown", async () => { + const run1 = enrollableRun() + const nonEnrollableRun = makeCourseRun({ is_enrollable: false }) + const archivedRun = makeCourseRun({ + is_archived: true, + is_enrollable: true, + }) + const course = makeCourse({ + courseruns: [run1, nonEnrollableRun, archivedRun], + }) + + renderWithProviders(
) + await openDialog(course) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + const options = await screen.findAllByRole("option") + expect(options).toHaveLength(3) // 1 "Please Select" + 2 enrollable runs + }) + + test("Course run label indicates whether certificate upgrade is available", async () => { + const run1 = upgradeableRun({ + start_date: "2024-01-01T00:00:00Z", + end_date: "2024-06-01T00:00:00Z", + }) + const nonUpgradableRun = enrollableRun({ + start_date: "2024-07-01T00:00:00Z", + end_date: "2024-12-01T00:00:00Z", + is_upgradable: false, + }) + const course = makeCourse({ courseruns: [run1, nonUpgradableRun] }) + + renderWithProviders(
) + await openDialog(course) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + // Verify upgradable run does NOT have "(No certificate available)" text + const options = await screen.findAllByRole("option") + expect(options.map((opt) => opt.textContent)).toEqual([ + "Please Select", + "Jan 1, 2024 - Jun 1, 2024", + "Jul 1, 2024 - Dec 1, 2024 (No certificate available)", + ]) + }) + }) + + describe("Certificate upgrade display", () => { + test("When upgradeable run is chosen, upgrade button is enabled with price and deadline", async () => { + const run = upgradeableRun({ + upgrade_deadline: "2024-02-15T00:00:00Z", + products: [makeProduct({ price: "149.00" })], + }) + const course = makeCourse({ courseruns: [run] }) + + renderWithProviders(
) + await openDialog(course) + + // The single run should be auto-selected + const certificatePrice = await screen.findByText(/Get Certificate: \$149/) + expect(certificatePrice).toBeInTheDocument() + + // Check for deadline text (date format may vary) + const deadline = screen.getByText(/Payment due:/) + expect(deadline).toBeInTheDocument() + + const upgradeButton = screen.getByRole("button", { + name: /Add to Cart.*to get a Certificate/i, + }) + expect(upgradeButton).toBeEnabled() + }) + + test("When non-upgradeable run is chosen, upgrade display is disabled with appropriate text", async () => { + const nonUpgradableRun = enrollableRun({ + is_upgradable: false, + products: [], + }) + const run2 = upgradeableRun() + const course = makeCourse({ courseruns: [nonUpgradableRun, run2] }) + + renderWithProviders(
) + await openDialog(course) + + // Select the non-upgradable run + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + const nonUpgradableOption = screen.getByRole("option", { + name: /No certificate available/i, + }) + await user.click(nonUpgradableOption) + + // Should show "Not available" text + const notAvailable = await screen.findByText("Not available") + expect(notAvailable).toBeInTheDocument() + + // Upgrade button should be disabled + const upgradeButton = screen.getByRole("button", { + name: /Add to Cart.*to get a Certificate/i, + }) + expect(upgradeButton).toBeDisabled() + }) + }) + + describe("Initial run selection", () => { + test("If exactly 1 enrollable run exists, it is initially chosen", async () => { + const singleRun = enrollableRun({ + start_date: "2024-01-01T00:00:00Z", + end_date: "2024-06-01T00:00:00Z", + }) + const course = makeCourse({ courseruns: [singleRun] }) + + renderWithProviders(
) + await openDialog(course) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + expect(select).toHaveTextContent(/Jan 1, 2024 - Jun 1, 2024/i) + + // The enroll button should be enabled + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + expect(enrollButton).toBeEnabled() + }) + + test("If multiple enrollable runs exist, none is initially chosen", async () => { + const run1 = enrollableRun() + const run2 = enrollableRun() + const course = makeCourse({ courseruns: [run1, run2] }) + + renderWithProviders(
) + await openDialog(course) + + // The select should show "Please Select" (check the hidden input) + const select = screen.getByRole("combobox", { name: /choose a date/i }) + expect(select).toHaveTextContent(/please select/i) + + // The enroll button should be disabled + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + expect(enrollButton).toBeDisabled() + }) + + test("Initial selection is reset when dialog is reopened", async () => { + const run1 = enrollableRun() + const run2 = enrollableRun() + const course = makeCourse({ courseruns: [run1, run2] }) + + renderWithProviders(
) + + // First open: no initial selection with multiple runs + await openDialog(course) + const select = screen.getByRole("combobox", { name: /choose a date/i }) + expect(select).toHaveTextContent(/please select/i) + + // Select an option + await user.click(select) + const options = screen.getAllByRole("option") + await user.click(options[1]) // Select first run (index 0 is "Please Select") + + // Close the dialog + const closeButton = screen.getByRole("button", { name: /close/i }) + await user.click(closeButton) + await waitFor(() => { + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + // Reopen the dialog + await openDialog(course) + + // Verify selection is reset to "Please Select" + const reopenedSelect = screen.getByRole("combobox", { + name: /choose a date/i, + }) + expect(reopenedSelect).toHaveTextContent(/please select/i) + }) + }) + + describe("Enrollment and upgrade actions", () => { + test("Clicking enrollment button submits enrollment form", async () => { + const run = enrollableRun() + const course = makeCourse({ courseruns: [run] }) + + renderWithProviders(
) + await openDialog(course) + + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + + expect(enrollButton).toBeEnabled() + + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + await user.click(enrollButton) + + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + "post", + mitxUrls.enrollment.enrollmentsListV1(), + { run_id: run.id }, + ) + }) + }) + + test("Clicking upgrade button redirects to MITxOnline cart with correct URL", async () => { + const assign = jest.mocked(window.location.assign) + + const run = upgradeableRun() + const product = run.products[0] + invariant(product, "Upgradeable run must have a product") + const course = mitxFactories.courses.course({ courseruns: [run] }) + + renderWithProviders(
) + await openDialog(course) + + const upgradeButton = screen.getByRole("button", { + name: /Add to Cart.*to get a Certificate/i, + }) + + // Verify the button is enabled before clicking + expect(upgradeButton).toBeEnabled() + + await user.click(upgradeButton) + + // Verify redirect URL includes product_id parameter + await waitFor(() => { + expect(assign).toHaveBeenCalledWith(upgradeRunUrl(product)) + }) + }) + + test("Default behavior: redirects to dashboard home after successful enrollment", async () => { + const run = enrollableRun() + const course = makeCourse({ courseruns: [run] }) + + const { location } = renderWithProviders(
) + await openDialog(course) + + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + await user.click(enrollButton) + + await waitFor(() => { + expect(location.current.pathname).toBe(DASHBOARD_HOME) + }) + + // Verify dialog has closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Custom onCourseEnroll: calls callback instead of redirecting", async () => { + const run = enrollableRun() + const course = makeCourse({ courseruns: [run] }) + const onCourseEnroll = jest.fn() + + const { location } = renderWithProviders(
) + await act(async () => { + NiceModal.show(CourseEnrollmentDialog, { course, onCourseEnroll }) + }) + await screen.findByRole("dialog") + + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + await user.click(enrollButton) + + await waitFor(() => { + expect(onCourseEnroll).toHaveBeenCalledWith(run) + }) + + // Should NOT redirect to dashboard + expect(location.current.pathname).not.toBe(DASHBOARD_HOME) + + // Verify dialog has closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Shows error message when enrollment fails", async () => { + const run = enrollableRun() + const course = makeCourse({ courseruns: [run] }) + + renderWithProviders(
) + await openDialog(course) + + const enrollButton = screen.getByRole("button", { + name: /Enroll for Free without a certificate/i, + }) + + // Mock enrollment failure + setMockResponse.post( + mitxUrls.enrollment.enrollmentsListV1(), + "Enrollment failed", + { code: 500 }, + ) + + // Click the button - the error will be caught by the mutation + await user.click(enrollButton) + + // Check for error alert - the mutation error should be displayed + await waitFor(() => { + expect( + screen.getByText( + /There was a problem enrolling you in this course. Please try again later./i, + ), + ).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx new file mode 100644 index 0000000000..8dd5a6233c --- /dev/null +++ b/frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx @@ -0,0 +1,319 @@ +import React from "react" +import { + FormDialog, + SimpleSelectOption, + SimpleSelectField, + styled, + Stack, + Typography, + PlainList, +} from "ol-components" +import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react" +import { + CourseRunV2, + CourseWithCourseRunsSerializerV2, +} from "@mitodl/mitxonline-api-axios/v2" +import { formatDate, LocalDate } from "ol-utilities" +import { RiCheckLine, RiArrowRightLine, RiAwardFill } from "@remixicon/react" +import { Alert, Button, ButtonProps } from "@mitodl/smoot-design" +import { + canUpgrade, + getCertificatePrice, + upgradeRunUrl, +} from "@/common/mitxonline" +import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" +import { useRouter } from "next-nprogress-bar" +import { DASHBOARD_HOME } from "@/common/urls" + +interface CourseEnrollmentDialogProps { + course: CourseWithCourseRunsSerializerV2 + /** + * Called after a course enrollment is successfully created + * By default, redirects to dashboard home. + */ + onCourseEnroll?: (run: CourseRunV2) => void +} + +const StyledSimpleSelectField = styled(SimpleSelectField)(({ theme }) => ({ + "&&& label": { + ...theme.typography.subtitle1, + marginBottom: "8px", + }, +})) as typeof SimpleSelectField + +const StyledFormDialog = styled(FormDialog)({ + ".MuiPaper-root": { + maxWidth: "702px", + }, +}) + +type BigButtonProps = { + label: string + sublabel: string + endIcon: React.ReactNode +} & Omit +const BigButton = styled( + ({ label, sublabel, endIcon, ...others }: BigButtonProps) => { + return ( + + ) + }, +)(({ theme }) => ({ + // mostly inheriting colors, hover, etc from regular button. + padding: "16px 32px", + boxShadow: "none", + display: "inline-flex", + alignItems: "center", + gap: "32px", + textAlign: "left", + justifyContent: "flex-start", + svg: { + width: "24px", + height: "24px", + }, + ...theme.typography.h5, + [theme.breakpoints.down("sm")]: { + ...theme.typography.subtitle2, + padding: "12px 20px", + gap: "16px", + justifyContent: "space-between", + }, + ".label": { + fontWeight: theme.typography.fontWeightBold, + }, +})) + +const CertificateBox = styled.div<{ disabled?: boolean }>( + ({ theme, disabled }) => [ + { + padding: "16px", + display: "flex", + width: "100%", + justifyContent: "space-between", + alignItems: "center", + background: "rgba(3, 21, 45, 0.05)", + borderRadius: "4px", + border: "1px solid #DFE5EC", + gap: "24px", + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + alignItems: "stretch", + gap: "12px", + }, + }, + disabled && { + background: "rgba(3, 21, 45, 0.025)", + color: theme.custom.colors.silverGrayDark, + }, + ], +) +const CertDate = styled.span<{ disabled?: boolean }>(({ theme, disabled }) => [ + { ...theme.typography.body1 }, + !disabled && { color: theme.custom.colors.red }, +]) + +const CertificatePriceRoot = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "flex-start", + justifyContent: "flex-start", + gap: "12px", + svg: { + width: "40px", + height: "40px", + }, + ...theme.typography.h5, + [theme.breakpoints.down("sm")]: { + svg: { + width: "32px", + height: "32px", + }, + ...theme.typography.subtitle1, + }, +})) + +const CertificateReasonsList = styled(PlainList)(({ theme }) => ({ + display: "grid", + rowGap: "24px", + columnGap: "40px", + gridTemplateColumns: "1fr 1fr", + [theme.breakpoints.down("sm")]: { + gridTemplateColumns: "1fr", + }, +})) +const CertificateReasonItem = styled.li(({ theme }) => ({ + display: "flex", + alignItems: "flex-start", + gap: "8px", + ...theme.typography.body2, + svg: { + width: "20px", + height: "20px", + color: theme.custom.colors.green, + }, + "> *": { flexShrink: 0 }, +})) +const CERT_REASONS = [ + "Certificate is signed by MIT faculty", + "Highlight on your resume/CV", + "Demonstrates knowledge and skills taught in this course", + "Share on your social channels & LinkedIn", + "Enhance your college & earn a promotion", + "Enhance your college application with an earned certificate from MIT", +] +const CertificateUpsell: React.FC<{ + courseRun?: CourseRunV2 +}> = ({ courseRun }) => { + const enabled = courseRun ? canUpgrade(courseRun) : false + const product = courseRun?.products[0] + const price = courseRun && enabled ? getCertificatePrice(courseRun) : null + const deadlineUI = courseRun?.upgrade_deadline ? ( + <> + Payment due: + + ) : null + return ( + + + Would you like to get a certificate for this course? + + + {CERT_REASONS.map((reason, index) => ( + // reasons are static, index key is OK + + + ))} + + + + + + Get Certificate{price ? `: ${price}` : ""} + + {enabled ? deadlineUI : "Not available"} + + + + + + ) +} + +const getRunOptions = ( + course: CourseWithCourseRunsSerializerV2, +): SimpleSelectOption[] => { + return course.courseruns + .filter((run) => run.is_enrollable) + .map((run) => { + const dates = [run.start_date, run.end_date] + .filter((d) => typeof d === "string") + .map((d) => formatDate(d)) + .join(" - ") + return { + label: canUpgrade(run) ? dates : `${dates} (No certificate available)`, + value: `${run.id}`, + } + }) +} + +const RUN_DEFAULT_OPTION: SimpleSelectOption = { + label: "Please Select", + value: "", + disabled: true, +} + +const CourseEnrollmentDialogInner: React.FC = ({ + course, + onCourseEnroll, +}) => { + const modal = NiceModal.useModal() + const runOptions = getRunOptions(course) + const options: SimpleSelectOption[] = [RUN_DEFAULT_OPTION, ...runOptions] + const getDefaultOption = () => { + // if multiple options, force a choice + return runOptions.length === 1 ? runOptions[0].value : "" + } + const [chosenRun, setChosenRun] = React.useState(getDefaultOption) + const run = course.courseruns.find((r) => `${r.id}` === chosenRun) + const createEnrollment = useCreateEnrollment() + const router = useRouter() + return ( + { + e.preventDefault() + if (!run) return + createEnrollment.mutate( + { + run_id: run.id, + }, + { + onSuccess: () => { + if (onCourseEnroll) { + onCourseEnroll(run) + } else { + router.push(DASHBOARD_HOME) + } + modal.hide() + }, + }, + ) + }} + onReset={() => setChosenRun(getDefaultOption())} + maxWidth={false} + disabled={!run} + > + ({ + color: theme.custom.colors.darkGray2, + })} + gap="24px" + > + setChosenRun(e.target.value)} + fullWidth + /> + + {createEnrollment.isError && ( +
el?.scrollIntoView()}> + + There was a problem enrolling you in this course. Please try again + later. + +
+ )} +
+
+ ) +} + +const CourseEnrollmentDialog = NiceModal.create(CourseEnrollmentDialogInner) + +export default CourseEnrollmentDialog + +export { StyledSimpleSelectField, StyledFormDialog, CertificateUpsell } diff --git a/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx new file mode 100644 index 0000000000..49aed874ae --- /dev/null +++ b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.test.tsx @@ -0,0 +1,530 @@ +import { act } from "@testing-library/react" +import { + screen, + waitFor, + renderWithProviders, + user, + setupLocationMock, +} from "@/test-utils" +import { makeRequest, setMockResponse } from "api/test-utils" +import { + urls as mitxUrls, + factories as mitxFactories, +} from "api/mitxonline-test-utils" +import type { + CourseWithCourseRunsSerializerV2, + V2Program, +} from "@mitodl/mitxonline-api-axios/v2" +import NiceModal from "@ebay/nice-modal-react" +import ProgramEnrollmentDialog from "./ProgramEnrollmentDialog" +import { upgradeRunUrl } from "@/common/mitxonline" +import { faker } from "@faker-js/faker/locale/en" +import invariant from "tiny-invariant" +import { DASHBOARD_HOME } from "@/common/urls" + +const makeCourseRun = mitxFactories.courses.courseRun + +const makeCourse = mitxFactories.courses.course + +const enrollableRun: typeof makeCourseRun = (overrides) => + makeCourseRun({ + is_enrollable: true, + enrollment_start: faker.date.past().toISOString(), + enrollment_end: faker.date.future().toISOString(), + ...overrides, + }) + +const upgradeableRun: typeof makeCourseRun = (overrides) => + makeCourseRun({ + is_upgradable: true, + is_enrollable: true, + is_archived: false, + products: [mitxFactories.courses.product()], + ...overrides, + }) + +describe("ProgramEnrollmentDialog", () => { + setupLocationMock() + + const makeProgram = mitxFactories.programs.program + + const COURSE_PAGE_SIZE = 100 + const setupCourseApis = (courses: CourseWithCourseRunsSerializerV2[]) => { + setMockResponse.get( + mitxUrls.courses.coursesList({ + id: courses.map((course) => course.id), + page_size: COURSE_PAGE_SIZE, + }), + { + results: courses, + count: courses.length, + next: null, + previous: null, + }, + ) + } + + const openProgramDialog = async (program: V2Program) => { + await act(async () => { + NiceModal.show(ProgramEnrollmentDialog, { program }) + }) + return await screen.findByRole("dialog") + } + + describe("Dialog title and basic display", () => { + test("Dialog opens with program title", async () => { + const program = makeProgram({ title: "Test Program Title" }) + setupCourseApis([]) + + renderWithProviders(null) + await openProgramDialog(program) + + expect(screen.getByRole("dialog")).toBeInTheDocument() + expect(screen.getByText("Test Program Title")).toBeInTheDocument() + }) + }) + + describe("Course dropdown states and options", () => { + test("Dropdown shows 'Please Select' initially when courses load", async () => { + const course1 = makeCourse({ courseruns: [enrollableRun()] }) + const program = makeProgram({ courses: [course1.id] }) + setupCourseApis([course1]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + expect(select).toHaveTextContent(/please select/i) + }) + + test("Dropdown shows loading state while courses load", async () => { + const program = makeProgram() + const courseResponse = Promise.withResolvers() + setMockResponse.get( + expect.stringContaining(mitxUrls.courses.coursesList()), + courseResponse.promise, + ) + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + expect( + screen.getByRole("option", { name: /loading courses/i }), + ).toBeInTheDocument() + }) + + test("Dropdown shows error state when courses fail to load", async () => { + const program = makeProgram() + setMockResponse.get( + expect.stringContaining(mitxUrls.courses.coursesList()), + "Failed to load courses", + { code: 500 }, + ) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await waitFor(() => { + expect(select).toHaveAttribute("aria-invalid", "true") + }) + }) + + test("Dropdown shows course options once loaded", async () => { + const run1 = enrollableRun({ course_number: "6.001" }) + const run2 = enrollableRun({ course_number: "6.002" }) + const course1 = makeCourse({ + courseruns: [run1], + title: "Introduction to CS", + next_run_id: run1.id, + }) + const course2 = makeCourse({ + courseruns: [run2], + title: "Advanced CS", + next_run_id: run2.id, + }) + const program = makeProgram({ courses: [course1.id, course2.id] }) + setupCourseApis([course1, course2]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + const options = screen.getAllByRole("option") + // Should have 3 options: 1 "Please Select" + 2 courses + expect(options).toHaveLength(3) + }) + + test("Course options include course title suffixed by the chosen run id", async () => { + const run = enrollableRun({ course_number: "6.001x" }) + const course = makeCourse({ + courseruns: [run], + title: "Introduction to Computer Science", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + expect( + screen.getByRole("option", { + name: /Introduction to Computer Science - 6.001x/i, + }), + ).toBeInTheDocument() + }) + + test("If no run is available, '(No available runs)' shows as suffix", async () => { + const course = makeCourse({ + courseruns: [], + title: "Course Without Runs", + next_run_id: null, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + screen.getByRole("option", { + name: "Course Without Runs - (No available runs)", + }) + }) + + test("If the chosen run is not upgradeable, '(No certificate available)' shows as suffix", async () => { + const nonUpgradableRun = enrollableRun({ + course_number: "6.003", + is_upgradable: false, + }) + const course = makeCourse({ + courseruns: [nonUpgradableRun], + title: "Non-Upgradable Course", + next_run_id: nonUpgradableRun.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + expect( + screen.getByRole("option", { + name: /Non-Upgradable Course - 6.003 \(No certificate available\)/i, + }), + ).toBeInTheDocument() + }) + + test("Upgradeable runs do NOT show '(No certificate available)' suffix", async () => { + const upgradableRun = upgradeableRun({ course_number: "6.004" }) + const course = makeCourse({ + courseruns: [upgradableRun], + title: "Upgradable Course", + next_run_id: upgradableRun.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + + const option = screen.getByRole("option", { + name: /Upgradable Course - 6.004$/i, + }) + expect(option).toBeInTheDocument() + expect(option.textContent).not.toContain("No certificate available") + }) + }) + + describe("Certificate upgrade and enrollment actions", () => { + test("Clicking 'Add to cart' redirects to cart page appropriately", async () => { + const assign = jest.mocked(window.location.assign) + + const run = upgradeableRun({ course_number: "6.005" }) + const product = run.products[0] + invariant(product, "Upgradeable run must have a product") + const course = makeCourse({ + courseruns: [run], + title: "Test Course", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + + setMockResponse.get( + expect.stringContaining(mitxUrls.courses.coursesList()), + { + results: [course], + count: 1, + next: null, + previous: null, + }, + ) + + renderWithProviders(null) + await openProgramDialog(program) + + // Select the course + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /Test Course - 6.005/i, + }) + await user.click(courseOption) + + // Wait for certificate upgrade section to appear + await screen.findByText(/Get Certificate/) + + // Click "Add to Cart" button + const upgradeButton = screen.getByRole("button", { + name: /Add to Cart.*to get a Certificate/i, + }) + await user.click(upgradeButton) + + // Verify redirect URL includes product_id parameter + await waitFor(() => { + expect(assign).toHaveBeenCalledWith(upgradeRunUrl(product)) + }) + }) + + test("Clicking 'No thanks, I'll take the course...' button enrolls in the course", async () => { + const run = enrollableRun({ course_number: "6.006" }) + const course = makeCourse({ + courseruns: [run], + title: "Algorithms", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + // Select the course + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /Algorithms - 6.006/i, + }) + await user.click(courseOption) + + // Wait for enrollment button to be enabled + const enrollButton = await screen.findByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + expect(enrollButton).toBeEnabled() + + // Mock the enrollment API call + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + + // Click the enrollment button + await user.click(enrollButton) + + // Verify the enrollment request was made + await waitFor(() => { + expect(makeRequest).toHaveBeenCalledWith( + "post", + mitxUrls.enrollment.enrollmentsListV1(), + { run_id: run.id }, + ) + }) + }) + + test("Enrollment button is disabled when no course is selected", async () => { + const run = enrollableRun() + const course = makeCourse({ + courseruns: [run], + title: "Test Course", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + // Don't select any course, just check button state + const enrollButton = screen.getByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + expect(enrollButton).toBeDisabled() + }) + + test("Enrollment button is disabled when course with no runs is selected", async () => { + const course = makeCourse({ + courseruns: [], + title: "Course Without Runs", + next_run_id: null, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + // Try to select the course without runs + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /No available runs/i, + }) + await user.click(courseOption) + + // Enrollment button should be disabled + const enrollButton = screen.getByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + expect(enrollButton).toBeDisabled() + }) + + test("Default behavior: redirects to dashboard home after successful enrollment", async () => { + const run = enrollableRun({ course_number: "6.007" }) + const course = makeCourse({ + courseruns: [run], + title: "Test Course", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + const { location } = renderWithProviders(null) + await openProgramDialog(program) + + // Select the course + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /Test Course - 6.007/i, + }) + await user.click(courseOption) + + // Wait for enrollment button to be enabled + const enrollButton = await screen.findByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + + // Mock the enrollment API call + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + + // Click the enrollment button + await user.click(enrollButton) + + // Verify redirect to dashboard home + await waitFor(() => { + expect(location.current.pathname).toBe(DASHBOARD_HOME) + }) + + // Verify dialog has closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Custom onCourseEnroll: calls callback instead of redirecting", async () => { + const run = enrollableRun({ course_number: "6.008" }) + const course = makeCourse({ + courseruns: [run], + title: "Another Course", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + const onCourseEnroll = jest.fn() + + const { location } = renderWithProviders(null) + await act(async () => { + NiceModal.show(ProgramEnrollmentDialog, { program, onCourseEnroll }) + }) + await screen.findByRole("dialog") + + // Select the course + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /Another Course - 6.008/i, + }) + await user.click(courseOption) + + // Wait for enrollment button to be enabled + const enrollButton = await screen.findByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + + // Mock the enrollment API call + setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {}) + + // Click the enrollment button + await user.click(enrollButton) + + // Verify callback was called with the run + await waitFor(() => { + expect(onCourseEnroll).toHaveBeenCalledWith(run) + }) + + // Should NOT redirect to dashboard + expect(location.current.pathname).not.toBe(DASHBOARD_HOME) + + // Verify dialog has closed + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + }) + + test("Shows error message when enrollment fails", async () => { + const run = enrollableRun({ course_number: "6.009" }) + const course = makeCourse({ + courseruns: [run], + title: "Error Test Course", + next_run_id: run.id, + }) + const program = makeProgram({ courses: [course.id] }) + setupCourseApis([course]) + + renderWithProviders(null) + await openProgramDialog(program) + + // Select the course + const select = screen.getByRole("combobox", { name: /choose a date/i }) + await user.click(select) + const courseOption = screen.getByRole("option", { + name: /Error Test Course - 6.009/i, + }) + await user.click(courseOption) + + // Wait for enrollment button to be enabled + const enrollButton = await screen.findByRole("button", { + name: /No thanks, I'll take the course for free without a certificate/i, + }) + + // Mock enrollment failure + setMockResponse.post( + mitxUrls.enrollment.enrollmentsListV1(), + "Enrollment failed", + { code: 500 }, + ) + + // Click the enrollment button - the error will be caught by the mutation + await user.click(enrollButton) + + // Check for error alert - the mutation error should be displayed + await waitFor(() => { + expect( + screen.getByText( + /There was a problem enrolling you in this course. Please try again later./i, + ), + ).toBeInTheDocument() + }) + }) + }) +}) diff --git a/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.tsx b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.tsx new file mode 100644 index 0000000000..1fdb689eea --- /dev/null +++ b/frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.tsx @@ -0,0 +1,160 @@ +import React, { useState } from "react" +import { SimpleSelectOption, Stack, Typography } from "ol-components" +import NiceModal, { muiDialogV5 } from "@ebay/nice-modal-react" +import { + CourseRunV2, + CourseWithCourseRunsSerializerV2, + PaginatedCourseWithCourseRunsSerializerV2List, + V2Program, +} from "@mitodl/mitxonline-api-axios/v2" +import { canUpgrade } from "@/common/mitxonline" +import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment" +import { coursesQueries } from "api/mitxonline-hooks/courses" +import { useQuery } from "@tanstack/react-query" +import { useRouter } from "next-nprogress-bar" +import { DASHBOARD_HOME } from "@/common/urls" +import { + CertificateUpsell, + StyledFormDialog, + StyledSimpleSelectField, +} from "./CourseEnrollmentDialog" +import { Alert } from "@mitodl/smoot-design" + +interface ProgramEnrollmentDialogProps { + program: V2Program + /** + * Called after a course enrollment is successfully created + * By default, redirects to dashboard home. + */ + onCourseEnroll?: (run: CourseRunV2) => void +} + +const COURSES_PAGE_SIZE = 100 +const getNextRun = (course?: CourseWithCourseRunsSerializerV2) => { + return course?.courseruns.find((run) => run.id === course.next_run_id) +} +const getCourseOptions = ({ + data, + isLoading, +}: { + data?: PaginatedCourseWithCourseRunsSerializerV2List + isLoading: boolean +}): SimpleSelectOption[] => { + const opts: SimpleSelectOption[] = + data?.results.map((course) => { + const run = getNextRun(course) + const upgradeCaveat = + run && !canUpgrade(run) ? " (No certificate available)" : "" + const label = run + ? `${course.title} - ${run.course_number}${upgradeCaveat}` + : `${course.title} - (No available runs)` + return { + label: label, + value: `${course.id}`, + } + }) ?? [] + if (isLoading) { + return [ + { + label: "Loading courses...", + value: "-", + disabled: true, + }, + ] + } + return opts +} +const COURSE_DEFAULT_OPTION: SimpleSelectOption = { + label: "Please Select", + value: "", + disabled: true, +} + +const ProgramEnrollmentDialogInner: React.FC = ({ + program, + onCourseEnroll, +}) => { + const modal = NiceModal.useModal() + const courses = useQuery( + coursesQueries.coursesList({ + id: program.courses, + page_size: COURSES_PAGE_SIZE, // in practice, these are like 3-5 courses + }), + ) + const createEnrollment = useCreateEnrollment() + const [chosenCourseId, setChosenCourseId] = useState("") + const options = [COURSE_DEFAULT_OPTION, ...getCourseOptions(courses)] + const chosenCourse = courses.data?.results.find( + (course) => `${course.id}` === chosenCourseId, + ) + const run = getNextRun(chosenCourse) + const router = useRouter() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!run) return + createEnrollment.mutate( + { + run_id: run.id, + }, + { + onSuccess: () => { + if (onCourseEnroll) { + onCourseEnroll(run) + } else { + router.push(DASHBOARD_HOME) + } + modal.hide() + }, + }, + ) + } + + return ( + setChosenCourseId("")} + fullWidth + confirmText="No thanks, I'll take the course for free without a certificate" + disabled={!run} + > + ({ + color: theme.custom.colors.darkGray2, + })} + gap="24px" + > + + Thank you for choosing an MITx online program. To complete your + enrollment in this program, you must choose a course to start with. + You can enroll now for free, but you will need to pay for a + certificate in order to earn the program credential. + + setChosenCourseId(e.target.value)} + error={courses.isError} + errorText={courses.isError ? "Error loading courses" : undefined} + fullWidth + /> + + {createEnrollment.isError && ( +
el?.scrollIntoView()}> + + There was a problem enrolling you in this course. Please try again + later. + +
+ )} +
+
+ ) +} + +const ProgramEnrollmentDialog = NiceModal.create(ProgramEnrollmentDialogInner) + +export default ProgramEnrollmentDialog diff --git a/frontends/main/src/page-components/ReloadOnUserChange/ReloadOnUserChange.test.tsx b/frontends/main/src/page-components/ReloadOnUserChange/ReloadOnUserChange.test.tsx index e5d20be1c5..07fc053a2c 100644 --- a/frontends/main/src/page-components/ReloadOnUserChange/ReloadOnUserChange.test.tsx +++ b/frontends/main/src/page-components/ReloadOnUserChange/ReloadOnUserChange.test.tsx @@ -1,25 +1,13 @@ import React from "react" import { ReloadOnUserChange } from "./ReloadOnUserChange" -import { renderWithProviders, waitFor } from "@/test-utils" +import { renderWithProviders, setupLocationMock, waitFor } from "@/test-utils" import { setMockResponse, urls, factories } from "api/test-utils" import { userQueries } from "api/hooks/user" const makeUser = factories.user.user describe("ReloadOnUserChange", () => { - const originalLocation = window.location - beforeEach(() => { - Object.defineProperty(window, "location", { - configurable: true, - value: { ...originalLocation, reload: jest.fn() }, - }) - }) - afterEach(() => { - Object.defineProperty(window, "location", { - configurable: true, - value: originalLocation, - }) - }) + setupLocationMock() const user1 = makeUser() const user2 = makeUser() diff --git a/frontends/main/src/page-components/SignupPopover/SignupPopover.tsx b/frontends/main/src/page-components/SignupPopover/SignupPopover.tsx index 6e0da9c580..98e38f498c 100644 --- a/frontends/main/src/page-components/SignupPopover/SignupPopover.tsx +++ b/frontends/main/src/page-components/SignupPopover/SignupPopover.tsx @@ -35,7 +35,11 @@ const SignupPopover: React.FC = (props) => { const loginUrl = useAuthToCurrent() return ( - + Join {SITE_NAME} for free. As a member, get personalized recommendations, curate learning lists, diff --git a/frontends/main/src/test-utils/index.tsx b/frontends/main/src/test-utils/index.tsx index 6658fc8c61..35dfad8e41 100644 --- a/frontends/main/src/test-utils/index.tsx +++ b/frontends/main/src/test-utils/index.tsx @@ -1,9 +1,8 @@ /* eslint-disable import/no-extraneous-dependencies */ import React from "react" -import { QueryClientProvider } from "@tanstack/react-query" +import { QueryClientProvider, QueryClient } from "@tanstack/react-query" import { ThemeProvider } from "ol-components" import { Provider as NiceModalProvider } from "@ebay/nice-modal-react" -import type { QueryClient } from "@tanstack/react-query" import { makeBrowserQueryClient } from "@/app/getQueryClient" import { render } from "@testing-library/react" @@ -65,8 +64,9 @@ const renderWithProviders = ( const allOpts = { ...defaultTestAppOptions, ...options } const { url } = allOpts - const queryClient = makeBrowserQueryClient() - + const queryClient = makeBrowserQueryClient({ + maxRetries: 0, + }) if (allOpts.user) { const user = { ...defaultUser, ...allOpts.user } queryClient.setQueryData(userQueries.me().queryKey, { ...user }) @@ -265,6 +265,32 @@ class TestingErrorBoundary extends React.Component< } } +/** + * JSDOM doesn't support window.location very well; this lets us mock it if + * needed. + */ +const setupLocationMock = () => { + const originalLocation = window.location + + beforeAll(() => { + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: { ...originalLocation, assign: jest.fn(), reload: jest.fn() }, + }) + }) + + afterAll(() => { + Object.defineProperty(window, "location", { + configurable: true, + enumerable: true, + value: originalLocation, + }) + }) + + return jest.mocked(window.location) +} + export { renderWithProviders, renderWithTheme, @@ -275,6 +301,7 @@ export { getMetas, assertPartialMetas, TestingErrorBoundary, + setupLocationMock, } // Conveniences export { setMockResponse } diff --git a/frontends/main/src/test-utils/withFakeLocation.ts b/frontends/main/src/test-utils/withFakeLocation.ts deleted file mode 100644 index 0f4b521b41..0000000000 --- a/frontends/main/src/test-utils/withFakeLocation.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * JSDOM doesn't support changes to window.location, and Jest can't directly spy - * on its properties because window.location is not configurable. - * - * This temporarily re-defines window.location to a plain object, which allows - * us to spy on its properties. - */ -const withFakeLocation = async ( - cb: () => Promise | void, -): Promise => { - const originalLocation = window.location - // @ts-expect-error We're deleting a required property, but we're about to re-assign it. - delete window.location - try { - // copying an object with spread converts getters/setters to normal properties - window.location = { ...originalLocation } - await cb() - return window.location - } finally { - window.location = originalLocation - } -} - -export { withFakeLocation } diff --git a/frontends/ol-components/src/components/Dialog/Dialog.tsx b/frontends/ol-components/src/components/Dialog/Dialog.tsx index f45d5d1706..fce3aef048 100644 --- a/frontends/ol-components/src/components/Dialog/Dialog.tsx +++ b/frontends/ol-components/src/components/Dialog/Dialog.tsx @@ -75,6 +75,8 @@ type DialogProps = { PaperProps?: MuiDialogProps["PaperProps"] actions?: React.ReactNode disableEnforceFocus?: MuiDialogProps["disableEnforceFocus"] + maxWidth?: MuiDialogProps["maxWidth"] + disabled?: boolean } /** @@ -99,6 +101,8 @@ const Dialog: React.FC = ({ isSubmitting = false, PaperProps, disableEnforceFocus, + maxWidth, + disabled = false, }) => { const [confirming, setConfirming] = useState(isSubmitting) const titleId = useId() @@ -125,6 +129,7 @@ const Dialog: React.FC = ({ PaperProps={PaperProps} TransitionComponent={Transition} aria-labelledby={titleId} + maxWidth={maxWidth} > @@ -153,7 +158,7 @@ const Dialog: React.FC = ({ variant="primary" type="submit" onClick={onConfirm && handleConfirm} - disabled={confirming || isSubmitting} + disabled={confirming || isSubmitting || disabled} > {confirmText} diff --git a/frontends/ol-components/src/components/FormDialog/FormDialog.tsx b/frontends/ol-components/src/components/FormDialog/FormDialog.tsx index ad2cbefb0b..5815dd39ca 100644 --- a/frontends/ol-components/src/components/FormDialog/FormDialog.tsx +++ b/frontends/ol-components/src/components/FormDialog/FormDialog.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react" +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import styled from "@emotion/styled" import { Dialog } from "../Dialog/Dialog" import type { DialogProps } from "../Dialog/Dialog" @@ -60,6 +60,8 @@ interface FormDialogProps { fullWidth?: boolean className?: string + maxWidth?: DialogProps["maxWidth"] + disabled?: boolean } /** @@ -85,6 +87,8 @@ const FormDialog: React.FC = ({ confirmText = "Submit", cancelText = "Cancel", className, + maxWidth, + disabled = false, }) => { const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit: React.FormEventHandler = useCallback( @@ -112,9 +116,10 @@ const FormDialog: React.FC = ({ return props }, [handleSubmit, noValidate]) + const handleReset = useRef(onReset) useEffect(() => { - onReset() - }, [open, onReset]) + handleReset.current?.() + }, [open]) return ( = ({ className={className} PaperProps={paperProps} actions={actions} + maxWidth={maxWidth} + disabled={isSubmitting || disabled} > {children} diff --git a/frontends/ol-components/src/components/SimpleSelect/SimpleSelect.tsx b/frontends/ol-components/src/components/SimpleSelect/SimpleSelect.tsx index 117858120e..d21066e3a2 100644 --- a/frontends/ol-components/src/components/SimpleSelect/SimpleSelect.tsx +++ b/frontends/ol-components/src/components/SimpleSelect/SimpleSelect.tsx @@ -46,8 +46,8 @@ const SimpleSelect: React.FC = ({ options, ...others }) => { ) } -type SimpleSelectFieldProps = Pick< - SelectFieldProps, +type SimpleSelectFieldProps = Pick< + SelectFieldProps, | "fullWidth" | "label" | "helpText" @@ -71,10 +71,10 @@ type SimpleSelectFieldProps = Pick< * A form field for text input via select dropdowns. Supports labels, help text, * error text, and start/end adornments. */ -const SimpleSelectField: React.FC = ({ +const SimpleSelectField = function ({ options, ...others -}) => { +}: SimpleSelectFieldProps) { return ( {options.map(({ value, label, ...itemProps }) => (